Python进阶与并发编程01:报价为什么不能随手改——对象、引用与精确金额
你已经会写列表、字典和函数。现在有一个真实后端需求:用户购买KB-001,系统向几家供应商询价,选出能供货且含运费最便宜的一家。功能第一天似乎只需要min(prices),第二天就有人反馈:“昨天查两件还是这个价,今天查一件怎么把两件的结果也改了?”这类错误并不需要线程才会发生;只要两个变量指向同一个可变对象,普通同步程序也会踩中。
本系列的项目叫 QuoteFlow。本课先把不会因并发而改变的业务规则讲清楚:金额怎么算,库存怎样筛选,数据归谁所有,同价时怎样排序。后面无论换成 HTTP、线程池还是协程,都要保留这些规则。环境只需要 Python 3.11,本课没有网络请求或第三方依赖。
先手算一次,建立可检查的目标
供应商报的是单价、运费和可用库存。金额统一使用整数分,运费按本次订单收一次。
| 供应商 | 单价/分 | 运费/分 | 库存 | 买1件总价 | 买2件总价 |
|---|---|---|---|---|---|
| atlas | 12900 | 600 | 100 | 13500 | 26400 |
| boreal | 12500 | 900 | 100 | 13400 | 25900 |
| cedar | 13100 | 0 | 1 | 13100 | 不可售 |
买一件时 cedar 胜出;买两件时 cedar 不够货,boreal 胜出。公式是unit_cents * quantity + shipping_cents,不能写成(unit_cents + shipping_cents) * quantity。请求数量必须是 1–100 的整数。库存不足可以是正常业务状态,不要为了凑齐三家而把它放进最低价排名。
图里最重要的箭头是“生成新结果”。原始报价代表供应商告诉我们的事实;按两件计算出的总价是当前查询的派生结果。这两种数据不应该混装在同一个可变字典中。
名字不是装值的独立盒子
先在交互解释器运行这个反例:
original={"supplier":"atlas","shipping_cents":600}for_promotion=original for_promotion["shipping_cents"]=0print(original["shipping_cents"])# 0赋值把for_promotion绑定到同一个字典,并没有克隆字典。original is for_promotion判断的是身份,此时为真;==判断的是值是否相等。调试共享状态问题时,把“同一个对象”和“内容相同的两个对象”混在一起,会让你找错方向。
你也许会改成for_promotion = original.copy()。当前字典只有整数和字符串,这样通常够用;但浅复制只复制最外层容器。如果字典新增tags=["standard"],两个字典里的tags仍可能指向同一个列表。deepcopy可以递归复制更多对象,却不能替你决定哪些事实可以修改、哪些资源可以复制。数据库连接、锁和网络客户端尤其不是“深复制一份”就能获得新所有权。我们的报价模型只放简单值,并让修改以新对象的形式出现。
列表也有相同问题。sorted(quotes)创建新列表,quotes.sort()修改原列表;两者都不会自动复制列表里的报价对象。选择sorted能避免影响调用方的列表顺序,但若元素仍是可变字典,元素字段依然能被改动。这正是本课同时处理容器和元素的原因。
用 dataclass 表达一份报价事实
Quote有五个字段,分别对应供应商、SKU、单价、运费和库存。@dataclass会生成初始化方法、方便调试的字符串表示和按字段比较的相等判断;它减少的是重复样板,不会自动理解业务。
@dataclass(frozen=True,slots=True)classQuote:supplier:strsku:strunit_cents:intshipping_cents:intstock:intfrozen=True使普通属性赋值被拒绝。如果促销要免运费,我们写replace(quote, shipping_cents=0),得到一个新Quote。原来的报价继续保持 600 分运费,任何已经持有它的调用方都不会被促销逻辑悄悄影响。这里的“不可变”是常规使用下的字段不可重新绑定,不是 Python 级别的绝对安全隔离;而且 frozen 不会递归冻结嵌套列表。所以本模型特意只保存字符串和整数。
slots=True限制实例通常可拥有的属性,也通常减少每个实例的额外存储。它不是正确性的核心,不能把它当作性能优化的万能开关。它还有一个教学好处:写错字段名不会静悄悄创建新字段。例如把shipping_cents写成shiping_cents应该尽早暴露。
类型提示unit_cents: int方便人和类型检查器理解接口,但 Python 运行时不会因为这行注解而禁止传入字符串。因此完整代码在__post_init__中做校验。这个方法在 dataclass 生成的初始化完成后执行,非常适合检查“一个对象一旦创建就必须满足”的约束。本例允许零单价、零运费和零库存,但拒绝负数;金额精度不是靠“先接受任意值,以后再补救”。
一个容易忽略的边界是bool:Python 中isinstance(True, int)为真。如果接口要求严格的数量整数,True不能意外代表买一件。所以这里使用type(value) is int,而不是宽泛的isinstance(value, int)。这是针对业务输入的有意选择;需要接收 NumPy 整数等类型的库可能会采用不同规则,不能机械照搬。
金额为什么不存 float
二进制浮点数无法精确表示所有十进制小数,常见实验是0.1 + 0.2 == 0.3为假。报价累计、比较和审计需要一致规则,在只有“分”这一精度的合同中,整数既直接又足够。12900表示 129.00 元,不是 12900 元;变量名里的_cents能把单位带到调用处。
也不要把int(float_text * 100)当成可靠的人民币解析器。若上游返回十进制金额字符串,应在接入边界使用Decimal并定义保留几位、怎样舍入,再转换为分。本系列的上游合同已经提供整数分,所以核心模型无需承受两套单位。展示给人看时才格式化单位,排序时仍比较整数。
使用整数并不意味着金额的全部问题都解决了。多币种汇率、税费、优惠顺序和单项舍入属于新的业务规则;本课没有实现这些规则。保持范围清楚比提前塞进一套货币框架更有助于学习对象设计。
原始报价和当前查询结果分开
PricedQuote包含quote和total_cents。这个小对象使调用方能同时看到原始单价和当前数量对应的总价,而不需要修改Quote。rank_quotes接受报价可迭代对象、数量和 SKU,先校验数量,再过滤 SKU 和库存,最后计算并排序。
为什么先校验数量,再进入列表推导式?如果校验藏在循环里,空输入不会执行循环,rank_quotes([], 0)就可能悄悄返回空列表。非法请求不应该因为供应商恰好为空就突然合法。本课的测试特意使用空输入验证这条边界,避免测试只覆盖“有报价”的顺利路径。
排序键是lambda item: (item.total_cents, item.quote.supplier)。Python 按元组从左向右比较:先比较总价,只有相同时再比较供应商名称。这是明确的业务平局规则。Python 的排序本身稳定,只表示键相等时保留原先顺序;如果原先顺序将来由网络完成顺序决定,仅依赖“稳定排序”仍会让相同价格的结果时前时后。把名称放进键,才能消除到达顺序的影响。本系列默认供应商名使用小写 ASCII 标识,排序不是给中文展示名称做语言学排序。
本模型允许把 Iterable 传进来,后续生成器也能使用;但完整排序需要收集可售结果,内存开销随可售报价数量增长。三家供应商没有问题,千万行离线数据就要考虑只求最小值或外部排序。一个函数能接生成器,不等于整个函数自动拥有常量内存。
运行并解释结果
以下命令均从课程根目录python-advanced-concurrency-course执行。Windows PowerShell:
py-3.11 lessons/01/quote_model.py py-3.11-m unittest discover-s lessons/01-p"test_*.py"-vLinux/macOS 使用python3.11替换py -3.11。完整脚本输出如下:
quantity=1 [('cedar', 13100), ('boreal', 13400), ('atlas', 13500)] quantity=2 [('boreal', 25900), ('atlas', 26400)] original_shipping=600 new_shipping=0前两行验证手算目标,后两行验证replace创建新值。测试还覆盖布尔数量、负金额、其他 SKU、库存为零,以及同价结果是否独立于输入顺序。测试的期望值是手算出来的字面量,而不是调用同一套公式重新计算期望;否则公式写错时,测试与实现可能一起错。
本课没有测试 dataclass 能否工作,而是在测试我们交给它的业务约束。比如,把stock >= quantity错写成stock > quantity,库存恰好一件的 cedar 就会被错误排除;“买一件”的断言会立即失败。这样的测试才有保护规则的作用。
练习 E01:数量变化与相同总价
使用本课Quote与rank_quotes,单独写一个可运行脚本,完成以下验收:
- 对标准三家报价分别查询 quantity=1 和 quantity=2。输出必须依次为
[("cedar",13100),("boreal",13400),("atlas",13500)]与[("boreal",25900),("atlas",26400)]。 - 构造输入顺序为 boreal、atlas 的两条报价:boreal 单价1100分、运费100分、库存10;atlas 单价1000分、运费300分、库存10;SKU都是
KB-001。quantity=2 时两家均为2300分,必须先 atlas 后 boreal;反转输入后结果仍相同。 - 使用断言证明查询没有修改标准报价的供应商顺序。不要通过原地排序来实现。
下一篇提供完整答案lessons/02/answer_previous.py,并解释为什么“排序稳定”和“结果可重复”不是同一个要求。
完整可运行代码
将下面每个代码块保存到标注路径;目录结构与课程下载包一致。测试是可执行验收材料,日常运行只需quote_model.py。后续课程保留本模型的独立快照,避免要求读者通过数字目录做跨课导入。
lessons/01/quote_model.py
"""Immutable quote values and deterministic, integer-money ranking."""fromdataclassesimportdataclass,replacefromtypingimportIterabledefvalidate_quantity(quantity:int)->None:iftype(quantity)isnotintornot1<=quantity<=100:raiseValueError("quantity must be an integer from 1 to 100")@dataclass(frozen=True,slots=True)classQuote:supplier:strsku:strunit_cents:intshipping_cents:intstock:intdef__post_init__(self)->None:fornamein("supplier","sku"):value=getattr(self,name)ifnotisinstance(value,str)ornotvalue.strip():raiseValueError(f"{name}must be a nonempty string")fornamein("unit_cents","shipping_cents","stock"):value=getattr(self,name)iftype(value)isnotintorvalue<0:raiseValueError(f"{name}must be a nonnegative integer")@dataclass(frozen=True,slots=True)classPricedQuote:quote:Quote total_cents:intdefrank_quotes(quotes:Iterable[Quote],quantity:int,sku:str="KB-001")->list[PricedQuote]:validate_quantity(quantity)priced=[PricedQuote(quote,quote.unit_cents*quantity+quote.shipping_cents)forquoteinquotesifquote.sku==skuandquote.stock>=quantity]returnsorted(priced,key=lambdaitem:(item.total_cents,item.quote.supplier))defsample_quotes()->tuple[Quote,...]:return(Quote("atlas","KB-001",12900,600,100),Quote("boreal","KB-001",12500,900,100),Quote("cedar","KB-001",13100,0,1),)defmain()->None:quotes=sample_quotes()forquantityin(1,2):ranked=rank_quotes(quotes,quantity)print(f"quantity={quantity}",[(p.quote.supplier,p.total_cents)forpinranked])free_shipping=replace(quotes[0],shipping_cents=0)print("original_shipping=",quotes[0].shipping_cents,sep="")print("new_shipping=",free_shipping.shipping_cents,sep="")if__name__=="__main__":main()lessons/01/test_quote_model.py
importunittestfromdataclassesimportFrozenInstanceError,replacefromquote_modelimportQuote,rank_quotesclassQuoteRulesTests(unittest.TestCase):defsetUp(self):self.quotes=[Quote("atlas","KB-001",12900,600,100),Quote("boreal","KB-001",12500,900,100),Quote("cedar","KB-001",13100,0,1),]deftest_quantity_changes_eligibility_and_total(self):one=rank_quotes(self.quotes,1)two=rank_quotes(self.quotes,2)self.assertEqual([(p.quote.supplier,p.total_cents)forpinone],[("cedar",13100),("boreal",13400),("atlas",13500)])self.assertEqual([(p.quote.supplier,p.total_cents)forpintwo],[("boreal",25900),("atlas",26400)])self.assertEqual([q.supplierforqinself.quotes],["atlas","boreal","cedar"])deftest_equal_totals_use_supplier_not_arrival_order(self):quotes=[Quote("boreal","KB-001",1100,100,10),Quote("atlas","KB-001",1000,300,10)]self.assertEqual([p.quote.supplierforpinrank_quotes(quotes,2)],["atlas","boreal"])deftest_reject_invalid_quantity_even_when_no_quotes(self):forquantityin[0,101,True,1.5,"2"]:withself.subTest(quantity=quantity),self.assertRaises(ValueError):rank_quotes([],quantity)deftest_reject_invalid_money_and_stock(self):forchangesin[{"unit_cents":-1},{"unit_cents":True},{"shipping_cents":1.5},{"stock":-1}]:withself.subTest(changes=changes),self.assertRaises(ValueError):replace(self.quotes[0],**changes)deftest_replacement_does_not_change_original(self):original=self.quotes[0]adjusted=replace(original,shipping_cents=0)self.assertEqual(original.shipping_cents,600)self.assertEqual(adjusted.shipping_cents,0)withself.assertRaises(FrozenInstanceError):original.stock=999deftest_other_sku_and_no_stock_have_no_best_offer(self):quotes=[Quote("atlas","OTHER",1,0,100),Quote("boreal","KB-001",1,0,0)]self.assertEqual(rank_quotes(quotes,1),[])if__name__=="__main__":unittest.main()