23 契约式设计

用前置条件、后置条件和不变量定义双方责任,让契约既可读又能在边界处执行。

学习目标

  • 能为一个接口写出调用前、调用后和始终成立的条件,并标明每项条件的责任人
  • 能用正常、边界和单项故障样本证明一次调用是在履约、拒绝还是实现违约
  • 能在契约被破坏时定位首个拒绝点,保存证据并从同一输入重放修复结果

为什么这一单元不可压缩

契约式设计不是把几句注释放在函数上方,而是把“谁必须先做什么、谁必须交付什么、哪些状态永远不能被破坏”变成双方都能检查的接口。没有这三层约束,调用者只能猜参数含义,实现者也容易把错误结果包装成成功。

本页依据 David Thomas、Andrew Hunt《程序员修炼之道:通向务实的最高境界(第2版)》,云风译,电子工业出版社,2020年4月,ISBN 9787121384356 的公开中文目录,独立重构 23 契约式设计。内容只使用目录来确定范围,示例、代码、图示和练习均为独立改写;它不复制原书正文、插图或答案。

先固定一个小接口:reserveSeat(count)。调用者请求预订座位,实现者更新库存并返回结果。我们不把“成功”当作唯一证据,而是同时观察请求是否满足入口条件、输出是否兑现承诺,以及库存这个状态是否仍然合法。

先把责任写成合同

23 契约式设计

把接口看成一份双方合同。调用者不能把非法输入推给实现者;实现者也不能在检查失败时悄悄截断请求、伪造成功或留下无法解释的状态。

一份可执行的合同至少回答四个问题:进入前谁负责什么,执行成功后必须交付什么,整个过程中哪个状态不能被破坏,以及违反时在哪个边界拒绝。比如 reserveSeat(2) 只有在 count 为正整数且不超过剩余座位时才进入实现;成功返回时,库存减少 2,订单增加 2,库存不能变成负数。

提示37:通过契约进行设计

“通过契约进行设计”不是等代码完成后再补几条断言,而是先写验收条件,再据此安排接口的责任边界。设计阶段就把接受、拒绝和交付写出来,测试与实现才有共同的判定语言。

对同一个请求准备三份样本:正常输入验证完整履约;恰好等于剩余库存的输入验证阈值口径;超过库存的输入验证调用者是否在入口被拒绝。三份样本只改变一个条件,结果差异才可以归因给该条件。

常见误区与回退

一条可执行的边界链

前置条件:进入前的责任

属于调用者的责任。它不是“函数尽量处理”的愿望,而是允许实现开始工作的门槛。reserveSeat(count) 的前置条件可以是:count 是正整数,且不超过当前剩余座位数;调用者还要拥有预订权限。

入口拒绝应当可行动:告诉调用者哪一项条件失败、当前值是什么、如何修正。超过库存的请求应被拒绝,而不是自动变成“预订剩余全部座位”,因为静默截断会改变调用者的意图。

后置条件:出口的承诺

属于实现者的责任。成功不等于“函数走到了 return”,而是所有承诺都已经能被复核。

对于预订接口,后置条件可以写成:返回的订单数量等于请求数量;库存减少同样的数量;订单与库存属于同一个请求身份;下游收到的状态与这三项事实一致。若库存更新成功但订单写入失败,就不能返回完整成功;系统应按既定回退策略处理,并把不完整结果暴露给调用者。

不变量:持续不被破坏的事实

是契约的护栏。它不只检查一次返回值,还约束所有能被其他操作观察到的状态。库存不能为负、订单数量不能小于零、同一预订不能重复扣减,都是可写成检查的例子。

不变量失效时,错误已经属于实现或状态管理问题,而不是要求调用者“再试一次”。实现应在首个可信观测点停止,把对象身份、旧状态、新状态和拒绝原因交给复核者;下游不应接收这个结果。

责任归属:把边界交给具体的人和代码

让契约不会停留在团队共识里。调用者负责提供满足前置条件的请求;实现者负责检查并兑现后置条件;双方共同维护不变量;复核者负责确认日志能从同一输入重建结论。

如果一条条件没有责任人,失败时就会出现“调用者以为实现会兜底、实现以为调用者已经检查”的空档。每个接口还应标出一个 ,把责任写进接口、日志和测试名称,拒绝就会成为可追踪的工程行为,而不是一次争论。

专属图示:五个节点怎样交接责任

下面的图把 reserveSeat(2) 从进入到交付的证据链画出来。先猜一猜:如果实现不再检查订单数量与库存变化是否一致,哪个节点应该先拒绝?图中高亮的是观察位置,不是替你证明真实系统已经满足契约。

契约式设计:把责任放在边界上提示37:通过契约进行设计 · 调用者承诺进入,实现者承诺退出输入:reserveSeat(2)1契约输入、输出、边界双方先说清责任承诺是什么?2前置条件调用者满足调用者负责入口能否开始?3实现执行承诺实现者保持边界做了什么?4后置条件结果可验证实现者负责出口交付了吗?5不变量状态始终合法双方共同守住还能信吗?验收合同:每一段都交出责任、证据和下一段的进入条件只改变一个条件;记录首个拒绝点,不用最终状态倒推原因图示是契约的可观察模型,不是业务成功率统计
好的契约让调用者知道何时可以进入,也让实现者知道何时必须拒绝交付。

三步观察:从条件写到可验证结果

三步都使用同一个请求身份,只改变一个条件。每一步先写预测,再观察图示和日志;若实际首差与预测不同,保留差异并检查观测点,不要把预测改成漂亮的事后解释。

分步1 / 3

1. 写出进入条件

reserveSeat(count) 写明类型、范围、权限和剩余容量。选择正常样本与恰好命中阈值的边界样本,先预测它们分别接受还是拒绝。

