# 第 13 章 · 整体部分

> 怎样造出能运行的系统：自上而下的设计剔除 bug，构件单元按计划调试，系统集成一次只加一个构件。

---

LLMS 索引： [llms.txt](/llms.txt)

---

题记用的是《亨利四世》里的一段对话："我可以召唤地下的幽魂。""这我也会，什么人都会，可是当您召唤它们的时候，它们会应召而来吗？"作者把它接到现代神话上：有人说自己能编写控制航空运输、拦截导弹、管理银行账户的系统，回答是一样的——**我也可以，任何人都可以，但当你真的写出来，它会应召而来吗？**

## 这一章讲了什么 {#summary}

这一章分三部分：怎么在设计阶段减少 bug、怎么调试单个构件、怎么把构件组装成系统。

### 剔除 bug 的设计

作者先给出 bug 的主要来源：**系统各个组成部分的开发者都会做出假设，而这些假设之间的不匹配，是大多数致命和难以察觉的 bug 的来源**。第 4、5、6 章讲的概念完整性正是直接对付这件事——它在让产品易用的同时，也使开发更容易、bug 更不容易产生。所以"详尽、艰苦的体系结构设计"不是洁癖，而是为了这个目的。他引贝尔实验室 V. A. Vyssotsky 的话："关键的工作是产品定义。许许多多的失败完全是因为那些产品未精确定义的地方而导致的。"

由此引出四个具体做法：

**测试规格说明。** 在编写任何代码之前，规格说明必须提交给**外部测试小组**，详细检查它的完整性与明确性。理由由 Vyssotsky 说出来："他们不会告诉你他们不懂。相反，他们乐于自己摸索出解决问题和澄清疑惑的办法。"——也就是说，规格说明里的含混之处，实现者会用猜的方式消化掉，而猜错的地方到很晚才会暴露。

**自上而下的设计。** Niklaus Wirth 在 1971 年把许多优秀程序员多年使用的方法形式化：把开发划分为体系结构设计、设计实现、物理编码实现，每一步都用自上而下的方式。Wirth 把设计看成一连串精化步骤——先由粗略的任务定义和大概方案得到主要结果，再细查结果与期望的差距，同时把解决方案在更细的层次上分解；任务定义的每次精化都伴随算法精化，可能还有数据表达的调整。关键在于：一旦识别出模块，**对它的进一步细化可以独立于其他工作进行，而模块的大小决定了程序的适用性和可变化的程度**。他还主张每一步都尽量用级别更高的表达方法，直到非细化不可。

作者列出这样做的四条收益：清晰的结构更容易精确描述需求与模块功能；模块分割与独立性避免了系统级 bug；细节被抑制之后，结构缺陷更容易识别；设计在每一步都可测试，于是测试能尽早开始，且每步的测试落在合适的级别上。他补充说，按部就班不等于不能回头——至少它让人更清楚**什么时候、为什么**该抛弃整个设计重新开始，而糟糕的系统往往就是硬要挽救一个基础很差的设计、不停贴补丁。他甚至断言：十年内，自上而下的设计会成为最重要的新型形式化开发方法。

**结构化编程。** 这一系列方法主要来自 Dijkstra，理论证明由 Bohm 与 Jacopini 给出：程序的控制结构基本只保留语句形式的循环（`DO WHILE`）与 `IF...THEN...ELSE` 条件判断。作者也记下了反对意见——`CASE` 多路分支、异常跳转等结构很实用，完全禁止 `GOTO` 显得教条。但他指出真正的要点不在禁令："**把系统的结构作为控制结构来考虑，而不是独立的分支语句**"，这是程序设计发展史上向前迈出的一大步。

### 构件单元调试

作者说过去二十年调试技术走了一个大圈，四个阶段各有各的道理：

| 阶段 | 做法与动机 |
| --- | --- |
| 本机调试 | 早期机器 IO 差、延迟长，磁带读写让调试难以忍受，所以一次交互会话要尽可能塞进更多试验；程序员预先设计停止点、检查的内存位置与失败对策。花在写调试程序上的时间可能达到被调试程序编制时间的一半。"重大罪过"是没把程序切成测试段、没计划终止位置就按 START |
| 内存转储 | 本机调试效率高（两小时可能发现一打问题），但机时昂贵；有了在线高速打印机，改为"跑到检测失败就转储全部内存"，再回桌面逐个位置推敲。桌面时间没减少，但发生在执行之后、更含混，且受批处理周期拖累 |
| 快照 | 内存从 2000–4000 字涨到无法整机转储，于是出现选择性转储、选择性跟踪和把快照插入程序的技术；OS/360 的 TESTRAN 允许插入快照而无需重新汇编编译，是这条路的终极产品 |
| 交互式调试 | 1959 年 Codd 与 Strachey 分别发表分时调试论文，兼有本机调试的实时性与批处理的高利用率；配上高级语言与高效编辑工具，改代码和取快照都变得容易 |

