跳转到主要内容

第 13 章 · 整体部分

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

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

这一章讲了什么

这一章分三部分:怎么在设计阶段减少 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 更少的版本,替换使用中的构件同样要走与新增构件一样的测试流程;此时测试用例通常更完整,测试时间反而会短很多。作者说这套阶段化变更非常优美地容纳了紫色线束技术:在下次定期发布之前一直使用快速补丁,而在当前发布中,只把已经过测试并文档化的修补整合进系统

我的判断

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

和我手上的工作有什么关系

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

一句话记住

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