出错的那一天 — 契约、路障与响亮的失败
这一章讲四件事: 两类错误的分界与各自的处置;路障战术怎么划这条线; 契约式设计怎么把责任写成合同;以及失败时为什么要「 响亮」。 主走查:一个接收外部输入的函数,从门口一路走进手术室。
1. 先分家:错误有两种,混着处理是万恶之源
「出错」这个词日常只有一个意思,写代码时必须拆成两个——它们的原因不同,正确反应也不同1:
- 预想之内的错误:用户输错了格式、文件不存在、网络断了。世界本来就是这个样子, 发生不是异常,是日常。处置:错误处理——发现问题、给明确反馈、走补救分支;
- 预想之外的错误:程序走到了「按设计不可能发生」的地方—— 这意味着代码与自己矛盾,是 bug。处置:断言——立刻停下来大声报错。
断言这个词要先讲清:断言(assert)是代码里的一种检查语句——写上一个「此刻必须为真」的条件, 程序跑到这里就验证它;为真,继续;为假,说明程序已经和自己的设计矛盾,立刻终止并报出位置2。
为什么 bug 要「立刻终止」而不是「试着继续」?书里的理由链很硬3: 发生不该发生的事,代码已处于无法继续运行的状态;强行运行只会更糟; 损坏的数据一旦进入重要数据库,将无法挽回。带着故障勉强跑, 不是延长了寿命,是扩大了爆炸半径。
2. 契约式设计:把责任写成合同
这一节回答:函数和调用它的人之间,谁对什么负责——回答清楚了,检查放哪、怎么检查就定了。
契约式设计(Design by Contract,DbC)借商业合同的形式规定双方义务4:
- 前置条件:函数开工前对输入的要求——由调用方负责满足(传对参数是调用方的责任);
- 后置条件:函数完工后对结果的承诺——由函数负责兑现;
- (面向对象里还有第三条:不变式——无论怎么调用,类对自己使用者的承诺始终为真,由类方担保5。)
「调用方」「参数」两个词补一下:调用方=发起调用的那段代码;参数=调用时传给函数的输入值。
契约被违反意味着什么?不是「用户做错了」,是「不该发生的事发生了」—— 两者有本质区别,处置天差地别6。
契约的三条纪律
书里给了三条容易被做错的纪律,每条都值得单独记7:
- 函数方不调整参数。函数检查参数时发现不合格,不许悄悄把它修正成合格值再继续—— 调整是调用方在调用前的责任。函数只管按契约放心使用。 理由:函数若替调用方擦屁股,每个调用方的错误都要在函数里预备一种擦法, 函数立刻膨胀;「被调用的函数方应当根据契约放心地使用参数。这样能保证代码简洁」;
- 断言不用于检查用户输入。用户输错是预想之内(§1 的左类),要在调用前拦下; 断言只对着「预想之外」。混淆这两者,程序就会把用户的每次手滑都当成系统崩溃的理由;
- 预想要严格,约定要宽容。让函数接受一切输入、永远返回正确结果,代码会多到写不完; 正确姿势:对输入提出苛刻要求(前置条件严格),对输出承诺尽力而为(后置条件宽松)—— 书里管这叫**「懒惰的代码」**8。这也是黑箱好用的前提:契约边界越清楚,黑箱越敢当。
断言的工程细则
断言本身有几条使用规范9:断言的检查代码不许有副作用(不改变任何变量或状态—— 否则调试版和正式版行为不一致);断言不进发布版——它是给开发与维护用的内部工具, 不是给终端用户看的信息,留着还拖累性能。
3. 路障战术:两类检查的分界线
这一节回答:一个系统里,断言和错误处理各放在哪——答案是「按位置分」。
防御性编程(本书出处《代码大全》)给了一个空间化的方案——路障战术: 像船体用隔离舱分舱段、大楼用防火墙分防火区那样,在代码里立一道门, 把系统分成两个区10:
路 障(一道门)
脏区 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~│~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 净区
│
用户输入 / 文件 / 网络 │ 内部模块
┌──────────────┐ 消毒 ┌──────────┐ │ ┌──────────────┐
│ 界面、解析模块 │ ────────→ │ 转换成 │─┼─→│ 业务逻辑各模块 │
│ 错误处理在这里 │ 验证/转换 │ 正确格式 │ │ │ 断言在这里 │
└──────────────┘ └──────────┘ │ └──────────────┘
│
图说:书里的比喻是手术室——所有东西必须先消毒才能进门;
通过大门进入手术室的东西都是安全的。门的位置=分界线。
书里对这张图的分工讲得非常干净11:
- 门外(脏区):数据来路不明,用错误处理应对预想之内的错误;
- 门本身:消毒——验证数据、立刻转换成正确格式(书里的说法:「输入的数据一定要立刻转换成正确的格式才行。这是基本中的基本」——格式不定的数据在代码里多待一秒,就多一分被误用的机会,比如把表示「是」的 Yes 传进只收颜色的参数里)12;
- 门内(净区):数据已消毒,用断言。因为此刻再发现非法值, 那一定不是数据的错,是代码自己的错——bug 上了断言,当场爆炸。
同一道门,同时解决了「两类检查放哪」和「谁来消毒」两个问题—— 这就是路障战术比散装检查高明的地方:它把纪律变成了位置。
门外的工具箱:错误处理九变种
预想之内的错误怎么处理?书里列了完整菜单13,挑承重的说:
| 变种 | 做法 | 适用 |
|---|---|---|
| 返回无害值 | 数值返回 0,字符串(一串文字)返回空 | 结果无损的场景 |
| 用下一个数据 | 跳过坏记录继续读 | 一次处理一整串 |
| 用上一个值 | 沿用上次读数 | 室温读数 1 秒读 100 次,偶尔失败一次无妨(书里原例) |
| 用近似值 | 低于 0 显示 0,高于 100 显示 100(书里原例) | 显示场景 |
| 记日志后继续 | 警告写进日志,处理继续 | 小错;但「发生过的错误一定要记录下来」 |
| 报告给上游 | 抛出异常或返回错误码,由上层决定 | 本层处理不了 |
| 统一错误处理函数 | 全系统的错误走同一个出口 | 一元化降调试难度;代价是耦合升高 |
| 就地显示错误 | 在发生处显示说明 | 简单直接;代价是界面与逻辑纠缠 |
| 终止处理 | 停机 | 安全攸关的系统 |
菜单底部有一条铁则收口:不忽视错误代码——哪怕函数理论上不会失败, 返回值也要检查;系统调用(向操作系统请求服务的函数)每一次都要查14。 忽略一个错误码只要一秒,追一个被吞掉的错误要一天。
4. 响亮的失败:修复原则与安全原理
这一节回答:失败的那一刻,程序该做什么动作——以及为什么安静是最危险的。
UNIX 思想的修复原则一句话:修复失败,立刻停止处理; 同时错误报告必须「震耳欲聋」——尽早、显眼地让用户知道15。 理由是本书最冷峻的一段:静默的失败会悄悄破坏数据。「我们发现问题时已经晚了」, 而修复失败后继续执行,「故障可能会在不知不觉间破坏所有数据」16。 软件暂时不可用是小事,用户的重要数据被无声损毁才是不可挽回的结局。
它还有一条对偶纪律,来自网络世界的老格言:对输入宽容、对输出保守—— 但书里立刻用 HTML 的教训泼了冷水:浏览器们对不规范网页过于宽容, 各自选了不同的「补法」,结果同一个页面各家渲染各樣17。 修正版格言:宽容的是「运行方式」,不许宽容的是「运行方式的解释」—— 怎么执行可以灵活,规矩本身不能松18。
安全原理(七个设计原理的最后一条)把同样的思想放到设计期:
编写代码时刻意把「不可能发生」的条件也写进去——一定成立的 if 也写 else,
一定命中的分支也写 default,不可能为空的变量也检查空值19。
书里的类比是取暖器的倾倒断电:没人希望它倒,但设计必须假设它会倒20。
为什么说明书里没要求还要写?书里给了一个深刻的分工:
需求和说明书只是必要条件(不满足一定错),充分条件(保证任何情况都对)靠程序员补——
安全性的正是那块「说明书永远不会写的部分」21。
一个真正的选择:正当性 vs 坚固性
错误处理还有一层价值排序,书里给了清晰的二分22:
- 正当性:一定不返回错误的结果——宁可什么都不返回,绝不返回错的;
- 坚固性:无论如何让软件继续跑——宁可带着错误结果,不能停下来。
选哪个,取决于软件的用途。书里给的两端正好是光谱的极点23: 医疗管理软件,宁可停机也绝不给出错误剂量——正当性优先; 文字处理软件,宁可偶发错误也绝不突然关闭丢掉用户的稿子——坚固性优先。 这不是技术选型,是价值判断,而且必须由「软件对用户意味着什么」来答—— 技术方案只是 这道判断题的落笔。
5. 作者的判断与证据
书里给了证据的:
- 契约式设计出自 Bertrand Meyer《面向对象软件构造》,是 Eiffel 语言的核心机制,理论谱系完整;
- 路障战术、错误处理九变种、正当性/坚固性出自 McConnell《代码大全》,是工业界长期实践的分类;
- HTML 宽容酿苦果是真实历史(各浏览器对不规范写法的补法不同,同一页面渲染不一)。
作者的推测与展开,要分开看的:
- 「懒惰的代码」(前置严、后置宽)是作者对契约精神的口号化,方向符合 Meyer 原意但措辞是他的;
- 安全原理的「统一标准:规定哪些条件必须写」是作者给出的团队化建议,原思想(《程序员修炼之道》式防御)没有这个组织学延伸。
6. 边界与局限
- 断言不是验证工具。 它抓「代码自相矛盾」,不替代测试(第 06 章)与错误处理;书里在「断言与错误处理的区别」处已划清,但读者仍常混淆。
- 「立刻终止」有适用范围。 面向消费者的软件恰恰要坚固性优先(书里自己的文字处理例子)——「崩溃优先」主要适用于:损害不可逆(数据、资金、医疗)或问题应尽早暴露的开发阶段。书里两端都给了,但没给中间地带(如电商下单失败该重试还是 终止)。
- 契约式设计的语言支持有限。 原生支持 DbC 的主流语言少;实践中多用断言+注释模拟,书里「到语言中编程」一节的立场是好想法不该被语言机制束缚——语言缺什么,就想办法把它补进语言。
- 2016 年之后的进展:现代语言普遍用「类型系统+可空类型」把一部分前置条件检查移到编译期(如空值检查),书内无此工具,思路同源但落点更早。
7. 可带走的
- 先问「这个错误预想之内吗」——用户错走错误处理,代码错上断言;答错方向,后面全错;
- 函数不许替调用方修参数——擦屁股的代码长在调用方;函数按契约放心用;
- 前置要严格,后置要宽容——契约边界清楚的函数,才配被当黑箱;
- 画一条门:门内无菌(断言),门外用错误处理,门口消毒(立刻转成正确格式);
- 输入格式就地转换,不让带病数据进系统一步;
- 错误码永远检查——「理论上不会失败」的函数更要查,这是铁则不是洁癖;
- 修不了就响亮地停——静默继续的代价是悄悄毁数据;
- 不可能的条件也写进代码——else、default、空值检查是给未来的自己留的保险丝;
- 先定价值再定方案:这个软件错一次的代价是什么?医疗答「绝不能错」,写作工具答「绝不能停」。
8. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 前置/后置条件定义 | 6.2 契约式设计 | text/73-ch06.txt:159(搜「前置条件」) · text/73-ch06.txt:160(搜「后置条件」) |
| 不变式 | 6.2 契约式设计 | text/73-ch06.txt:242(搜「不变式」) |
| 函数方不调整参数 | 6.2 契约式设计 | text/73-ch06.txt:215(搜「不调整参数」) · text/73-ch06.txt:221(搜「放心地使用」) |
| 断言不查用户输入 | 6.2 契约式设计 | text/73-ch06.txt:228(搜「用户输入」) |
| 预想严格约定宽容、懒惰的代码 | 6.2 契约式设计 | text/73-ch06.txt:234(搜「严格」) · text/73-ch06.txt:240(搜「懒惰的代码」) |
| 断言无副作用、发布版剔除 | 6.2 契约式设计 | text/73-ch06.txt:264(搜「副作用」) · text/73-ch06.txt:267(搜「发布版」) |
| 尽早停止运行 | 6.2 契约式设计 | text/73-ch06.txt:271(搜「停止运行」) · text/73-ch06.txt:277(搜「无法挽回」) |
| 防御性驾驶类比、两类确认 | 6.3 防御性编程 | text/73-ch06.txt:296(搜「防御性驾驶」) · text/73-ch06.txt:307(搜「预想之内」) · text/73-ch06.txt:313(搜「预想之外」) |
| 路障战术、手术室 | 6.3 防御性编程 | text/73-ch06.txt:346(搜「路障战术」) · text/73-ch06.txt:356(搜「手术室」) |
| 输入立刻转正确格式 | 6.3 防御性编程 | text/73-ch06.txt:476(搜「消毒」) · text/73-ch06.txt:483(搜「基本中的基本」) |
| 错误处理九变种 | 6.3 防御性编程 | text/73-ch06.txt:394(搜「返回无害的值」) · text/73-ch06.txt:409(搜「温度计」) · text/73-ch06.txt:412(搜「近似值」) · text/73-ch06.txt:418(搜「警告信息」) · text/73-ch06.txt:446(搜「终止处理」) |
| 正当性与坚固性 | 6.3 防御性编程 | text/73-ch06.txt:459(搜「正当性」) · text/73-ch06.txt:468(搜「医疗」) · text/73-ch06.txt:470(搜「坚固性就要优先于正当性」) |
| 不忽视错误代码 | 6.3 防御性编程 | text/73-ch06.txt:487(搜「铁则」) · text/73-ch06.txt:492(搜「系统函数」) |
| 到语言中编程 | 6.3 防御性编程 | text/73-ch06.txt:498(搜「在语言中」) |
| 修复失败立刻停止 | 3.49 修复原则 | text/55-ch03-49.txt:8(搜「立刻停止处理」) · text/55-ch03-49.txt:23(搜「震耳欲聋」) |
| 静默破坏数据 | 3.49 修复原则 | text/55-ch03-49.txt:20(搜「悄悄地破坏」) · text/55-ch03-49.txt:18(搜「已经晚了」) |
| Postel 法则与 HTML | 3.49 修复原则 | text/55-ch03-49.txt:33(搜「自由主义」) · text/55-ch03-49.txt:39(搜「苦果」) · text/55-ch03-49.txt:41(搜「运行方式的解释」) |
| 安全原理、不可能的条件 | 3.36 安全原理 | text/42-ch03-36.txt:10(搜「不可能的条件」) · text/42-ch03-36.txt:11(搜「else」) |
| 取暖器类比、必 要充分条件 | 3.36 安全原理 | text/42-ch03-36.txt:18(搜「取暖器」) · text/42-ch03-36.txt:32(搜「必要条件」) · text/42-ch03-36.txt:32(搜「充分条件」) |