38 巧合式编程
把偶然可运行的代码转成有意图、有假设和有边界的实现,不依赖未说明的环境巧合。
学习目标
- 能找出代码依赖的默认顺序、隐含状态、环境细节和未声明的输入假设
- 能用反向顺序、边界输入和隔离环境验证因果,并把巧合改成显式契约
- 能以回归测试、可回退提交和失败记录证明修复没有把风险移到下游
能运行不等于有因果
本页依据 David Thomas、Andrew Hunt《程序员修炼之道:通向务实的最高境界(第2版)》,云风译,电子工业出版社,2020 年 4 月,ISBN 9787121384356 的公开中文目录,独立重构 38 巧合式编程。正文、代码、图示、实验和练习都是本课程重新设计的教学材料,不复制原书正文、插图或答案。
代码在一个环境里运行,可能只是因为多个未声明条件恰好同时成立:字典顺序没有变化、初始化已经被别处完成、机器时区与测试数据相同,或数据库返回了你没有要求的排序。问题不在于代码曾经成功,而在于成功的因果没有写进合同。巧合式编程要做的第一件事,是把“为什么它能工作”变成一个可以被打破、验证和重建的假设。
三个会放大巧合的陷阱
从观察到显式契约
<Term def="代码在未声明的顺序、状态、环境或实现细节恰好成立时产生的成功。">巧合式编程</Term>需要通过五步回路转化。每一步都有一个可失败问题:
- 观察:成功依赖了什么动作、数据和环境条件?
- 列假设:如果反转顺序、清空状态或更换区域设置,哪个结果会变?
- 验证因果:只改变一个条件,比较预测与实际的首个差异。
- 显式设计:把真实依赖写入类型、参数、排序、初始化或权限检查。
- 回归:保存能打破旧巧合的输入,并在修复后持续重放。
图中的回路不把“验证通过”当成永久保证。环境、数据分布和依赖版本会变化,所以回归集合必须包含曾经失败的最小输入。若修复只让一个样例通过,却没有覆盖真正的依赖,下一次变化仍会把巧合带回来。
38 与提示 62:不要依赖巧合编程
本单元对应中文目录的 38 巧合式编程 和 提示 62:不要依赖巧合编程。提示的可检验解释是:任何未写入调用合同、数据合同或部署合同的前提,都只能暂时当作假设,不能当作能力。
可以用四个问题审查一段“恰好能跑”的代码:
- 如果调用顺序倒过来,结果还成立吗?
- 如果所有可变状态从零开始,接口仍然自洽吗?
- 如果输入为空、重复、跨时区或超出容量,拒绝路径在哪里?
- 如果依赖升级或部署到另一台机器,谁负责重新验证?
例如,价格汇总函数依赖数据库默认返回顺序,把第一条记录当成当前价格。最小实验不是迁移整个数据库,而是给同一查询增加显式排序和反向种子,再比较结果。若结果改变,巧合已经被证实;修复要把排序键写入查询合同,并把重复价格的决定规则写成测试。
把隐藏依赖写出来
<Term def="代码运行所需要但没有出现在输入、类型、配置校验或文档合同中的前提。">隐含假设</Term>常见于时区、编码、排序、默认超时、全局单例、权限和初始化。列清假设后,不必一次消除所有风险;先挑一个最可能改变结果且最容易验证的前提。
<Term def="明确规定输入形状、输出意义、顺序、错误和副作用的边界,使调用者不必猜测实现细节。">显式契约</Term>可以是类型、参数、断言、配置 schema、数据库约束或端到端测试。契约不是一段解释文字,而是调用错误输入时能给出可观察拒绝的规则。
type Event = { id: string; occurredAt: string; kind: "created" | "paid" };
function latestPaid(events: ReadonlyArray<Event>): Event | null {
const paid = events
.filter((event) => event.kind === "paid")
.toSorted((left, right) => left.occurredAt.localeCompare(right.occurredAt));
return paid.at(-1) ?? null;
}这段代码不依赖输入数组原本的顺序,也不把“总会有一条付款事件”当成前提。若时间格式不是明确的可比较格式,契约还应在输入边界拒绝它,而不是让排序结果看起来合理。
用反例验证因果
<Term def="能在复核时指出某个条件改变后,哪一个观察点先产生不同结果的记录。">可回退证据</Term>要同时保留预测、实际、首个差异和恢复路径。只有最终输出不同,仍然可能是多个变化叠加;只有首个差异被定位,修复才有方向。
对巧合最有效的反例通常很小:空集合、重复键、反向顺序、干净进程、不同区域设置、过期缓存或缺失权限。每次只选一个反例维度。若它没有打破结果,不要直接宣布假设错误;检查反例是否真的触达了隐藏依赖,并记录这次实验的限制。
练习实验:拆掉一个默认前提
Interactive lab
选择一个条件,观察巧合是否破裂
输入证据
重复键与空集合进入接口,默认值不再替调用者决定。
实际首差
边界节点:接口返回明确拒绝或空结果。
恢复动作
把输入规则和拒绝原因写入契约测试。
先写预测再选择样本;若结果改变,记录它依赖的条件,而不是把旧环境恢复成“看起来正常”。
1. 描述一次巧合成功
写出对象、输入、环境、调用顺序和成功结果。用事实描述,不要写“它应该能工作”;列出至少两个可能的隐含假设。
正常、边界与单一故障证据
| 样本 | 唯一变化 | 预期判定 | 必存证据 |
|---|---|---|---|
| 正常 | 已声明契约与稳定输入 | 结果可由输入和规则重建 | 规则、输出和版本 |
| 边界 | 空、重复、反序或超时输入 | 明确接受或拒绝,不依赖默认值 | 反例、拒绝原因和阈值 |
| 单一故障 | 一个隐藏环境条件被移除 | 在首个差异处停止 | 失败输入、首差和回退 |
独立复核者不应只看到修复后的绿色测试,而应看到修复前如何成功、哪一个条件打破了它、修复把条件放到了哪里。若一个测试依然需要先运行另一条测试才能通过,说明隐含状态还没有被消除。
迁移与维护
依赖显式化后仍要考虑时间边界。数据系统会升级排序实现,云服务会改变默认区域,构建工具会改变模块加载,AI 辅助工具也可能生成依赖环境巧合的代码。审查生成代码时,要求它同时给出输入合同、拒绝条件、边界测试和回退路径。
当旧依赖确实是稳定平台保证时,也要把保证写入项目边界:记录版本范围、责任人和升级验证动作。这样“依赖平台保证”就不再是巧合,而是一个能被维护的部署契约。
术语表
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 巧合式编程
依赖未声明的顺序、状态、环境或实现细节而成功的代码。
- 隐含假设
运行所需却没有写入输入、类型、配置或测试合同的前提。
- 显式契约
规定输入、输出、顺序、错误和副作用的可观察边界。
- 可回退证据
包含预测、实际、首个差异和恢复路径、可以重放的记录。
- 最小反例
只改变一项条件、足以挑战某个假设的最小输入。
练习
练习
问题 1: 一个导入脚本依赖文件名顺序,开发机上总是先读配置文件。请设计一个反例并写出契约修复。
问题 2: 一个函数只有在完整应用启动后才能测试。你如何判断是知识缺口还是隐藏初始化?
问题 3: 修复了默认排序后,另一个页面的结果发生变化。为什么这不是理由去撤销修复?
本单元回顾
不要依赖巧合编程,意味着要追问一次成功依赖了什么:顺序、状态、环境、权限还是数据分布。用最小反例打破未声明前提,把真实依赖写进显式契约,再以可回退证据和回归测试保护它。最终的目标不是让所有环境都一样,而是让差异出现时能尽早、明确且可恢复地失败。