读前:这一章要回答什么

"这段代码很复杂"到底是什么意思?怎样在复杂性把你拖垮之前就认出它?

第 1 章说全书都关乎复杂性,这一章就给它下定义。作者把这一章称为全书的基础:后面各章把这里的结论当作前提,用它来论证各种具体的设计建议。所以这一章的词汇(三种症状、两个成因)值得记牢,本站后面的题目解析也一直在用。

这一章的一个关键立场是:认出复杂性比造出简单的设计容易。你未必一开始就能写出简单的设计,但只要能判断"这个方案比那个复杂",就能拿这种判断力引导自己去试别的方案,慢慢积累出哪些做法通常更简单的经验。

  1. 2.1 节:作者的定义是"实用的",注意它说复杂性和系统的规模、功能多少不一定有关。读到那个加权公式时,想一想:你们系统里最吓人的那部分代码,真的是最拖后腿的吗?
  2. 2.2 节:三种症状中作者认为哪一种最糟?读的时候留意作者是怎么拿它和另外两种比较的,判断标准是什么。
  3. 2.3 节回到网站横幅的例子:改进后的设计并没有消除依赖,而是换了一个依赖。作者给了几条理由说明新依赖为什么更好,把它们找出来。

核心观点

实用的定义

复杂性是系统结构中一切让它难以理解、难以修改的东西。它是开发者在某个时刻为某个目标而体验到的,不等于规模大、功能多。

加权:看人在哪里花时间

系统整体复杂度 = 各部分复杂度按开发时间比例加权求和。很复杂但几乎没人碰的部分,对整体影响很小。

读者说了算

你觉得简单、别人觉得复杂,那它就是复杂的。代码要让别人也能轻松地用和改。

三种症状

变更放大:小改动要改很多处。认知负担:要知道的太多。未知的未知:不知道该改哪里、缺什么信息,最糟。

行数 ≠ 复杂度

几行就能写完、却没人知道该写哪几行的框架并不简单。有时行数更多的方案更简单,因为它降低了认知负担。

两个成因

依赖:一段代码不能被孤立地理解和修改。模糊:重要的信息不明显。

复杂性是累积起来的

没有哪一个灾难性错误,而是成百上千个小依赖、小模糊叠在一起。所以要"零容忍"。

心智模型:两个成因,三种症状

作者在 2.3 节末尾把成因和症状连了起来。画出来是这样的:

成因 症状 依赖 不能孤立地理解和修改 模糊 重要的信息不明显 变更放大 认知负担 未知的未知 三者中最糟 底下是成百上千个小依赖、小模糊,日积月累(2.4 节)
虚线表示"也会加重":模糊主要制造未知的未知,同时也增加认知负担。本站自绘。

这张图有两个用法。诊断时从右往左:你感到"改这个好累"(症状),去找背后是哪个依赖、哪处模糊。设计时从左往右:每加一个依赖、每引入一个不明显的约定,都在给右边某个症状加码。

复杂度看的是"人在哪里花时间"

作者用一个粗略的数学式子刻画系统的整体复杂度:

C = Σp cp · tp

其中 cp 是部分 p 的复杂度,tp 是开发者花在这个部分上的时间所占的比例。所有 tp 加起来是 1,所以 C 其实就是"开发者平均每时每刻感受到的复杂度"。

Isolating complexity in a place where it will never be seen is almost as good as eliminating the complexity entirely.— 第 2.1 节

下面这个计算器让你亲手体会这句话。左边两根滑杆分别调复杂度和时间权重(权重会自动归一化成比例),右边的条是这一部分对 C 的贡献。先看"现状",再切到"把难点藏进深模块",最后看"模块漏了"。

交互

加权复杂度计算器

一个虚构的电商后端。c = 这一部分的复杂度(0~10),t = 开发时间落在这里的比例。贡献最大的一行会被标红。数字是为了建立直觉而编的,不是测量值。
0
整体复杂度 C
—
c 最高的部分
—
对 C 贡献最大的部分
—
c 降 1 分收益最大的部分