交互式调试那一段藏着一个我觉得很有价值的实验：本机调试逼人预先计划，而交互式调试不需要——因为人思考时机器时间不浪费。但 **Gold 的实验发现，每次调试会话中第一次交互取得的进展是后续交互的 3 倍**。作者的解读是：缺少对调试会话的计划，我们并没有充分利用交互式调试的潜力，本机调试时代那段高效率的"计划期"消失了。

他因此给出自己的经验比例：**正确使用良好的终端系统，往往要求每两小时的终端会话对应两小时的桌面工作**——一半用于上次会话的清理（更新调试日志、归档更新后的程序清单、解释调试中出现的怪现象），一半用于准备（为下一次操作设计详细测试与改进计划）。没有计划，两小时的高产很难维持；没有清理，后续会话既不成体系、也无法持续推进。

### 系统集成调试

他把"出乎意料的困难部分"指定为系统集成测试，并重申两件事：**系统调试花费的时间会比预料的更长**，它的困难本身就证明了需要一套完备、系统化、可计划的方法。方法有六条：

- **使用经过调试的构件单元**。通行的看法是集成只能在各部分都能正常运行之后开始，但也存在两种不同做法："合在一起尝试"（理由是除构件 bug 外还有接口等系统级 bug，早合拢早暴露）和"用各部分相互测试"（省掉搭建测试平台的工作）。作者说这两种想法都合理，但经验显示都不完全正确——**在系统测试中使用完好的、经过调试的构件，比搭建测试平台加做全面单元测试更省时间**。他还提到一种更巧妙的说法"文档化的 bug"：单元缺陷已被发现但未完全修复时就进入系统调试，测试人员知道这些缺陷的后果从而忽略它们。但他随即泼冷水：这多半是良好愿望，因为调试人员并不了解这些 bug 的全部后果，而记录在案的 bug 在被修复时又会注入新的未知问题。
- **搭建充分的测试平台**。测试平台指为调试服务、但不会进入最终产品的程序与数据，规模可能达到被测对象的一半代码量——他指出这是合乎情理的。三种形式：**伪构件**（只有接口和伪数据，例如排序程序未完成时先输出格式正确但无意义的有序数据）、**微缩文件**（只含典型记录但覆盖全部描述的小文件，专治对磁带磁盘格式的误解）、以及**辅助程序**（数据发生器、特殊打印输出、交叉引用分析等专用工具）。
- **控制变更**。硬件调试中严密控制变更的技术同样适用于软件：必须有人负责构件单元的变更与版本替换；必须存在受控拷贝——供单元测试的最终锁定版本、用于修缺陷的测试版本、以及其他人在其上开发的开发库。他讲了 System/360 工程里那个著名的做法：**紫色线束**——发现 bug 后先接一段紫色电线做快速修复，让测试继续，同时把更改记入日志、写正式变更文档、启动设计自动化流程，最终把调整落到电路图与线路中，紫色线束随之消失。软件同样需要这个手法：关键因素是**对变更与差异的记载**——日志里记录所有变更，在源码中显著标出"快速补丁"与"正式修改"的区别，而后者必须是完备、经过测试并被文档化的。
- **一次添加一个构件**。好处显而易见，但乐观与惰性总是诱人破坏它——因为每加一个构件都要配调试伪程序与测试平台，太麻烦了，"也许根本不会有 bug 吧？"作者的回应只有一句：拒绝诱惑。要假设系统里存在许多错误，并计划一个有序的过程把它们找出来。每加入一个新构件，都要用完整的测试用例跑一遍子系统——因为原来能在子系统上通过的用例，必须在新的整体上重新运行，也就是回归测试。
- **阶段（量子）化、定期变更**。开发者会不断拿来更快、更小、更完整或 bug 更少的版本，替换使用中的构件同样要走与新增构件一样的测试流程；此时测试用例通常更完整，测试时间反而会短很多。作者说这套阶段化变更非常优美地容纳了紫色线束技术：**在下次定期发布之前一直使用快速补丁，而在当前发布中，只把已经过测试并文档化的修补整合进系统**。

