第7章 当你编码时
在编码现场结合直觉、因果、复杂度、重构、测试、安全和命名持续校正实现。
学习目标
- 能把编码中的直觉、巧合、复杂度、重构、测试、安全和命名转成可检验的假设
- 能用小步实验、性质与端到端测试保护行为,在首个偏差处停止并保留证据
- 能以最小权限、可读命名和可回退提交交付实现,并说明适用边界与恢复路径
编码不是把键盘敲得更快
本页依据 David Thomas、Andrew Hunt《程序员修炼之道:通向务实的最高境界(第2版)》,云风译,电子工业出版社,2020 年 4 月,ISBN 9787121384356 的公开中文目录,独立重构 第7章 当你编码时。正文、代码、图示、实验和练习都是本课程重新设计的教学材料,不复制原书正文、插图或答案。
编码现场经常同时出现两种声音:手感告诉你某处设计不对,编译器和测试告诉你目前哪里已经不对。务实的做法不是压住第一种声音,也不是盲信第二种声音,而是把感觉变成小实验,把实验结果写成能被别人重放的证据。本章用一条反馈回路串起 Topic 37–44:先听见阻力,再显化假设,测量算法,重构结构,用测试保护行为,缩小安全边界,最后让名称表达真实意图。
先识别三个会让编码失真的陷阱
一条可观察的编码回路
<Term def="把输入、假设、测量、决定和反馈连接起来,并能在首个偏差处停止的短循环。">反馈回路</Term>不是线性教程,而是每次改动都能重复的工作协议。下面的流程把直觉当作输入,把测试和命名当作输出;任何节点没有证据,下一节点就不能假定它已经完成。
五个节点的责任如下:
- 听见阻力:记录迟疑、重复、难以解释的分支或不自然的接口,并提出一个可观察的问题。
- 显化假设:写明对象、输入、状态变化、下游接收者和拒绝条件,不让默认行为藏在代码里。
- 测量复杂度:先判断增长阶,再在真实数据分布和目标环境测量时间、内存与失败率。
- 测试重构:用行为测试和性质保护外部合同,再以小步改善结构,保持每次提交可回退。
- 安全表达:在输入边界、权限检查、依赖版本和名称上表达约束,让错误尽早且明确地停止。
37 听从蜥蜴脑:把阻力变成实验
“蜥蜴脑”不是不可证伪的神秘直觉。它通常表现为:你不断绕开一个函数、害怕修改一段逻辑、无法用一句话解释对象的责任,或在重复复制前先产生不安。停下来问三个问题:哪一个对象让我迟疑?我预期它发生什么状态变化?哪一个最小输入可以区分两个解释?
例如,订单页面既要读取购物车,又要计算折扣,还要发送通知。若新增一个折扣规则需要同时修改页面、数据库和通知模板,阻力提示的可能不是“我还不熟悉代码”,而是责任边界混在一起。先写一个只计算折扣的例子,再观察接口是否能被单独测试;实验结果比感觉本身更有价值。
38 巧合式编程:证明你依赖的是什么
<Term def="代码依靠未写入合同的环境状态、调用顺序或偶然实现细节而工作。">巧合式编程</Term>会制造脆弱的成功。常见例子包括依赖字典的遍历顺序、依赖当前时区、依赖数据库返回的隐含排序,以及先调用某个初始化函数才“恰好”有效的对象。
把巧合改成意图可以从四个动作开始:
- 把隐含前提写进参数、类型、断言或配置校验;
- 用一个反向顺序、空值、重复值或不同区域设置的输入挑战前提;
- 在边界处返回明确的拒绝,而不是让下游猜测;
- 保存失败输入,使修复后的测试能证明同一问题不会悄悄回来。
type PriceRequest = {
currency: "CNY" | "USD";
items: ReadonlyArray<{ unitPrice: number; quantity: number }>;
};
function total(request: PriceRequest): number {
if (request.items.some((item) => item.quantity < 0)) {
throw new Error("quantity-must-be-non-negative");
}
return request.items.reduce(
(sum, item) => sum + item.unitPrice * item.quantity,
0,
);
}这里没有把货币换算偷偷放进总价函数。边界被写出来,调用者可以选择汇率和舍入规则,也就不会把一次巧合的本地结果误当成业务合同。
39 算法速度:先问增长,再问常数
<Term def="算法随输入规模增长的计算、存储和等待成本;它既包括增长阶,也包括目标环境中的常数。">算法成本</Term>需要两个证据。增长阶帮助我们发现输入变大后会不会失控;目标环境测量则揭示缓存、网络、序列化和数据分布带来的常数。只写一个复杂度符号,无法证明某个实现适合当前服务。
假设要判断一万个商品是否存在于黑名单。逐个扫描黑名单是 O(n × m),先建立集合再查询通常是 O(n + m);但集合建造、内存峰值和冷启动时间仍要在目标数据上测量。估算前先写输入规模、单位和接受阈值,测量后记录硬件、编译选项、数据分布与重复次数。若测量和估算冲突,先检查实验是否测到了真正的瓶颈。
40 重构:让结构变化成为可回退实验
<Term def="保持外部可观察行为不变,只改善代码内部结构、命名、边界或依赖方向的受控改动。">重构</Term>的验收标准是行为等价,不是 diff 看起来漂亮。先把缺失的外部行为补成测试,再一次只做一种结构变化:提取函数、移动责任、替换名称或隔离副作用。每完成一步就运行快速反馈,并留下能说明目的的提交信息。
重构前后都问:调用者看到的输入、输出、错误和副作用是否相同?如果答案还不确定,就把变化拆得更小,或先补一条端到端测试。功能需求应该另起提交,避免在一次失败中丢失因果。
41 为编码测试:测试也是第一个用户
测试不只是发布前找 Bug 的筛子。测试调用接口、组织夹具、观察错误,并因此暴露设计是否难以使用。一个只能通过复杂全局初始化才能测试的函数,已经告诉我们它的依赖边界需要调整。
优先构造一条端到端的最小切片:输入订单,经过校验和折扣,得到明确的响应与事件。再在边界处增加单元测试,检查纯计算、拒绝条件和性质。自上而下或自下而上都可能留下没有真实用户路径的孤岛;端到端切片先建立可见结果,局部测试再缩短反馈时间。
42 基于特性测试:让生成器挑战假设
<Term def="对一组输入生成器声明不变量,用大量样本验证结果关系,并在失败后缩减到最小反例的测试方式。">属性</Term>比手工挑几个例子更适合检查可交换、守恒、幂等和边界关系。例如,购物车总价应满足:添加一件数量为零的商品不改变总价;合并两个购物车的结果与逐项合并一致;数量为负时必须拒绝。
属性必须绑定业务边界,不能用“永远不抛异常”这种空目标掩盖错误。生成器产生非法输入时,属性应确认拒绝原因;发现失败后保存缩减后的最小反例,并把它加入回归集合。生成器、种子、版本和失败输出共同构成证据,不能只报告“跑了一万次”。
43 出门在外注意安全:把拒绝放在边界
安全工程从代码设计开始:公开接口只收所需字段,敏感动作检查身份与权限,依赖保持可更新,日志不泄露凭据,失败响应不暴露内部细节。把攻击面缩小比在上线前增加一轮神奇扫描更可复查。
对每个外部输入列出允许的形状、大小、字符集和权限,再为拒绝路径写测试。补丁管理也要有责任人、版本范围、验证步骤和回滚方案。安全修复与普通重构一样需要小步提交,但风险判断不能因“测试全绿”而自动通过。
44 事物命名:让名称承载当前意图
名称是代码给下一个读者的最短解释。data、tmp 或 handle 只有在作用域极小且语义确实明确时才足够;跨边界对象应说明它代表的事实、单位、生命周期或权限。名称与行为不一致时,先重命名再继续添加逻辑,避免陈旧词汇把错误模型传播到新代码。
命名也要服务于安全和测试:adminToken 不应被普通用户路径复用,untrustedHtml 不应被误传给可执行渲染器,expiresAt 不应被当作创建时间。名称不能替代校验,但它能让审查者更快发现不该发生的组合。
实验:从直觉到可重放的证据包
下面的实验使用一个价格计算器。每一步只改变一个条件,保存预测、实际首差、拒绝原因和恢复动作;不要用最终总价反推中间过程。
Interactive lab
选择样本,定位首个偏差
输入证据
数量为零且请求恰好达到额度阈值。
实际首差
边界节点:必须明确接受或拒绝。
恢复动作
把阈值来源写入测试,并补一条性质。
操作提示:先预测首差,再点击样本;若预测与结果不同,保留差异,不要修改结果来迎合预测。
1. 记录阻力与假设
写下一个你不愿修改的函数,以及它可能依赖的环境条件。为正常输入、边界输入和一条失败输入各写一条预期。若无法写出预期,先缩小对象,不要继续堆代码。
证据矩阵与回退规则
| 样本 | 唯一变化 | 应观察到的首差 | 可接受的恢复 |
|---|---|---|---|
| 正常 | 已知输入与稳定依赖 | 回路完整,输出满足合同 | 保存结果与提交哈希 |
| 边界 | 恰好容量、时限或权限 | 明确接受或拒绝,不产生假成功 | 回到边界规则并补测试 |
| 故障 | 一个依赖、顺序或权限失效 | 在首个受影响节点停止 | 重放失败输入并回退 |
证据包至少包含输入摘要、代码与依赖版本、生成种子、观察点、首差、错误信息、输出摘要、恢复动作和未覆盖范围。截图可以帮助定位,但不能代替可重放的输入与日志。独立复核者应能只拿到这些字段就重建结论。
术语表
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 反馈回路
从阻力到假设、测量、测试和安全表达的可重复短循环。
- 巧合式编程
依赖未写入合同的环境状态、调用顺序或偶然细节的实现。
- 算法成本
随输入增长的资源消耗,以及目标环境中的时间、内存和等待常数。
- 重构
保持外部行为不变、改善内部结构的受控改动。
- 属性
由生成器探索输入、由不变量裁决结果的可检验关系。
练习
练习
问题 1: 一个导入函数在开发机上正常,但换成不同区域设置后日期顺序改变。请指出巧合,设计一个最小反例,并写出修复证据。
问题 2: 一个搜索接口在小数据集上足够快,但数据增长后延迟急剧上升。你如何区分算法增长问题和环境常数?
问题 3: 重构后单元测试全绿,但端到端测试发现普通用户能触发管理员导出。你会怎样处理?
本章回顾
当你编码时,最有价值的产物不是一次看似顺利的提交,而是一条可以重放的反馈回路:直觉指出阻力,假设说明原因,算法测量风险,测试保护行为,重构改善结构,安全边界拒绝不该发生的输入,名称让下一位读者继续理解。能指出首个偏差、回退动作和尚未覆盖的条件,才算真正掌握本章。