设计模式 09:继承成功,为什么预留对象仍不能替换
基类 Before 允许库存为 3 时预留 1 件:成功后库存剩 2,已预留量为 1;数量为 0、库存不足和重复预留会被拒绝,拒绝后状态不变。四个子类都能写 extends Before,代码也能编译,但它们分别改变了“允许什么输入”“成功必须保证什么”“状态始终合法”和“状态在多次操作中能否倒退”。这四种问题不能用同一个“类型匹配成功”来证明没事。
让客户端合同,而不是子类名称,决定可替换性
Liskov 与 Wing 的 A Behavioral Notion of Subtyping把类型约定中的方法前后条件、不变量以及跨状态的历史约束放在一起讨论。文中用容器的边界示例区别某个状态合法与两个状态之间保持的性质;不能把历史约束缩减为一次方法返回时的布尔值。Java SE 21 JLS §8.4.8.1说明实例方法在语言层面如何重写,但 @Override 不会验证这种行为合同。
实验把调用放进同一套可失败断言。在独立的新对象上先预留 1,检查库存 2、已预留 1、重复预留被拒且库存不变;再检查数量 0 和请求 4 均拒绝且状态保持。另对已预留对象执行实现所允许的附加动作,再检查“只要没有定义取消,成功预留后仍保持预留”。合格的基类、使用 03 篇 Order+Inventory 的组合方案,以及复用 03 单对象版本的简单替代都运行这套合同。负例也运行同一函数,测试明确预期它触发 JUnit 断言失败;绿色构建并不等于负例合格。
| 子类反例 | 被破坏的合同 | 输入与实际状态 |
|---|---|---|
StrongerPrecondition |
收紧前置条件 | 基类接受 reserve(1),子类要求至少 2 件并拒绝 |
WeakerPostcondition |
放松成功后置条件 | 扣库存后不记录预留量:库存 2,预留仍 0 |
BrokenInvariant |
破坏库存不变量与失败约定 | 库存 3 请求 4 未拒绝,库存变为 -1 |
ResettableHistory |
破坏跨操作的历史约束 | 先预留 1,再经子类新方法 reset() 清除预留、返还库存;当前数值合法,但预留历史被抹去 |
sequenceDiagram
participant Client as 持有 Before 引用的客户端
participant Sub as ResettableHistory
Client->>Sub: reserve(1)
Sub-->>Client: available=2, reserved=1
Note over Sub: 另一调用方持有同一子类对象
Sub->>Sub: reset()
Client->>Sub: isReserved()
Sub-->>Client: false, available=3 (历史约定被破坏)
ResettableHistory 尤其容易漏掉:它没有覆写 reserve;子类独有的 reset 把库存返还、预留量清零,当前数值仍满足“非负、剩余加预留等于初始库存”。但持有基类引用的客户端后来看到“已成功预留”变成“未预留”,违反了它可依赖的时间约定。这个例子的历史约束是本地教学 API 明确选定的“本章无取消操作,预留标志不会逆转”,不是论文对所有订单系统的通用禁令。若业务需要取消,应先修改超类型合同、明确释放库存和重复取消语义,再让所有实现通过新合同;不应让某个子类偷偷改变历史。
1 | |
基类为了复现违约暴露了 protected 状态,子类更容易绕过检查。修正版 After 不靠从 Before 继承:订单与库存分别遵守 03 篇的边界;另一种合格的 Alternative 直接复用 03 的单对象实现。组合没有自动证明任何未来行为正确,它只是缩小了当前实验里可随意覆写单条核心路径的范围。该设计不会自动提供并发、数据库回滚或跨进程库存一致性。
验证与练习
2026-10-02 在 Ubuntu OpenJDK 21.0.12.1、Maven Wrapper 3.9.9 下运行 ./mvnw -B -ntp verify,退出码 0;09 的 7 次测试与 00–08 的 67 次回归均通过。原始命令、输入、异常和输出见 examples/design-patterns/evidence/09/;每个反例通过对同一个客户端合同函数产生断言失败来验证,没有静默跳过。
- 新需求允许“取消已成功预留一次,并完整返还库存”。在超类型合同中新增取消前后的库存、预留量及重复取消状态不变的约定,再让
Before、After和Alternative运行同一测试。此时“预留永不回退”的历史约束应如何修改?不要只给一个子类加reset()。 - 写一个新子类:成功预留时把库存正确扣 1,已预留量却设为 2。分别指出哪条后置条件和哪条不变量会失败;让第 09 篇的同一合同检测它,而不是给这个新类编一套偏宽松测试。
参考与系列导航
- Liskov、Wing:A Behavioral Notion of Subtyping:前后条件、不变量与历史约束的第一手论文。
- Java SE 21 JLS §8.4.8.1:语言层面实例方法的重写判定。
- 08 OCP 开闭原则 ← 09 LSP 里氏替换(本篇) → 10 ISP 接口隔离。