一个小推论:把某部分的 c 降低 1,C 就减少该部分的 t。所以"改进哪里收益最大"只取决于大家在哪里花时间,和那部分本身有多复杂无关(当然,复杂的部分通常更容易降分)。

一个原创例子:日志记录的头部格式

一个追加写的存储引擎,每条记录前有一个 12 字节的头部:校验和、键长、值长。现在要加一个 4 字节的时间戳字段。看三种代码结构下,这个改动分别有多难。书中 2.2~2.3 节用网站横幅颜色说明同一件事。

// writer.cpp
char hdr[12];
encodeU32(hdr,     crc32(key, val));
encodeU32(hdr + 4, key.size());
encodeU32(hdr + 8, val.size());

// reader.cpp
pread(fd, hdr, 12, off);
uint32_t klen = decodeU32(hdr + 4), vlen = decodeU32(hdr + 8);
off += 12 + klen + vlen;

// compactor.cpp
if (fileSize - off < 12) break;   // 文件尾部是残缺记录

变更放大:每个 12 和每个偏移都得改。更要命的是 compactor 里那个 12:没人会想到压缩线程也"知道"头部多长,它很可能被漏掉,等到某天文件尾部恰好残留 12~15 字节的半条记录,压缩线程会把它当成完整的头部去解析,这是未知的未知。

// format.h
constexpr size_t kHeaderSize = 12;

// writer.cpp
char hdr[kHeaderSize];
encodeU32(hdr + 4, key.size());   // 偏移仍然写死
// reader.cpp
uint32_t klen = decodeU32(hdr + 4);
// compactor.cpp
if (fileSize - off < kHeaderSize) break;

头部长度改一处就行,compactor 也跟着变了。但字段布局这个知识还同时存在于 writer 和 reader 里:如果新字段插在中间,两边的偏移都要改,改漏一边就是读写不一致。依赖少了,但没有消失。

// record_format.h —— 记录格式的唯一出处
struct RecordHeader { uint32_t crc, keyLen, valLen, timestamp; };
constexpr size_t kHeaderSize = 16;
void encodeHeader(const RecordHeader& h, char* out);
RecordHeader decodeHeader(const char* in);

// writer.cpp
encodeHeader({crc32(key, val), klen, vlen, now()}, hdr);
// reader.cpp
RecordHeader h = decodeHeader(hdr);

格式知识集中在 record_format 里,加字段只改这里和真正要用新字段的地方。writer、reader 仍然依赖它,但这个依赖是明显的:搜 RecordHeader 就能找到所有使用者,结构一变编译器会指出没跟上的地方。代价是多了一个小模块要了解。

注意第三步并没有消灭依赖,而是用一个简单、明显、编译器能检查的依赖,替换了一堆隐蔽、难管理的依赖。这正是作者在 2.3 节说的:依赖是软件的基本组成部分,不可能完全消除,目标是让依赖更少、留下的尽量简单明显。

复杂性是一点一点累积起来的

回头看第一个面板:每一个写死的 12,在它被写下的那一刻都无伤大雅,甚至是最快的写法。作者在 2.4 节指出,复杂性不是由某一个灾难性错误造成的,而是由成百上千个这样的小依赖、小模糊叠出来的,直到系统里的每个改动都会撞上好几个。

这带来两个麻烦。一是很容易说服自己"这次加的一点点复杂性没关系",而每个人每次都这么想,复杂性就涨得飞快。二是积累之后很难清除:修好其中一个,整体几乎感觉不到变化,于是更没有动力去修。作者的结论是对复杂性采取"零容忍"的态度,具体怎么做,是第 3 章的主题。

Complexity is more apparent to readers than writers.— 第 2.1 节

还有一个让累积难以察觉的原因:写代码的人对自己加进去的依赖心知肚明,感觉不到复杂;感受到复杂的是后来的读者。所以当别人说你的代码难懂时,作者的建议是去问清楚对方为什么觉得复杂,这种看法上的落差里往往有值得学的东西。

