读前:这一章要回答什么

系统一直在被修改。怎样让每一次修改都不让设计变差,并且让注释跟得上代码?

前面十几章大多在讲"第一次把东西设计好"。但作者在本章开头提醒:一个成熟系统的设计,更多是由它一路上的修改决定的,而不是由最初的构想决定的。大多数程序员大部分时间都在改别人(或很久以前的自己)写的代码,所以这一章讨论的其实是日常工作里最常见的场景。

本章分两部分。第一部分(16.1 节)把第 3 章的战略式编程用到修改代码上:每次修改都是一次小投资的机会。第二部分(16.2~16.6 节)是一组很具体的技巧,让第 12~15 章辛苦写出来的注释不会随着修改而过时。它们都可以直接用在下一次代码评审里。

  1. 16.1 节:作者承认了两种"不得不妥协"的情况。读的时候留意作者在承认之后,给出的那个自问的问题,以及两条退路。
  2. 16.2 节关于 C/C++ 头文件的那一段要读仔细:作者说放在 .h 里是"不行"还是"不是最好"?又怎样回应"使用者要看头文件"这个反对意见?
  3. 16.4 节里有三种不同的"重复":代码内部多处重复、在调用处重述被调用方法、重述外部已有的文档。对每一种,作者给的替代做法各是什么?

核心观点

改代码时也要战略式

"最小改动"心态会让每次修改都留下一点特殊情况和依赖。理想是:改完之后,系统看起来像是从一开始就为这个改动设计的。

没变好,多半就是变差

即使这次改动不需要重构,也顺手修一点看到的设计瑕疵。每次修改至少让设计好一点点。

约束下的最好

期限和兼容性可能逼你妥协。这时问自己:在当前约束下,这是我能做到的最干净的设计吗?

注释贴着代码

注释离代码越远,越容易忘了更新。接口注释紧挨方法体;实现注释放在能覆盖相关代码的最小范围里。

写进代码,而不只是提交信息

将来的开发者需要的信息,必须写进代码。他们很少会想到去翻提交历史。

每件事只记录一次

设计决定只写在最显然的一处,别处引用它;不要在调用处重述被调用方法;外部已有的文档直接引用。

提交前看一遍 diff

花几分钟确认每处改动都反映在文档里,顺便抓出遗留的调试代码和没处理的 TODO。

心智模型:设计质量随修改的走向

好 差 修改次数 → 每次只做最小改动 每次顺手改进一点 小的下探 = 重构当时的投入
示意图,不是测量数据。本站自绘。
外部文档(RFC、手册):只引用 类注释 / 设计说明文件 方法的接口注释(紧挨方法体) 方法开头:总体思路 每个阶段的第一行上方 紧挨某条语句 离代码远 → 更抽象 离代码近 → 可具体 细节写在底层,越往上越只讲"做什么、为什么"
注释的位置和抽象程度要匹配。本站自绘。
If you're not making the design better, you are probably making it worse.— 第 16.1 节

左图是这句话的直观版本。最小改动的每一步看起来都无害,但它们只朝一个方向累积。战略式修改每次都要多花一点时间(图中的小下探),换来的是总体向上的趋势。右图对应 16.2 节的一条规则:注释离它描述的代码越远,就越应该抽象,这样小的代码改动不会让它失效。

一个原创例子:同一个 bug 的两种修法

电商系统里,促销商品的价格偶尔显示成旧价。排查发现:促销引擎直接写数据库里的价格表,绕过了价格服务,所以价格服务的缓存不会失效。

Price PriceService::get(ItemId id) {
    const Item& item = catalog_.item(id);
    if (item.onPromotion)                 // 促销价不走缓存
        return db_.loadPrice(id);
    if (auto p = cache_.find(id)) return *p;
    Price p = db_.loadPrice(id);
    cache_.put(id, p);
    return p;
}
// 提交信息:促销引擎直接写库,缓存不会失效,所以促销商品先绕过缓存。

两行就修好了,测试也能过。但根本原因(有一个写入方绕过了失效机制)还在;读取路径多了一个特殊情况;促销商品从此每次都查数据库;而为什么要绕过缓存只写在提交信息里,代码里那条注释只说了做什么。下一个"有人绕过价格服务写库"的地方出现时,这个 bug 会换个样子再来。

// 商品价格的唯一写入入口。所有改价都必须经过这里:绕过它直接写
// 价格表,缓存中的旧价格就不会失效。读取请用 get(),它总是走缓存。
void PriceStore::update(ItemId id, Price p) {
    db_.savePrice(id, p);
    cache_.invalidate(id);
}

