Python进阶与并发编程01:报价为什么不能随手改——对象、引用与精确金额 Python进阶与并发编程01报价为什么不能随手改——对象、引用与精确金额你已经会写列表、字典和函数。现在有一个真实后端需求用户购买KB-001系统向几家供应商询价选出能供货且含运费最便宜的一家。功能第一天似乎只需要min(prices)第二天就有人反馈“昨天查两件还是这个价今天查一件怎么把两件的结果也改了”这类错误并不需要线程才会发生只要两个变量指向同一个可变对象普通同步程序也会踩中。本系列的项目叫 QuoteFlow。本课先把不会因并发而改变的业务规则讲清楚金额怎么算库存怎样筛选数据归谁所有同价时怎样排序。后面无论换成 HTTP、线程池还是协程都要保留这些规则。环境只需要 Python 3.11本课没有网络请求或第三方依赖。先手算一次建立可检查的目标供应商报的是单价、运费和可用库存。金额统一使用整数分运费按本次订单收一次。供应商单价/分运费/分库存买1件总价买2件总价atlas129006001001350026400boreal125009001001340025900cedar131000113100不可售买一件时 cedar 胜出买两件时 cedar 不够货boreal 胜出。公式是unit_cents * quantity shipping_cents不能写成(unit_cents shipping_cents) * quantity。请求数量必须是 1–100 的整数。库存不足可以是正常业务状态不要为了凑齐三家而把它放进最低价排名。图里最重要的箭头是“生成新结果”。原始报价代表供应商告诉我们的事实按两件计算出的总价是当前查询的派生结果。这两种数据不应该混装在同一个可变字典中。名字不是装值的独立盒子先在交互解释器运行这个反例original{supplier:atlas,shipping_cents:600}for_promotionoriginal 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(frozenTrue,slotsTrue)classQuote:supplier:strsku:strunit_cents:intshipping_cents:intstock:intfrozenTrue使普通属性赋值被拒绝。如果促销要免运费我们写replace(quote, shipping_cents0)得到一个新Quote。原来的报价继续保持 600 分运费任何已经持有它的调用方都不会被促销逻辑悄悄影响。这里的“不可变”是常规使用下的字段不可重新绑定不是 Python 级别的绝对安全隔离而且 frozen 不会递归冻结嵌套列表。所以本模型特意只保存字符串和整数。slotsTrue限制实例通常可拥有的属性也通常减少每个实例的额外存储。它不是正确性的核心不能把它当作性能优化的万能开关。它还有一个教学好处写错字段名不会静悄悄创建新字段。例如把shipping_cents写成shiping_cents应该尽早暴露。类型提示unit_cents: int方便人和类型检查器理解接口但 Python 运行时不会因为这行注解而禁止传入字符串。因此完整代码在__post_init__中做校验。这个方法在 dataclass 生成的初始化完成后执行非常适合检查“一个对象一旦创建就必须满足”的约束。本例允许零单价、零运费和零库存但拒绝负数金额精度不是靠“先接受任意值以后再补救”。一个容易忽略的边界是boolPython 中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 PowerShellpy-3.11 lessons/01/quote_model.py py-3.11-m unittest discover-s lessons/01-ptest_*.py-vLinux/macOS 使用python3.11替换py -3.11。完整脚本输出如下quantity1 [(cedar, 13100), (boreal, 13400), (atlas, 13500)] quantity2 [(boreal, 25900), (atlas, 26400)] original_shipping600 new_shipping0前两行验证手算目标后两行验证replace创建新值。测试还覆盖布尔数量、负金额、其他 SKU、库存为零以及同价结果是否独立于输入顺序。测试的期望值是手算出来的字面量而不是调用同一套公式重新计算期望否则公式写错时测试与实现可能一起错。本课没有测试 dataclass 能否工作而是在测试我们交给它的业务约束。比如把stock quantity错写成stock quantity库存恰好一件的 cedar 就会被错误排除“买一件”的断言会立即失败。这样的测试才有保护规则的作用。练习 E01数量变化与相同总价使用本课Quote与rank_quotes单独写一个可运行脚本完成以下验收对标准三家报价分别查询 quantity1 和 quantity2。输出必须依次为[(cedar,13100),(boreal,13400),(atlas,13500)]与[(boreal,25900),(atlas,26400)]。构造输入顺序为 boreal、atlas 的两条报价boreal 单价1100分、运费100分、库存10atlas 单价1000分、运费300分、库存10SKU都是KB-001。quantity2 时两家均为2300分必须先 atlas 后 boreal反转输入后结果仍相同。使用断言证明查询没有修改标准报价的供应商顺序。不要通过原地排序来实现。下一篇提供完整答案lessons/02/answer_previous.py并解释为什么“排序稳定”和“结果可重复”不是同一个要求。完整可运行代码将下面每个代码块保存到标注路径目录结构与课程下载包一致。测试是可执行验收材料日常运行只需quote_model.py。后续课程保留本模型的独立快照避免要求读者通过数字目录做跨课导入。lessons/01/quote_model.pyImmutable quote values and deterministic, integer-money ranking.fromdataclassesimportdataclass,replacefromtypingimportIterabledefvalidate_quantity(quantity:int)-None:iftype(quantity)isnotintornot1quantity100:raiseValueError(quantity must be an integer from 1 to 100)dataclass(frozenTrue,slotsTrue)classQuote:supplier:strsku:strunit_cents:intshipping_cents:intstock:intdef__post_init__(self)-None:fornamein(supplier,sku):valuegetattr(self,name)ifnotisinstance(value,str)ornotvalue.strip():raiseValueError(f{name}must be a nonempty string)fornamein(unit_cents,shipping_cents,stock):valuegetattr(self,name)iftype(value)isnotintorvalue0:raiseValueError(f{name}must be a nonnegative integer)dataclass(frozenTrue,slotsTrue)classPricedQuote:quote:Quote total_cents:intdefrank_quotes(quotes:Iterable[Quote],quantity:int,sku:strKB-001)-list[PricedQuote]:validate_quantity(quantity)priced[PricedQuote(quote,quote.unit_cents*quantityquote.shipping_cents)forquoteinquotesifquote.skuskuandquote.stockquantity]returnsorted(priced,keylambdaitem:(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:quotessample_quotes()forquantityin(1,2):rankedrank_quotes(quotes,quantity)print(fquantity{quantity},[(p.quote.supplier,p.total_cents)forpinranked])free_shippingreplace(quotes[0],shipping_cents0)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.pyimportunittestfromdataclassesimportFrozenInstanceError,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):onerank_quotes(self.quotes,1)tworank_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(quantityquantity),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(changeschanges),self.assertRaises(ValueError):replace(self.quotes[0],**changes)deftest_replacement_does_not_change_original(self):originalself.quotes[0]adjustedreplace(original,shipping_cents0)self.assertEqual(original.shipping_cents,600)self.assertEqual(adjusted.shipping_cents,0)withself.assertRaises(FrozenInstanceError):original.stock999deftest_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()