读前:这一章要回答什么
当"我的写法更好"和"大家都这么写"冲突时,该听谁的?一致性又在什么时候会变成削足适履?
第 2 章把模糊性(obscurity)列为复杂性的两大来源之一,并点名"不一致"是造成模糊的原因。第 12~16 章从注释、命名、修改代码的角度对付模糊性;这一章换一个角度:让相似的东西长得相似,让不同的东西长得不同,读者就能靠已有的经验读懂新代码,不必每处从头学。
这是全书最短的章节之一,观点也最容易被点头放过。但它有一个相当保守、甚至有点反直觉的立场:局部更好的写法,通常不值得用一处不一致去换。读的时候要特别留意作者给"打破约定"设的门槛有多高。下一章(代码应该一目了然)会把一致性和命名并列为让代码变明显的两大手段。
- 17.1 节列了五个层次的一致性。注意最后一个"不变式"(invariant):它乍看和"风格统一"没什么关系,想想作者为什么把它归到一致性里,又和第 10 章"消除特殊情况"有什么关系。
- 17.2 节"不要改已有约定"那一段:作者给出了两个问题,并且要求答案都是"是"之后还要做一件事。数一数,打破一个约定需要满足几个条件?
- 17.3 节只有一段,但它划出了一致性的边界:一致性的价值来自一个承诺。读完用一句话说出这个承诺是什么。
核心观点
认知杠杆
在一个地方学会的做法,可以直接用来读懂所有用同样做法的地方。学一次,用很多次。
一致性减少错误
系统不一致时,两处代码看起来一样、实际却不同,读者按经验推断就会出错。一致的系统里,"看着眼熟"的推断是安全的。
五个层次
名字、代码风格、同一接口的多个实现、设计模式、不变式。都是"相似的事用相似的方式做"。
不变式也是一致性
一个永远成立的性质,让代码里少掉一整类特殊情况,推理也更容易。
写下来,再用工具卡住
约定不写下来就没人遵守;写下来也会忘,所以最好用提交前的自动检查拦住违规,再加上较真的代码评审。
入乡随俗
进一个新文件、做一个设计决定之前,先看周围是怎么做的,照着做。作者说这是所有约定里最重要的一条。
不要"改进"已有约定
有一个更好的主意,不足以成为制造不一致的理由。要换,就得全部换完。
别把不同的东西硬凑成一样
一致性只有在"看起来是 x 的东西真的是 x"时才有价值;强行统一不同的东西,只会制造混乱。
心智模型:看起来一样 × 实际一样
作者对一致性的定义有两半:相似的事用相似的方式做,不同的事用不同的方式做。很多人只记住了前一半。把"实际上是不是同一类事"和"代码里的做法是不是一样"交叉一下,就得到四种情况:
左下角的"不一致"拖慢阅读;右上角的"假一致"更糟,因为读者根本不会意识到自己需要停下来查。这就是为什么作者在 17.3 节强调:一致性的全部价值,建立在读者可以相信"看着一样的就真的一样"之上。
五个层次,各举一个 C/C++ 的例子
| 层次 | 一致的样子 | 不一致的代价 |
|---|---|---|
| 名字 | len 在全项目都表示字节数,元素个数一律叫 count | 有人把 batch.size()(条数)传给 reserve(要字节数),缓冲区小了几百倍 |
| 代码风格 | 统一的 .clang-format;头文件里先 public 后 private;禁用 strcpy 等危险函数 | 评审时一半注意力花在格式上;diff 里混着无意义的改动 |
| 接口 | Compressor 有 zstd、lz4 两种实现,读懂一种就知道另一种必须提供什么 | 每种实现各有一套方法名和错误约定,换实现等于重学 |
| 设计模式 | 所有网络组件都用同一种"注册回调 + 事件循环"结构 | 同样的问题在三个模块里有三种自创解法,读者无法复用经验 |
| 不变式 | "段列表永远非空,最后一段总是可写的" | 每个函数开头都要先判断一遍空列表和已封存,漏一处就是崩溃 |
书中关于不变式举的是一个文本行数据结构的例子(每一行都以换行符结尾)。它和第 10 章的"把错误定义为不存在"、第 6 章的"消除特殊情况"是同一类思路:与其在每个使用点处理例外,不如让例外不存在。下面的第二个例子会展开这一点。
原创例子一:同一个代码库里的三种错误约定
一个日志结构的键值存储,几个模块由不同的人在不同时期写成。每个人的约定单独看都说得过去:
// segment.h:成功返回 0,失败返回 -1 并设置 errno
int Segment::append(const Record& r);
// index.h:成功返回 true
bool Index::insert(const Key& k, Offset off);
// compactor.h:成功返回 0,失败返回负的 errno(如 -ENOSPC)
int Compactor::run();
int Store::put(const Key& k, const Value& v) {
Record rec = makeRecord(k, v);
if (seg_->append(rec) != 0) return -errno;
if (!index_.insert(k, seg_->lastOffset())) return -EEXIST;
// 新同事照着上面 append 的写法写了下面这行:
if (needCompact() && compactor_.run() == -1) return -errno;
return 0;
}
第 13 行是一个真实会出现的 bug:run() 失败时返回 -ENOSPC(-28),永远不等于 -1,磁盘满了也被当成成功。写这行的人没有犯傻,只是相信了"看起来一样的东西真的一样"。这正是作者说的:不一致的系统里,眼熟的模式会诱导错误的假设。
// 存储层约定(见 docs/conventions.md):会失败的函数返回 int,
// 0 表示成功,负数是 -errno。不使用全局 errno,不返回 bool。
[[nodiscard]] int Segment::append(const Record& r);
[[nodiscard]] int Index::insert(const Key& k, Offset off); // 键已存在:-EEXIST
[[nodiscard]] int Compactor::run();
int Store::put(const Key& k, const Value& v) {
Record rec = makeRecord(k, v);
if (int rc = seg_->append(rec); rc < 0) return rc;
if (int rc = index_.insert(k, seg_->lastOffset()); rc < 0) return rc;
if (needCompact()) {
if (int rc = compactor_.run(); rc < 0) return rc;
}
return 0;
}
三个调用长得一模一样,读者看懂一个就看懂了全部;Index::insert 现在能告诉调用者为什么失败,不用再猜 -EEXIST。[[nodiscard]] 让"忘了检查返回值"变成编译警告,约定写在一处文档里。
为什么选 -errno,而不是更"现代"的 std::expected?因为 Store 这一层和它的大部分调用者已经在用 -errno,统一到它改动最少。如果你想换成别的约定,就要先过作者在 17.2 节设的那两道关(见下文),并且一次换完。代价:这次统一要改所有调用点,Index::insert 的每个调用者都要从 if (!…) 改成 if (… < 0),漏改一处就是新的 bug;所以它要配合编译器警告和评审来做,不能顺手做一半。
原创例子二:用不变式消掉一类特殊情况
同一个存储引擎里,"当前可写的段"有时不存在:刚启动时还没有段,某段写满封存后新段还没建。于是每个用到它的地方都要先检查一遍:
int Store::append(const Record& r) {
if (segments_.empty() || segments_.back().sealed())
if (int rc = rollSegment(); rc < 0) return rc;
return segments_.back().append(r);
}
size_t Store::activeBytes() const {
if (segments_.empty() || segments_.back().sealed()) return 0;
return segments_.back().bytes();
}
int Store::sync() {
if (segments_.empty()) return 0; // 这里忘了判断 sealed,但恰好没事
return segments_.back().sync();
}
三个函数各自处理"没有可写段",写法还各不相同;下一个加进来的函数很可能漏掉。
// 不变式:segments_ 永远非空,且最后一段总是未封存的可写段。
// 由构造函数建立;sealActive() 封存当前段后立即打开新段来维持。
std::vector<Segment> segments_;
int Store::append(const Record& r) {
if (active().full())
if (int rc = sealActive(); rc < 0) return rc;
return active().append(r);
}
size_t Store::activeBytes() const { return active().bytes(); }
int Store::sync() { return active().sync(); }
特殊情况只在建立和维持不变式的两个地方处理,其他函数可以放心假设"一定有可写段"。不变式写在成员声明处,这正是 17.2 节说的:局部约定要写在代码里合适的位置。
这算一致性吗?算:所有代码对 segments_ 做同一个假设,读者只需记住一条规则。代价是建立不变式的那几处(构造、封存、恢复)要格外小心,因为整个类都依赖它。
怎样维持一致性
作者的建议有四条,按"由软到硬"排列大致是:
- 写下来:全局约定(风格、错误约定、命名)放在显眼的地方,如项目 Wiki,并让新人入职时读;局部约定(如上面的不变式)写在代码里。
- 强制执行:最可靠的是写一个检查工具,不通过就提不了代码。书里讲了作者项目里换行符(Unix 的 LF 和 Windows 的 CRLF)混用、导致整文件 diff 的故事,最后靠一个提交前脚本根治。C/C++ 项目里对应的做法有
clang-format --dry-run --Werror、clang-tidy的命名检查、禁止某些函数的 lint 规则。代码评审是第二道关,作者认为评审者越较真,团队学会约定越快。 - 入乡随俗(When in Rome…):进一个新文件,先看它的声明顺序、命名风格;做一个设计决定,先找项目里有没有做过类似的决定,照着做。
- 不要改已有约定:见下面的判断框。
打破约定之前问两个问题(17.2 节):
- 我有没有当初定这个约定时还没有的重要新信息?
- 新做法是不是好到值得花时间把所有旧用法都改掉?
只有组织(不是你一个人)认同两个答案都是"是",才去升级,而且升级完不能留下旧约定的痕迹。即使这样,别人以后还可能把旧写法带回来。所以作者的总体判断是:重新讨论已经确立的约定,很少是值得花的时间。
The value of consistency over inconsistency is almost always greater than the value of one approach over another.— 第 17.2 节
边界与反方
一致性是双向的:不同的东西也该看起来不同。用同一个变量名指代实际不同的东西、把不合适的设计模式套到问题上,都会制造"假一致"。例如为了和 UserCache 保持一致,把一个只读的配置快照也命名为 ConfigCache 并给它加上从不该被调用的 invalidate():读者会以为它会过期、需要失效,实际并不会。判断标准就是作者那句话:看起来是 x 的东西,必须真的是 x。
第 16 章说每次修改都要让设计变得更好,不要做最小改动;这一章却说不要"改进"已有约定。本站补充一个区分:第 16 章说的是改善结构(把一段代码改得像是一开始就为新需求设计的),本章反对的是在局部引入第二种约定。你可以在修改时把一个透传方法合并掉,但不该在一个满是 -errno 的模块里,单独给你的新函数用 std::expected。前者让系统更简单,后者让读者多学一种东西。
作者认为评审者越挑剔,团队学得越快、代码越干净。本站补充:能用工具自动检查的约定(格式、命名、禁用函数),最好交给工具,评审者把精力留给工具查不出来的约定(错误约定、不变式、设计模式的用法)。工具不会累,也不会让人觉得是在针对自己;书里换行符的故事本身就说明了工具的效果:问题当即消失,还顺带教会了新人。
作者没有说约定永远不能改,只是把门槛设得很高:要有新信息、要值得全部改完、要组织同意、改完不留痕迹。现实中常见的反面做法是"新代码用新写法,旧代码慢慢改",结果是两种约定长期并存,比只有那个旧约定更糟。如果没有资源一次改完,作者的立场意味着:继续用旧约定。
以下不在书里,来自书中给出的读者讨论组。有读者问:17.2 节说不要改动已有的约定,第 16 章又说每次修改都该让设计好一点;想引入一个更好的写法、又没法一次改完全部旧代码时,听哪个?Ousterhout 承认这两条在有些情况下确实冲突(2019-05),给的是一个成本收益的判断:
- 约定混用有实实在在的成本:读的人会困惑,新来的人分不清哪种才对,可能把旧写法继续扩散。
- 所以新约定只是"更好"还不够,要好到值得忍受一段新旧并存的日子。Ousterhout 说给不出公式。
- 如果借此还掉的是一笔很大的技术债,一个新旧混用的代码库可以好过一个约定统一、债却更多的代码库。Ousterhout 说自己这样做过,事后并不后悔。
本站补充:真要并存,把"并存"本身写下来(哪种是新的、旧代码什么时候迁、新代码一律用哪种),混用的成本会小得多。
练一练
下面三道题来自训练场。先自己判断再看解析。
再加一道,取材于读者讨论组(对应上面"边界与反方"里的"讨论组"一栏):
自测题
snake_case 命名,而你个人和公司新规范都偏好 camelCase。你要在这个模块里加一个函数,按本章的建议应该:小结与读后一句话
- 一致性提供认知杠杆:学会一处,就能读懂所有同样做法的地方;它也减少"看着眼熟其实不同"带来的错误。
- 一致性有多个层次:名字、风格、同一接口的多种实现、设计模式、不变式。不变式能消掉一类特殊情况。
- 维持一致性:写下来、用工具和评审强制、入乡随俗、不要轻易改约定。
- 改约定前问两个问题;真要改,组织同意、一次改完、不留痕迹。
- 一致性是双向的:不同的东西要看起来不同,硬凑的一致比不一致更危险。
下一章(代码应该一目了然)会把命名和一致性作为让代码变明显的两大手段,并讨论哪些写法会让代码变得不明显,给出本书的最后一个红旗。
读后一句话:想一想你的项目里有没有"同一件事有两三种做法"的地方,写下来,并判断它值不值得统一。