契约式设计:把责任放在边界上提示37:通过契约进行设计 · 调用者承诺进入,实现者承诺退出输入:reserveSeat(2)1契约输入、输出、边界双方先说清责任承诺是什么?2前置条件调用者满足调用者负责入口能否开始?3实现执行承诺实现者保持边界做了什么?4后置条件结果可验证实现者负责出口交付了吗?5不变量状态始终合法双方共同守住还能信吗?验收合同:每一段都交出责任、证据和下一段的进入条件只改变一个条件;记录首个拒绝点,不用最终状态倒推原因图示是契约的可观察模型,不是业务成功率统计
好的契约让调用者知道何时可以进入,也让实现者知道何时必须拒绝交付。

动手试:后置条件缺失会在哪里停下

点击“注入后置条件缺失”,再逐步推进到第 4 个节点。观察调用者为何拒绝接收结果;最后点击“重置实验台”,确认节点、故障和步数都回到初始状态。这个实验只模拟责任交接,不能替代真实业务的事务、并发和权限测试。

Topic 23 · 契约边界实验台
契约式设计:把责任放在边界上提示37:通过契约进行设计 · 调用者承诺进入,实现者承诺退出输入:reserveSeat(2)1契约输入、输出、边界双方先说清责任承诺是什么?2前置条件调用者满足调用者负责入口能否开始?3实现执行承诺实现者保持边界做了什么?4后置条件结果可验证实现者负责出口交付了吗?5不变量状态始终合法双方共同守住还能信吗?验收合同:每一段都交出责任、证据和下一段的进入条件只改变一个条件;记录首个拒绝点,不用最终状态倒推原因图示是契约的可观察模型,不是业务成功率统计

第 1 / 5 步:契约 已留下责任证据。

故障注入只移除一项承诺;恢复后必须从同一输入重放,而不是修改最终答案。

正常、边界与单故障矩阵

样本唯一改变应有结果必存证据
正常完整前置、实现和后置检查请求数量、订单数量和库存变化一致请求身份、旧状态、新状态、出口检查
边界count 恰好等于剩余库存明确接受并使库存归零,或按合同明确拒绝阈值、单位、比较结果、拒绝原因
单故障移除后置条件检查在出口拒绝,不向下游传播不可信结果首差、责任人、恢复动作、重放结果

三类样本共享代码版本和起始状态。若同时更换存储引擎、放大并发量并修改权限,结果就无法说明是哪个条件造成的;应先恢复正常样本,再逐个验证变更。

契约的代码形状

下面的示例把入口拒绝、实现动作和出口验证分开。真实项目还需决定事务边界与并发策略,但这三个位置足以让责任先显形:

type Reservation = { orderId: string; count: number };
 
function reserveSeat(count: number, stock: number): Reservation {
  if (!Number.isInteger(count) || count <= 0 || count > stock) {
    throw new Error("precondition failed: count is outside the available range");
  }
 
  const reservation = { orderId: "order-42", count };
  const nextStock = stock - reservation.count;
  if (nextStock < 0 || reservation.count !== count) {
    throw new Error("postcondition failed: reservation and stock disagree");
  }
  return reservation;
}

入口条件防止非法请求进入,出口条件检查实现是否兑现了请求;nextStock 不能小于零则是不变量的一个实例。若异常代表的是可预期的业务拒绝,应返回带原因的结果;若内部状态违反不变量,应保留上下文并报警,不能用默认值掩盖。

本章回顾

  • 契约把接口拆成进入条件、出口承诺和持续不变量,并为每一项指定责任归属。
  • 前置条件约束调用者能否开始,后置条件证明实现是否交付,不变量防止状态在边界间失真。
  • 正常、边界和单项故障样本必须共享其余条件;首个拒绝点比最终的“失败”更有诊断价值。
  • 修复契约破坏后,要从原始输入重放并保留预测、首差、恢复动作和未覆盖边界。

完成本页后,你应该能为一个接口写出三类条件,解释调用者违约与实现违约的区别,并说明为何不能把一次成功返回当作完整证据。

练习:把接口承诺写到可以验收

练习

问题 1:写一份契约。 按提示37“通过契约进行设计”的思路,为 reserveSeat(count) 写出一条前置条件、一条后置条件和一条不变量;说明 count 等于剩余库存、超过剩余库存时分别发生什么。

问题 2:区分责任。 调用者传入了合法数量,但实现只更新库存,没有写入订单却返回成功。这里是调用者违约、实现违约还是边界样本?请写出首个应拒绝的条件和日志字段。

问题 3:设计一次重放。 你修复了后置条件检查。请安排正常、边界和单故障三次运行,并说明每次只改变什么、怎样证明修复没有把错误转移到下游。

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

契约式设计

把调用者与实现者的进入条件、退出结果和持续状态约束写成可读、可检查的接口承诺。

前置条件

请求进入实现前必须满足的类型、范围、权限或资源条件,由调用者负责提供。

后置条件

实现成功返回时必须成立的输出和状态变化,由实现者负责兑现。

不变量

在操作前后和可观察边界上都不能被破坏的状态事实,例如库存不能为负。

责任归属

把条件绑定到具体角色与代码边界,让失败时知道谁检查、谁修复、谁复核。

契约边界

条件被检查、结果被验收或不可信状态被拒绝的明确位置。

来源与改写范围

  • 作者与英文版页面:核对 20 周年版书名、版本与 Topic 23 的英文目录坐标。
  • 中文公开目录:核对“23 契约式设计”与“提示37:通过契约进行设计”的中文目录位置。
  • 本页是基于公开目录的 independent rewrite;契约示例、代码、图示、实验和练习均为本课程重新设计,不声称提供原书全文或原书答案。

前后导航

讨论

评论区加载中…