边界与反方

作者自己说这是"粗略的数学方式"。cp 没法精确测量,tp 也会随时间和任务变化。它的用处是纠正直觉(最吓人的代码不一定最拖后腿),而不是拿来打分。还要注意作者说的是"几乎和消除一样好":被隔离的部分一旦出了 bug 或需要升级,你仍然要付全价。而且 tp 不是天生的:接口没藏住细节的模块,会逼着别人频繁打开它。

作者反对用代码行数衡量复杂度,这和"代码越少越好"的常见说法正面冲突。但作者说的是行数更多的方案有时更简单,条件是它降低了认知负担。这不是写啰嗦代码的许可证:多出来的行如果只是重复,那就是变更放大。判断标准始终是"完成任务需要知道多少、要改多少处"。

作者明确说依赖是软件的基本组成部分,每写一个新类就会围绕它的 API 制造依赖。本站补充:有些"解耦"手法只是把依赖藏了起来,比如用字符串名订阅事件、用无类型的 map 传参数。模块之间看似互不引用,依赖其实还在,只是从编译器能检查的明处,挪到了只能靠全文搜索的暗处,这反而增加了模糊。训练场里有一道 A/B 题专门讲这个。

作者承认模糊常常来自文档不足(第 13 章专门讲),但又说模糊也是设计问题:设计干净的系统需要的文档更少,需要大量文档才能讲清楚,本身往往说明设计不太对。减少模糊最好的办法是简化设计。这不等于"好代码不需要注释",第 12 章会专门反驳这个说法。

以下不在书里,来自书中给出的读者讨论组。2018 年有读者指出 2.1 节的公式有漏洞:把程序切成无数个极小的部分,每部分都很简单,按公式算出来的总复杂度就很低,可这样的程序显然更难懂。这位读者提议改成按"开发者会做的各类修改"求和:每类修改有多难,乘以它有多常发生。

Ousterhout 的回答有三层(2018-08):别对那个公式解读过多,它只是用来说明"藏在永远不用碰的地方的复杂性,几乎等于不存在";即便按公式算,切得太碎也占不到便宜,因为要做成任何一件事都得同时和好几个部分打交道,部分越多,每个部分分到的简单并不成比例;最后说读者的提法也许更贴切,会列入下一版要考虑的事项。本站核对过第 2 版,公式没有改。读者的提法值得记住,因为它直接对应本章的定义:复杂度是开发者在做某件具体的事时感受到的东西。

2021 年回答另一位读者时,Ousterhout 用了一个书里没有的说法(2021-11):大系统总是复杂的,但真正要紧的不是系统的总复杂性,而是开发者在任一时刻面对的复杂性。模块化设计的全部意义,就是把这两者分开:整个系统复杂到没有人能全部理解,开发者仍然可以轻松地在其中一块上工作。同一帖里还有一句对"把每一块都做小做简单就行"的回应:这种说法漏掉了接口也是复杂性的来源,块越小,接口加起来越多。

2026 年讨论组里有一场二十多条的争论。一位读者认为书里把依赖说得太坏:模块之间不打交道,程序根本无法工作;依赖是模块化的本质,该做的是把依赖理清楚、写明白,坏的只是隐晦的依赖。

Ousterhout 承认 4.1 节"理想世界里模块彼此完全独立"那段话是为了说明观点而夸张,但不接受"有好的依赖"(2026-05 至 06,不在书里)。论证是一个对照:两个系统处处相同,只是其中一个多一条依赖,能想出多的那个反而更好的情形吗?在 Ousterhout 看来,定义一个好模块不是在增加依赖,而是在消除依赖:没有它时,系统其余部分依赖着那项功能的无数实现细节;有了它,这些依赖被换成更少、更简单的一组。另外两点澄清:依赖不只存在于模块之间,一个庞大的模块内部同样满是依赖;模块的个数不等于依赖的条数,所以"拆开"不一定增加依赖,而如果拆开真的增加了依赖,多半也就没有降低复杂性。最后的说法是:依赖避免不了,是几害相权取其轻。