Price PriceStore::get(ItemId id) {
    if (auto p = cache_.find(id)) return *p;
    Price p = db_.loadPrice(id);
    cache_.put(id, p);
    return p;
}
// 同一次提交中:促销引擎改为调用 PriceStore::update(),不再直接写价格表。

修的是根本原因:让"缓存失效"和"写入"成为同一个操作,读取路径没有特殊情况。约束写在了代码里最显然的地方,将来想绕过它的人会先看到这段话。代价是要改促销引擎的代码,可能涉及另一个团队。

如果促销团队这周确实没法配合,就该问那个问题:在当前约束下,最干净的做法是什么?比如让价格服务订阅数据库的变更通知来做失效,这样不需要改促销引擎,读取路径也不用加特殊情况;或者先上最小改动,但把"绕过缓存"的原因写进代码,并约好下个迭代回来做正确的修法。

实现注释:放到最小的范围里

16.2 节的另一条规则针对方法内部:不要在方法开头写一大段注释描述所有细节,而是把每条注释"往下推",推到恰好覆盖它所说代码的最小范围。方法开头可以保留一段只讲整体思路的概述:

void Compactor::run() {
    // 分三步:选出要合并的段 → 合并成一个新段 → 切换索引并删除旧段。
    // 任何一步失败都只影响新段,旧段在第三步之前一直完整可用。

    // 只挑垃圾比例超过阈值的段;太新的段可能还有进行中的读者,跳过。
    std::vector<Segment*> victims = pickVictims(minGarbageRatio_);
    ...
    // 新段先写到临时文件,fsync 之后才改名,保证崩溃后不会留下半个段。
    ...
    // 先切换索引再删旧段:顺序反过来的话,读者可能查到已删除的段。
    ...
}

改第三步的人一定会看到第三步上方的注释,却未必会回到方法开头去更新一大段描述。概述只讲结构,小改动不会让它失效。

跟着做:提交前看一遍 diff

