引言
本章前八篇把面向对象的语法讲完了:第 1 篇的类与实例、第 2 篇的 __init__ 与实例属性、第 3 篇的三种方法、第 4 篇的封装与 property、第 5 篇的继承、第 6 篇的多态与鸭子类型、第 7 篇的魔法方法,第 8 篇还用图书管理系统做了一次整体演练。但”会写类”和”会建模”(modeling)是两回事:真实业务里对象之间互相协作,有状态流转,有失败路径,还要回答”金额到底怎么存”这种工程问题。本篇用「订单与库存」这个高频业务场景,把本章全部概念串成一个小而完整的系统——读完你拿到的不是一堆语法演示,而是一份可以运行、可以继续扩展的建模样板。
读者应先掌握第 1 至 8 篇的全部内容:property 的读写拦截、自定义异常(第四章第 8 篇的 raise 与自定义异常)与类的三种方法。本篇全部代码用本机 CPython 3.12.10(Windows)实测运行,输出与下文逐字一致。
概念与原理:对象建模的四个角色
建模的第一件事是划分责任。订单系统里至少存在四类对象,它们的职责边界不同,这也是真实工程里最常见的划分:
数据对象(data object):描述”一件商品是什么”,如名称、单价、库存。这类对象只持有事实,不做业务判断。对应书里的 Product。
值对象(value object):描述”一笔交易的一个组成部分”,如”2 盒鲜牛奶”。它最重要的本领是自己能算出小计(subtotal),因为”单价 × 数量”这笔账只属于它自己。把计算放在数据旁边,而不是散落在外部函数里,是面向对象的第一收益:规则只写一处,改价、改算式时只动一个类。对应 OrderItem。
流程对象(process object):编排多个对象的协作,掌握全局状态。Order 知道自己收了几件商品、当前处于什么状态、什么时候允许下一步。流程对象是”规则执行者”,但它不重复别人会算的账——总额它只负责把各订单项的小计加起来。
策略对象(strategy object):一段可以整体替换的行为,如折扣规则。普通订单不打折、会员订单九折——用继承 + 方法覆盖(第 5、6 篇)就能做出这种”换一个对象,行为就变”的效果,调用方完全不用改。
一个判断建模是否合理的经验法则:数据经过对象自己的方法才能改变。库存只能通过 Product 的赋值(setter)改变,下单只能通过 Order.place() 触发,这样任何环节出问题,你都知道去哪个类里找。下面三个设计决策是本篇的核心,先讲透”为什么”,再给实现。
订单状态为什么用类常量
订单的生命周期是典型的状态机(state machine):新建时是「待支付」,扣库存成功后变成「已支付」,取消后(见练习)变成「已取消」。状态值如果散落在代码里写成裸字符串(比如有的地方写 'PAID'、有的地方写 'Paid'),要么运行时报错才知道写错了,要么静默产生两份互不相等的数据。把状态定义为类属性常量,所有代码都引用 Order.PAID,编译器替你检查拼写,改一处全覆盖。本章用简单字符串常量就够;后续章节会介绍更正式的枚举(enum)类型,但那属于类型系统的话题,这里不展开。
金额到底用浮点还是整数分
这是每个做交易系统的开发者都要回答的问题。直觉是”钱有小数,用 float 最方便”,但 IEEE 754 二进制浮点数无法精确表示 0.1——这也是第二章讲 Decimal 时的核心结论。累加越多误差越大,更致命的是 0.1 + 0.2 == 0.3 为假,金额比较就会出错。Decimal 精确但慢,还要管理精度上下文;而整数分(把 6.80 元存成 680 分)是整数运算,精确、可比较、速度快,数据库里也常用整型或 NUMERIC 存储分/厘。本篇演示采用整数分,显示时除以 100 并用 :.2f 格式化;折扣计算也用整数除法(* 9 // 10 实现九折),从根上杜绝浮点尾差。向下滚动,第一步代码就是证据。
下单为什么必须”全成或全不动”
一次下单可能涉及多个商品。如果系统”扣掉牛奶后才发现书不够”,库存就被扣成半截:用户没买到书,牛奶却凭空少了。正确的语义是事务性(transactional)——要么整单成功,要么库存分毫不动。实现上有一条捷径:两阶段提交(two-phase commit):先遍历全部订单项做校验,全部通过后才统一扣减。因为”扣减”只发生在校验全部成功之后,失败路径里根本不存在”已扣了一半”的状态,也就不需要回滚代码。另一种”边扣边验、失败回滚”的做法在真实系统里也存在(比如需要跨多个资源扣减时),但代码更复杂、更容易漏。本篇用两阶段方案,易错点一节会演示”边扣边验却忘了回滚”的后果。
操作与实现
第一步:金额决策的实证
from decimal import Decimal
print(0.1 + 0.2) # 二进制浮点:0.1 无法精确表示
print(Decimal('0.1') + Decimal('0.2')) # Decimal:按十进制精确运算
print(680 * 3) # 整数分:乘法天然精确
运行输出:
0.30000000000000004
0.3
2040
0.1 加 0.2 在二进制浮点里是 0.30000000000000004,因为 0.1 和 0.2 都没有有限二进制表示;Decimal 用十进制运算得到精确的 0.3;而整数分 680 × 3 = 2040 是整数精确算术。这就是本篇所有金额都用分的理由。
第二步:Product 与 property 库存校验
库存是业务约束最强的字段:负数库存没有物理意义。用第 4 篇的 property 把”非负”这个规则焊死在赋值口上——任何人对 stock 赋值都会经过 setter,__init__ 里的 self.stock = stock 同样触发校验,连构造阶段都不可能产生负库存。
class Product:
"""商品:名称、单价(单位:分)、库存数量。库存必须非负。"""
def __init__(self, name, price_fen, stock):
self.name = name
self.price_fen = price_fen
self.stock = stock # 赋值会触发 setter 校验
@property
def stock(self):
return self._stock
@stock.setter
def stock(self, value):
if value < 0:
raise ValueError(f'库存不能为负,收到 {value}')
self._stock = value
milk = Product('鲜牛奶', 680, 10)
print(milk.name, milk.price_fen, '分', milk.stock, '件')
milk.stock = 8
print('补货后:', milk.stock)
try:
milk.stock = -1
except ValueError as e:
print('非法赋值被拦截:', e)
运行输出:
鲜牛奶 680 分 10 件
补货后: 8
非法赋值被拦截: 库存不能为负,收到 -1
注意 setter 抛的是 ValueError 而不是自定义异常——“调用方传了非法值”是编程错误,用标准异常即可;库存不足则是业务失败,下一步我们为它定义专属的异常类型。
第三步:OrderItem 与自定义异常
小计的计算属于 OrderItem 自己,这是值对象的核心职责。同时定义 InsufficientStockError:它继承 Exception,构造时把商品名、请求数、可用数三个字段存下来并拼出人话信息。自定义异常的价值在于类型化:调用方可以用 except InsufficientStockError 精确捕获”库存不足”这一种失败,而不会误吞其它错误(第四章第 8 篇的捕获原则)。
class Product:
"""本节演示用的最简商品:只保存数据,暂不加校验。"""
def __init__(self, name, price_fen, stock):
self.name = name
self.price_fen = price_fen
self.stock = stock
class InsufficientStockError(Exception):
"""库存不足:携带商品名、请求数、可用数,方便上层定位。"""
def __init__(self, product_name, requested, available):
self.product_name = product_name
self.requested = requested
self.available = available
super().__init__(
f'商品「{product_name}」库存不足:请求 {requested} 件,仅剩 {available} 件'
)
class OrderItem:
"""订单项:商品 + 数量,负责计算小计。"""
def __init__(self, product, quantity):
if quantity <= 0:
raise ValueError('数量必须是正整数')
self.product = product
self.quantity = quantity
def subtotal_fen(self):
return self.product.price_fen * self.quantity
chip = Product('薯片', 950, 5)
item = OrderItem(chip, 2)
print('小计:', item.subtotal_fen(), '分 =', f'{item.subtotal_fen() / 100:.2f}', '元')
try:
raise InsufficientStockError('《Python 实战》', 2, 1)
except InsufficientStockError as e:
print('异常类型:', type(e).__name__)
print('异常信息:', e)
print('可恢复信息:', e.product_name, e.requested, e.available)
运行输出:
小计: 1900 分 = 19.00 元
异常类型: InsufficientStockError
异常信息: 商品「《Python 实战》」库存不足:请求 2 件,仅剩 1 件
可恢复信息: 《Python 实战》 2 1
异常对象上挂着的三个字段是用来”恢复”的:上层捕获后可以直接读出是哪个商品、缺几件,用于提示用户改数量或补货,而不必解析错误字符串——这就是异常携带结构化信息的价值。
第四步:Discount 策略与多态
折扣规则会变(会员日八折、满减、新人券……),把它硬编码进 Order 会让订单类越改越胖。正确姿势是定义 Discount 基类,用子类覆盖 apply 实现不同规则。结算函数只依赖”订单提供 total_fen、折扣提供 apply”这两个接口,不关心对象的实际类型——这正是第 6 篇的鸭子类型。下面的代码用一个只实现这两个接口的迷你订单替身(OrderStub)先验证结算逻辑:
class Discount:
"""折扣策略基类:默认不打折,子类覆盖 apply() 决定实付。"""
def apply(self, total_fen):
return total_fen
def describe(self):
return '无折扣'
class MemberDiscount(Discount):
"""会员折扣:九折。用整数分运算,避免浮点尾差。"""
def apply(self, total_fen):
return total_fen * 9 // 10
def describe(self):
return '会员九折'
class OrderStub:
"""只实现 total_fen() 与 discount 两个接口的订单替身,
用来先验证结算逻辑,结构同下文的真实订单。"""
def __init__(self, total_fen, discount):
self._total = total_fen
self.discount = discount
def total_fen(self):
return self._total
def checkout(order):
"""统一结算:不管订单对象来自哪个类,只要接口齐全就能算。"""
original = order.total_fen()
payable = order.discount.apply(original)
print(f'原价 {original / 100:.2f} 元,{order.discount.describe()},应付 {payable / 100:.2f} 元')
checkout(OrderStub(7260, Discount()))
checkout(OrderStub(7260, MemberDiscount()))
运行输出:
原价 72.60 元,无折扣,应付 72.60 元
原价 72.60 元,会员九折,应付 65.34 元
72.60 元的九折是 65.34 元:整数分运算 7260 * 9 // 10 = 6534,精确无尾差。注意 checkout 一个字都没改,只是换了传入的折扣对象,行为就变了——覆盖、多态、鸭子类型在这里三合一。真实订单里的结算逻辑与此完全相同,下一节把 Discount 接入 Order。
第五步:Order 的完整实现
下面是最终合一的版本(紧凑写法,类含义与第二至四步一致):Order 提供两个类常量状态与一个 discount 字段;place() 采用两阶段提交——先遍历所有订单项校验库存,任一不足立即抛 InsufficientStockError 且任何库存都未被改动;校验全部通过后才统一扣减,最后把状态置为「已支付」。
class InsufficientStockError(Exception):
def __init__(self, product_name, requested, available):
super().__init__(f'商品「{product_name}」库存不足:请求 {requested} 件,仅剩 {available} 件')
class Product:
def __init__(self, name, price_fen, stock):
self.name = name
self.price_fen = price_fen
self.stock = stock # 赋值走 setter,负数被拦截
@property
def stock(self):
return self._stock
@stock.setter
def stock(self, value):
if value < 0:
raise ValueError(f'库存不能为负,收到 {value}')
self._stock = value
class OrderItem:
def __init__(self, product, quantity):
if quantity <= 0:
raise ValueError('数量必须是正整数')
self.product = product
self.quantity = quantity
def subtotal_fen(self):
return self.product.price_fen * self.quantity
class Discount:
def apply(self, total_fen):
return total_fen
def describe(self):
return '无折扣'
class MemberDiscount(Discount):
def apply(self, total_fen):
return total_fen * 9 // 10
def describe(self):
return '会员九折'
class Order:
PENDING = '待支付'
PAID = '已支付'
CANCELLED = '已取消'
def __init__(self, customer, discount=None):
self.customer = customer
self.discount = discount or Discount()
self.items = []
self.status = Order.PENDING
def add_item(self, product, quantity):
self.items.append(OrderItem(product, quantity))
def total_fen(self):
return sum(i.subtotal_fen() for i in self.items)
def payable_fen(self):
return self.discount.apply(self.total_fen())
def place(self):
for item in self.items: # 两阶段提交:先整体校验、后统一扣减
if item.quantity > item.product.stock:
raise InsufficientStockError(
item.product.name, item.quantity, item.product.stock)
for item in self.items:
item.product.stock -= item.quantity
self.status = Order.PAID
payable_fen 是”应付金额”,由策略算出;total_fen 是”原价总额”。discount or Discount() 保证不带折扣参数的订单也有一个默认策略对象,checkout 式的多态结算因此始终可用。
第六步:完整运行会话
把上面的类定义与下面的会话代码依次保存到同一个文件中运行,会依次看到三幕:会员下单成功(九折、库存扣减)、库存不足下单失败(库存分毫未动、订单停留在待支付)、查看两个订单的明细与状态。
# 运行会话:把上一块的类定义粘贴到本文件上方,保存后运行
milk = Product('鲜牛奶', 680, 10) # 6.80 元/盒,库存 10
chip = Product('薯片', 950, 5) # 9.50 元/袋,库存 5
book = Product('《Python 实战》', 5900, 2) # 59.00 元/本,库存 2
order1 = Order('林晓', MemberDiscount()) # 会员九折
order1.add_item(milk, 2)
order1.add_item(book, 1)
order1.place()
print(f'会话一:{order1.customer} 下单成功,状态「{order1.status}」')
print(f' 原价 {order1.total_fen() / 100:.2f} 元,{order1.discount.describe()},应付 {order1.payable_fen() / 100:.2f} 元')
print(f' 扣减后库存:{milk.name} {milk.stock} 件,{book.name} {book.stock} 件')
order2 = Order('王远') # 想买两本书,但库存只剩 1 本
order2.add_item(chip, 3)
order2.add_item(book, 2)
try:
order2.place()
except InsufficientStockError as e:
print(f'会话二:下单失败,原因:{e}')
print(f' 失败后库存分毫未动:{chip.name} {chip.stock} 件,{book.name} {book.stock} 件')
print(f' 订单 2 状态:{order2.status}')
print('会话三:订单明细')
for i, order in enumerate([order1, order2], 1):
parts = ';'.join(f'{it.product.name} ×{it.quantity} = {it.subtotal_fen() / 100:.2f} 元'
for it in order.items)
print(f' 订单 {i}({order.customer}):{parts};合计 {order.total_fen() / 100:.2f} 元,{order.status}')
运行输出:
会话一:林晓 下单成功,状态「已支付」
原价 72.60 元,会员九折,应付 65.34 元
扣减后库存:鲜牛奶 8 件,《Python 实战》 1 件
会话二:下单失败,原因:商品「《Python 实战》」库存不足:请求 2 件,仅剩 1 件
失败后库存分毫未动:薯片 5 件,《Python 实战》 1 件
订单 2 状态:待支付
会话三:订单明细
订单 1(林晓):鲜牛奶 ×2 = 13.60 元;《Python 实战》 ×1 = 59.00 元;合计 72.60 元,已支付
订单 2(王远):薯片 ×3 = 28.50 元;《Python 实战》 ×2 = 118.00 元;合计 146.50 元,待支付
三个细节值得回看:第一,订单 2 的合计里仍包含 2 本书(118.00 元),但它的状态是「待支付」——失败订单的订单项只是”账面上的草稿”,库存从未被扣,用户可以改数量后重试,这是两阶段语义的直接体现;第二,place() 成功后才置状态,所以状态值可以当作”扣库存是否已完成”的凭证;第三,会话三的明细打印完全通过属性与方法访问数据,没有触碰任何内部下划线变量——封装在这里不是限制,而是让外部代码永远走对象自己的规则。
易错点与陷阱
1. 用浮点算钱,比较或累加时翻车。 即使单价写成 0.1,三个 0.1 相加也不是 0.3:
total_a = 0.1 * 3 # 三件 0.1 元的商品,数学上等于 0.3
total_b = 0.1 + 0.2
print(total_a == 0.3, total_b == 0.3)
print(f'{total_a:.20f}')
运行输出:
False False
0.30000000000000004441
0.1 * 3 的真值是 0.30000000000000004441:== 判等失败、累计多了还会显贵。金额一律走整数分(或 Decimal),不要用 float。
2. 直接改内部名,绕过 property 校验。 property 拦截的是”通过属性名赋值”的路径;如果有人(包括你自己)直接写 p._stock = -5,setter 不会触发,负库存照样产生:
class Product:
def __init__(self, name, price_fen, stock):
self.name = name
self.price_fen = price_fen
self.stock = stock
@property
def stock(self):
return self._stock
@stock.setter
def stock(self, value):
if value < 0:
raise ValueError('库存不能为负')
self._stock = value
p = Product('鲜牛奶', 680, 10)
p._stock = -5 # 直改内部名,绕过校验
print(p.stock)
运行输出:
-5
单下划线是”约定”不是”封锁”(第 4 篇的封装真相)。工程上的防线是:模块内部也统一走属性赋值,并让带下划线的名字只在类方法内出现。
3. 边扣边验,失败却不回滚。 这是最经典的库存事故。下面的写法先扣了薯片,再发现书不够——如果不补回扣减,库存就悄悄少了:
stock = {'薯片': 5, '《Python 实战》': 1}
stock['薯片'] -= 3 # 先扣了薯片
if 2 > stock['《Python 实战》']: # 书不够,下单注定失败
print('下单失败,但薯片已被扣成', stock['薯片'])
运行输出:
下单失败,但薯片已被扣成 2
两阶段方案(先全查后全扣)从结构上消灭这类错误;若业务确实需要边扣边回滚,务必用 try/except 把已扣的部分逐项加回,再重新抛出异常。
4. 状态值散落成裸字符串,或通过实例”改”类常量。 第一类问题:有的地方写 'paid' 有的写 'PAID',两处不相等,bug 直到上线才暴露——统一引用 Order.PAID。第二类问题更隐蔽:类属性可以被实例”遮蔽”(shadowing):
class Order:
PAID = '已支付'
order = Order()
order.PAID = '已支付!' # 通过实例赋值只会生成实例“影子”属性
print(Order.PAID, order.PAID)
运行输出:
已支付 已支付!
给 order.PAID 赋值并不会改类属性,只是在该实例上新建了一个同名属性,从此该实例读到的”类常量”与其它实例不一致。类常量永远通过类名访问(Order.PAID),不要通过实例写。
小结
本篇把第八章的知识点组装成一个可运行的订单系统:Product 用 property 保证库存非负(封装),OrderItem 自算小计(值对象),Order 用类常量管理状态机、用两阶段提交保证”全成或全不动”(流程与一致性),Discount 用继承覆盖实现可替换的折扣策略(多态)。金额全程使用整数分,从根源避开浮点误差。建模的核心思想可以浓缩为一句:数据只能通过对象自己的方法改变,失败必须留下可恢复的信息(异常字段),成功与失败之间不允许存在中间状态。
练习与思考题
- 给单价加校验:用 property 给 Product 增加
price_fen的非负正整数校验(提示:与 stock 的 setter 写法完全对称)。思考:为什么单价校验放在 setter 而不放在__init__里就够? - 实现满减券:定义
FullReductionDiscount(Discount),规则为满 10000 分(100 元)减 300 分。只需覆盖apply与describe两个方法,然后把它与MemberDiscount分别传给Order,回答”满减叠加会员折扣”该由谁负责(提示:策略对象可以组合,但那是后续章节的话题)。 - 实现取消订单:给 Order 增加
cancel()方法:仅当状态为「待支付」时,遍历订单项把数量加回商品库存,并把状态置为CANCELLED;否则抛ValueError。写完后对照place()想想:取消是否也需要”两阶段”保护? - 思考题:两阶段提交在单进程内完美解决问题,但真实电商系统并发下单时,两个请求可能同时通过校验。要堵住这个窗口需要什么机制(提示:锁或数据库行级锁,属于并发章节的内容)?先把”为什么需要”想清楚,学并发时你会豁然开朗。