双方最后各自保留意见。本站看法:两边对"该怎么做"其实没有分歧(少而清楚的依赖),分歧在措辞。对读者有用的是那个对照实验,它可以直接拿来审一次重构:这次改动之后,系统里"改这里必须想到那里"的地方是变多了还是变少了?

以下不在书里,来自 Ousterhout 2024 年 1 月在 X(原 Twitter)上的一串回帖。一位开发者主张:人们说"复杂"时,多半指的其实是"不熟悉";真正的复杂度应该看有多少件事缠在一起,可以按操作数、运算和路径的个数去数,好不好懂是另一回事。这位开发者举了两个现象:同一段代码,读过一遍之后人们常常改口说"其实不复杂",代码没变,变的只是感受;只改变量名、结构一点不动,别人也会说"简单多了"。

Ousterhout 逐条不同意,回帖里有五层意思:

改名字的那个现象,Ousterhout 的解读正好相反:这恰恰说明名字的选择对复杂度影响很大(见第 14 章)。双方没有谈拢,Ousterhout 最后说大概只能各自保留意见(2024-01-19)。

本站看法:对方数的那种东西(路径、操作数)是存在的,工具也算得出来,圈复杂度就是这一类;分歧在于"复杂度"这个词该留给谁。本章的定义是从后果出发的:让系统难懂、难改的东西都算。按这个定义,"熟了就好"不是辩护,因为熟悉本身就是成本,每个新来的人都要再付一次。评审时有人说"看不懂",更有用的回应不是"你还不熟",而是问:要熟悉的东西,能不能变少?

练一练

判断症状和成因是本章最该练熟的技能。先自己判断再看解析。

再加一道,取材于读者讨论组(对应上面"边界与反方"里的"讨论组"一栏):

本章共 14 道题,全部在训练场。

自测题

按本书的定义,下列哪种说法正确?
作者的定义是实用的:让系统难以理解和修改的结构性因素。它和规模、功能不一定相关。现实中大系统几乎都难以维护,所以通常也符合定义,但这不是必然的;小系统也可能非常复杂。
三种症状中,作者认为未知的未知最糟。理由是什么?
变更放大和认知负担只要清楚该改什么、该读什么,改动仍然可以是正确的。未知的未知连"该做什么"都不清楚,唯一确定的办法是读完系统里的每一行,这对任何规模的系统都不现实。
一个函数原来有 3 个参数,现在加了第 4 个,所有调用点都编译失败,必须逐个修改。关于这个依赖,下列哪个说法最准确?
作者在 2.3 节正是用"给方法加参数"来说明依赖。这种依赖有代价(要改所有调用点),但它明显、可搜索、编译器能检查,所以不会变成未知的未知。用全局变量绕开它,反而把明显的依赖变成了隐蔽的依赖。
按 C = Σ cp tp,下面哪种改动最可能明显降低整体复杂度?
加权的意思是:开发者花时间多的地方,复杂度的权重大。很少被碰的部分即使很复杂,对整体影响也很小。行数不是复杂度。
关于复杂性的成因,下列哪项描述是模糊而不是依赖?
A、B、D 都是依赖:一段代码不能脱离另一段代码被修改。C 是模糊:重要的信息不明显,作者特别指出不一致(同一个名字两种用途)是模糊的主要来源。
为什么复杂性一旦累积起来就很难清除?
这是 2.4 节的论点:复杂性是增量的,没有单个罪魁祸首。也正因如此,防范要从每一个小改动做起,这就是第 3 章的"零容忍"和持续投资。

小结与读后一句话

既然复杂性是一点点积累起来的,那么问题就变成了:每天写代码的时候,你愿意为了不积累复杂性多花多少时间?第 3 章讨论的就是这个问题:战术式编程和战略式编程。

读后一句话:用你自己的话写下这一章最重要的一个观点,最好结合你写过的某段代码。