## 我的判断 {#reading}

- 这一章是全书最"工程"的一章，也是最容易被读成清单的一章。但我认为它的主线只有一句：**bug 的主要来源是假设不匹配，所以减少 bug 的功夫要花在定义与结构上，而不是花在更努力的调试上**。后面所有清单都服务于这一点。
- "外部测试小组审规格说明"这条我过去重视不够。Vyssotsky 那句"他们不会告诉你他们不懂"点破了一个普遍现象：**实现者会用猜测填补规格的空白，而且不会主动报告自己猜了**。这意味着未被发现的规格含混，最终都会以 bug 的形式出现，而且出现得很晚。
- 自上而下设计那四条收益里，第二条"模块独立性避免系统级 bug"和第三条"细节抑制让结构缺陷更易识别"，我认为是它真正的价值所在，而不是"从上往下写"这个字面意思。至于作者"十年内会成为最重要的形式化方法"的断言，从今天的角度看并没有实现——结构化方法后来被敏捷实践挤到了背景里，但它作为**局部工具**仍然有效。
- 我在上一章刚被"交互式调试效率翻倍"说服，这一章又看到 Gold 实验的反面：**第一次交互的进展是后续的 3 倍**。两条合起来才是完整的结论——工具给了你实时性，但如果用实时性替代计划，效率增益会被浪费掉。这解释了为什么我有时在终端里待了很久却收获很小。
- "两小时终端会话配两小时桌面工作"这个比例，我认为是本章最实用的一句话，而且它反直觉：**看起来在写代码之外的时间，其实占了另一半**。其中"清理"那一半尤其容易被省掉，而作者说省掉它，后续会话就无法系统化和持续推进。
- 紫色线束这个比喻太好了：**允许快速补丁存在，但必须让它显性、可追溯、并且有明确的回收时机**。它同时解决两个对立的问题——不因为细节不完美而阻塞测试，也不因为赶进度而让临时方案永远留在系统里。软件里的等价物就是"带标记的临时修改 + 修复期限"。
- 我对"文档化的 bug"被否定这一点持部分保留。作者的理由（不了解后果、修复会注入新问题）在当时的调试条件下成立，但在今天的工程实践里，"已知缺陷 + 明确影响范围 + 后续修复计划"是相当常见且有效的做法。差别在于今天有自动化回归测试来兜住修复引入的新问题，而这正是他那个年代缺的东西——所以这条结论的适用性取决于测试基础设施的水平。

## 和我手上的工作有什么关系 {#relevance}

- 我的多数时间花在调试上而不是定义上，这一章正好说明问题出在哪：**先写后想**的代价会在后期以难以定位的 bug 形式偿还。对我这种一个人负责全部环节的项目，最划算的改变是把"规格说明"这一步显式化——哪怕只是写下接口、边界和失败行为，因为我既是实现者也是规格作者，天然最容易用猜测填补空白。
- "外部测试小组审规格"在单人项目里的可行替代是：把规格交给一个独立的 Agent 去复述，看它复述出来的东西是否与我的意图一致。**能复述对，才说明写清楚**；这一步比我反复自读有效得多。
- 紫色线束这条我马上能用：我经常在项目里留下"临时能跑"的取巧代码，然后忘了它。按这一章的做法，应该给临时补丁加显式标记（例如 `# TEMP:` 加日期与替代计划），并且规定它们必须在某个里程碑前回收。这样临时方案就不会悄悄变成遗产。
- "一次添加一个构件 + 每次回归"这条对嵌入式尤其重要：硬件调试成本高，一次烧板改一堆东西会让排查无从下手。以前我靠自觉，现在有明确的规则：**一次只加一个变量，并重跑先前的测试**。
- 测试平台的投入比例给了我一个心理许可：测试平台可能相当于被测对象一半的代码量，而且"合乎情理"。我以前总觉得写测试夹具是浪费，现在可以把它算进预算，而不是当成额外负担。

## 一句话记住 {#takeaway}

bug 主要来自各部分的假设不匹配，所以要靠定义和结构去剔除；到了集成阶段，唯一可靠的办法是假设错误大量存在、一次只加一个构件、每次都回归，并让临时补丁显性留痕。
