AI 技术博客
返回首页
Python 基础 27 分钟阅读

实战:订单与库存的对象建模

Python面向对象订单库存实战

引言

本章前八篇把面向对象的语法讲完了:第 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 用继承覆盖实现可替换的折扣策略(多态)。金额全程使用整数分,从根源避开浮点误差。建模的核心思想可以浓缩为一句:数据只能通过对象自己的方法改变,失败必须留下可恢复的信息(异常字段),成功与失败之间不允许存在中间状态。

练习与思考题

  1. 给单价加校验:用 property 给 Product 增加 price_fen 的非负正整数校验(提示:与 stock 的 setter 写法完全对称)。思考:为什么单价校验放在 setter 而不放在 __init__ 里就够?
  2. 实现满减券:定义 FullReductionDiscount(Discount),规则为满 10000 分(100 元)减 300 分。只需覆盖 apply 与 describe 两个方法,然后把它与 MemberDiscount 分别传给 Order,回答”满减叠加会员折扣”该由谁负责(提示:策略对象可以组合,但那是后续章节的话题)。
  3. 实现取消订单:给 Order 增加 cancel() 方法:仅当状态为「待支付」时,遍历订单项把数量加回商品库存,并把状态置为 CANCELLED;否则抛 ValueError。写完后对照 place() 想想:取消是否也需要”两阶段”保护?
  4. 思考题:两阶段提交在单进程内完美解决问题,但真实电商系统并发下单时,两个请求可能同时通过校验。要堵住这个窗口需要什么机制(提示:锁或数据库行级锁,属于并发章节的内容)?先把”为什么需要”想清楚,学并发时你会豁然开朗。