38 巧合式编程

把偶然可运行的代码转成有意图、有假设和有边界的实现,不依赖未说明的环境巧合。

学习目标

  • 能找出代码依赖的默认顺序、隐含状态、环境细节和未声明的输入假设
  • 能用反向顺序、边界输入和隔离环境验证因果,并把巧合改成显式契约
  • 能以回归测试、可回退提交和失败记录证明修复没有把风险移到下游

能运行不等于有因果

本页依据 David Thomas、Andrew Hunt《程序员修炼之道:通向务实的最高境界(第2版)》,云风译,电子工业出版社,2020 年 4 月,ISBN 9787121384356 的公开中文目录,独立重构 38 巧合式编程。正文、代码、图示、实验和练习都是本课程重新设计的教学材料,不复制原书正文、插图或答案。

代码在一个环境里运行,可能只是因为多个未声明条件恰好同时成立:字典顺序没有变化、初始化已经被别处完成、机器时区与测试数据相同,或数据库返回了你没有要求的排序。问题不在于代码曾经成功,而在于成功的因果没有写进合同。巧合式编程要做的第一件事,是把“为什么它能工作”变成一个可以被打破、验证和重建的假设。

三个会放大巧合的陷阱

从观察到显式契约

<Term def="代码在未声明的顺序、状态、环境或实现细节恰好成立时产生的成功。">巧合式编程</Term>需要通过五步回路转化。每一步都有一个可失败问题:

  1. 观察:成功依赖了什么动作、数据和环境条件?
  2. 列假设:如果反转顺序、清空状态或更换区域设置,哪个结果会变?
  3. 验证因果:只改变一个条件,比较预测与实际的首个差异。
  4. 显式设计:把真实依赖写入类型、参数、排序、初始化或权限检查。
  5. 回归:保存能打破旧巧合的输入,并在修复后持续重放。
拆掉巧合:从观察走向显式契约反例只改变一个条件,修复后把反例保留为回归证据1观察记录成功条件已留证据2列假设找出隐藏前提已留证据3验证因果只改变一项当前验证入口4显式设计写入边界合同等待证据5回归保存最小反例等待证据最终结果不同还不够:先找到第一个改变的观察点,再决定如何修复
专属图示:巧合被反例打破后,真实依赖必须进入可观察契约。

图中的回路不把“验证通过”当成永久保证。环境、数据分布和依赖版本会变化,所以回归集合必须包含曾经失败的最小输入。若修复只让一个样例通过,却没有覆盖真正的依赖,下一次变化仍会把巧合带回来。

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 / 4

1. 描述一次巧合成功

写出对象、输入、环境、调用顺序和成功结果。用事实描述,不要写“它应该能工作”;列出至少两个可能的隐含假设。

正常、边界与单一故障证据

证据矩阵:默认值必须接受反例相同输入只移除一个条件,才知道巧合发生在哪里字段正常边界故障条件契约完整空或重复顺序改变预测结果可重建明确拒绝出现首差首差边界节点因果验证恢复保存版本补约束重放反例把失败反例加入回归,避免修复只在原环境中“恰好”成立
专属图示:正常、边界和故障样本共同证明显式契约的边界。
样本唯一变化预期判定必存证据
正常已声明契约与稳定输入结果可由输入和规则重建规则、输出和版本
边界空、重复、反序或超时输入明确接受或拒绝,不依赖默认值反例、拒绝原因和阈值
单一故障一个隐藏环境条件被移除在首个差异处停止失败输入、首差和回退

独立复核者不应只看到修复后的绿色测试,而应看到修复前如何成功、哪一个条件打破了它、修复把条件放到了哪里。若一个测试依然需要先运行另一条测试才能通过,说明隐含状态还没有被消除。

迁移与维护

依赖显式化后仍要考虑时间边界。数据系统会升级排序实现,云服务会改变默认区域,构建工具会改变模块加载,AI 辅助工具也可能生成依赖环境巧合的代码。审查生成代码时,要求它同时给出输入合同、拒绝条件、边界测试和回退路径。

当旧依赖确实是稳定平台保证时,也要把保证写入项目边界:记录版本范围、责任人和升级验证动作。这样“依赖平台保证”就不再是巧合,而是一个能被维护的部署契约。

术语表

名词解释

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

巧合式编程

依赖未声明的顺序、状态、环境或实现细节而成功的代码。

隐含假设

运行所需却没有写入输入、类型、配置或测试合同的前提。

显式契约

规定输入、输出、顺序、错误和副作用的可观察边界。

可回退证据

包含预测、实际、首个差异和恢复路径、可以重放的记录。

最小反例

只改变一项条件、足以挑战某个假设的最小输入。

练习

练习

问题 1: 一个导入脚本依赖文件名顺序,开发机上总是先读配置文件。请设计一个反例并写出契约修复。

问题 2: 一个函数只有在完整应用启动后才能测试。你如何判断是知识缺口还是隐藏初始化?

问题 3: 修复了默认排序后,另一个页面的结果发生变化。为什么这不是理由去撤销修复?

资料与写作方式声明

本章以程序员修炼之道权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

本单元回顾

不要依赖巧合编程,意味着要追问一次成功依赖了什么:顺序、状态、环境、权限还是数据分布。用最小反例打破未声明前提,把真实依赖写进显式契约,再以可回退证据和回归测试保护它。最终的目标不是让所有环境都一样,而是让差异出现时能尽早、明确且可恢复地失败。

前后导航

讨论

评论区加载中…