16.5 节的建议最容易执行:提交前花几分钟把整个 diff 过一遍。下面是一次"把缓存容量从条目数改成字节数"的提交。先自己找问题,写下来,再展开参考。(提示:至少有 5 处,大多和注释有关。)

  // cache.cc
  // 当缓存中的条目数超过 capacity_ 时,淘汰最久未使用的条目。
  void Cache::evictIfNeeded() {
-     while (lru_.size() > capacity_) {
+     while (usedBytes_ > capacityBytes_) {
+         fprintf(stderr, "evict used=%zu\n", usedBytes_);
          Entry& victim = lru_.back();
+         usedBytes_ -= victim.value.bytes();
          map_.erase(victim.key);
          lru_.pop_back();
      }
+     // TODO: 大对象单独处理
  }

  void Cache::put(Key k, Value v) {
      ...
+     usedBytes_ += v.bytes();
+     // evictIfNeeded() 会按字节数淘汰最久未使用的条目,
+     // 直到 usedBytes_ 不超过 capacityBytes_。
      evictIfNeeded();
  }

  // cache.h
- explicit Cache(size_t capacity);        // capacity:最多保存的条目数
+ explicit Cache(size_t capacityBytes);   // capacity:最多保存的条目数
展开参考
  1. 第 2 行:evictIfNeeded 的接口注释还在说"条目数",没有跟着行为一起改。它就在被修改的代码正上方,所以只要看一眼 diff 就能发现。
  2. 第 6 行:遗留的调试输出。
  3. 第 12 行:新加的 TODO。要么在这次提交里处理掉,要么说清楚"大对象"是什么问题、为什么现在不处理(例如单个对象超过总容量时会把整个缓存清空),否则它很快就会变成没人看得懂的噪音。
  4. 第 18~19 行:在调用处重述被调用方法做了什么。这是 16.4 节说的重复:想知道的人应该去看 evictIfNeeded 的接口注释(IDE 悬停就能看到)。而且一旦淘汰策略再变,这里就会悄悄过时。
  5. 第 25 行:参数改名了,头文件里的注释却还是"条目数"。这正是 16.2 节反对把接口注释放在 .h 里的理由:改 .cc 的人看不到它。无论团队约定放在哪,这次都只应该在一个地方写,并且写对。

你可能还发现了一个代码问题:如果单个对象比 capacityBytes_ 还大,循环会把它连同所有条目一起淘汰。这类问题恰恰是"顺手让设计好一点"的机会:在接口注释里写清楚超大对象的行为,或者直接拒绝放入。

做本站的任何一个项目任务时,都可以把这一步变成习惯:git add -p 逐块确认,或者提交前跑一遍 git diff --staged,专门只看注释和文档有没有跟上。

边界与反方

作者的观点要读准:作者认为最好的位置是代码文件里、紧挨方法体,因为改方法的人一定会看到;放在 .h 声明处是一种可选做法,但离代码太远,改方法体时看不到。对"使用者要从头文件学用法",作者的回应是使用者应该读 Doxygen 一类工具生成的文档或 IDE 的提示,所以注释应该放在对改代码的人最方便的地方。

本站补充:这在 C++ 圈是有争议的。不少 C++ 风格指南(例如 Google C++ Style Guide)要求在声明处写说明用法的注释,在定义处写说明实现的注释;发布二进制库或 SDK 时,使用者手里往往只有头文件,头文件就是契约本身;很多人确实也是直接读头文件学 API 的。比较务实的做法是:团队选定一处,只写一份(避免作者在 16.4 节反对的重复),并在评审时检查改了方法行为的提交是否同步改了那份注释,就像上面那个练习的第 25 行。

作者自己承认两种情况:正确的重构要三个月、快速修补只要两小时,而期限迫在眉睫;或者重构会给许多其他团队造成不兼容。作者要求的不是"永远重构",而是尽量抵抗妥协:找一个几天能完成、几乎一样干净的方案,或者争取在期限之后安排时间回来做。书中还建议每个组织都拿出一小部分精力专门做清理和重构。

本站补充:线上事故中先用最小改动止血,几乎总是对的;关键在于事后的跟进有没有发生。把"回来做正确修法"写成一个有负责人的任务,而不是一个 TODO。

作者并不反对写详细的提交信息,并明确说在提交信息里也放一份没问题,最重要的是代码里得有。两者回答的问题不同:提交信息说明"这次改了什么、为什么在这个时候改",是历史;代码注释说明"代码现在为什么是这个样子",是现状。判断标准就是作者那个问题:将来改这段代码的人需不需要这条信息?

"见某处的注释"、外部文档的链接都可能失效。作者的论点是:失效的引用是看得见的,读者找不到东西就知道出了问题,可以去查版本历史;而重复的副本有一份没更新,读者毫无察觉。本站补充:引用外部文档时写得具体一些(标准的版本号和章节,而不只是一个首页链接),能大大降低断链之后的损失。

以下不在书里,来自书中给出的读者讨论组。本章要求每次修改都让设计好一点;第 17 章又要求不要改动已有的约定。Ousterhout 被问到时承认两者有时确实冲突,判断标准是新做法要好到值得忍受一段新旧混用的日子,而不只是"更好"(2019-05)。详见第 17 章"边界与反方"里的同一条。

练一练

本章没有新的红旗。训练题都是"评审一份 diff"的场景,用到的是前面各章的红旗,正好检验你在修改代码时能不能认出它们。

自测题

按作者的说法,一次理想的修改完成后,系统应该是什么样子?
这是 16.1 节给出的目标。为此要抵抗快速修补的诱惑,先想想现有设计在这个改动之下是否仍然是最好的,不是就重构。"重写所有相关模块"则走向另一个极端,作者要的是最好的设计,而不是最大的改动。
一个方法分三个阶段。按 16.2 节的建议,实现注释应该怎么放?
把每条注释下沉到能覆盖它所说代码的最小范围,改这段代码的人才一定会看到它。离代码越远的注释越应该抽象,比如开头的概述只讲结构,不讲细节。
下列哪种做法违反了 16.4 节的"避免重复"?
B、C、D 都是书中推荐的做法。A 是在一个模块里重述另一个模块的设计决定:想了解的人应该去看被调用方法的接口注释,好的开发工具也会自动显示它。
你修了一个微妙的 bug,原因比较复杂。按 16.3 节,这个原因应该写在哪里?
将来需要这条信息的人很少会去翻提交历史。如果原因不在代码里,后来的人可能会把修改撤回去,重新引入这个 bug。在提交信息里也放一份是可以的,但最重要的是写进代码。
书中说提交前扫一遍 diff 除了能确认文档跟上改动,还能发现什么?
16.5 节提到的正是这两类:忘了删的调试代码、没有修掉的 TODO。它们都是"写的时候顺手加上、提交前忘了处理"的东西,看一遍 diff 就能发现。
为什么作者说比代码更高层、更抽象的注释更容易维护?
高层注释描述的是"做什么、为什么",而不是"怎么做",所以只有整体行为变化才会让它失效。作者同时提醒,有些注释(第 13 章讨论过)必须详细精确;但总的来说,最有用的注释(不复述代码的那些)也是最容易维护的。

小结与读后一句话

第 17 章讨论一致性。它和本章是同一个问题的两面:修改代码时最容易破坏的,就是系统里原有的约定。新代码要遵守旧约定,想改约定,就得把旧代码一起改过来。

读后一句话:用你自己的话写下这一章最重要的一个观点,最好结合你最近一次提交或评审。