读前:这一章要回答什么
异常为什么会让系统复杂得不成比例?在错误真实存在的前提下,怎样让必须处理它的地方尽可能少?
第 4~9 章一直在讲怎样把模块做深。这一章用同样的眼光看异常:一个类会抛出的异常属于它的接口,而且是特别"重"的那部分,因为异常会穿过好几层调用栈,牵连的不只是直接调用者。所以异常越多,模块越浅。
它也是好几条线索的交汇点。第 6 章 6.8 节说要消除特殊情况,而异常正是特殊情况代码最主要的来源之一;本章的"屏蔽"是第 8 章"把复杂性往下拉"的一个实例;"聚合"则又一次说明了通用机制的好处。10.9 节"别做过头"预告了第 21 章的主题:判断什么重要。
这一章最反直觉的地方,是它反对"能检测的错误越多越好"这种防御式本能。读的时候注意作者的论证并不是"错误不重要",而是处理异常的地方越少越好,而最好的办法是让一部分"错误"根本不存在。
- 10.1 节列了异常难处理的好几条理由:两种应对方式各有各的麻烦、处理过程中又冒出新异常、语法冗长、很难测试。读完拿你最近写的一段错误处理代码对照一下:它在生产环境里真正执行过几次?
- 10.3 节 Tcl
unset的修正:注意改的不是实现,而是操作的定义,只改了一句话。这是全章的精髓,10.4、10.5 两节的例子用的是同一招。 - 10.7 节后半段:屏蔽为什么适合放在底层、聚合为什么适合放在高层;RAMCloud 的"错误提升"有什么前提。然后读 10.9 节,问自己:什么时候这些技术会害人?
核心观点
异常是广义的
任何改变正常控制流的不常见情况都算:throw 出来的,和"返回一个特殊值表示没做成"的,都会增加复杂性。
抛出容易,处理难
复杂性来自处理代码。所以目标不是少检测错误,而是减少必须处理异常的地方。
异常是接口的一部分
抛的异常越多,接口越复杂、类越浅;异常还会往上穿透,连带影响更高层的调用者。
① 定义掉(首选)
改写操作的语义,让正常行为本身就覆盖原来的"错误"情况。
② 屏蔽
在底层发现并就地处理,上层完全感知不到。这是"把复杂性往下拉"的一种。
③ 聚合
让异常往上传几层,在高处用一个处理器接住一大批。把小错误"提升"成已有恢复机制的大错误,也是一种聚合。
④ 直接崩溃
难以处理又很少发生的错误,打印诊断信息后退出。能不能这样做,取决于应用。
别做过头
只有调用者不需要的异常信息才可以藏起来。调用者需要的,哪怕让接口变复杂也要暴露。
为什么异常处理格外昂贵
遇到异常,代码只有两条路:要么想办法继续往前做(重发、从副本恢复),要么中止并往上报。前者会引出新的异常:重发的包其实没丢,只是迟到了,对端就收到了重复的包;后者得先把做了一半的状态恢复一致,再交给上层,而上层又面临同样的二选一。书中把这叫作处理异常的过程中产生的次生异常,它们往往比原来的异常更隐蔽。
语言的异常语法也在添乱。书中用一段 Java 反序列化代码说明,光是 try/catch 的样板就比正常逻辑还长(10.1 节)。下面是同样问题的 C++ 版本:
// 从快照文件恢复内存索引
try {
File f(path); // 可能抛 IoError(不存在、无权限)
Header h = readHeader(f); // 可能抛 FormatError、IoError
for (uint32_t i = 0; i < h.count; ++i)
index_.insert(readEntry(f)); // 可能抛 FormatError、IoError
} catch (const FormatError& e) {
// ……
} catch (const IoError& e) {
// 是打开失败、读文件头失败,还是读到一半失败?到这里已经分不清了,
// 而这三种情况下 index_ 的状态各不相同:空的、空的、填了一半。
}
一个大 try 块让人看不出每个异常从哪里来,也看不出出错时状态被破坏到了什么程度。反过来给每一行单独包 try,出错位置清楚了,正常流程却被切得支离破碎,相同的处理代码还会在几个 catch 里重复出现。两种写法都不好,根源在于需要处理的地方太多。
更麻烦的是,异常处理代码很少被执行。I/O 错误在测试环境里很难造出来,于是处理代码里的 bug 可以潜伏很久,等到真正需要它的那天才发现它不工作。作者有一句很喜欢的话:
code that hasn't been executed doesn't work— 第 10.1 节
书中引用了 OSDI 2014 年的一项研究(Yuan 等):在分布式数据密集型系统里,超过 90% 的灾难性故障是由错误处理不当造成的。本站补充:那篇论文的标题就叫《简单的测试就能防止大多数严重故障》,很多出事的处理代码一眼就能看出问题(比如空的 catch、只写了一行 TODO),只是从来没有被测试覆盖过。
所以本章的策略很朴素:
Throwing exceptions is easy; handling them is hard.— 第 10.2 节
既然难的是处理,那就让需要处理的地方越少越好。作者给出了四种技术,其中把错误定义掉是最好的办法。它们的区别可以用一个问题概括:处理器放在哪里?
交互:数一数要写几个 catch
下面是一个小型文本协议服务器的调用关系:分发器把命令交给 5 个处理函数,每个处理函数会调用解析参数、存储层、内存分配、回写响应等下层函数,每个调用都可能出现某种异常。一开始采用最直接的写法:哪里可能出错,就在哪里处理。按书中的顺序依次打开四种技术,观察处理位置的数量怎么变化,以及它们被挪到了哪里。最后试试那个黄色的选项。
catch 计数器
计数规则:屏蔽、聚合、崩溃都不是让处理器消失,而是把许多处理器合成一个,所以它们各算 1 处;只有定义掉才真正是 0。19 处、几个异常种类都是本站为演示设定的规模。
四种技术,按书中的顺序
① 把错误定义掉
书中的三个例子用的是同一招。Tcl 的 unset 在变量不存在时报错,作者事后称这是自己设计 Tcl 时最大的错误之一:unset 最常见的用途是清理一个可能只做了一半的操作留下的临时变量,调用者根本不确定哪些变量存在,只好每次都包一层 catch。把定义从"删除变量"改成"确保变量不存在",错误就消失了(10.3 节)。Unix 允许删除一个正被打开的文件:名字立刻从目录里消失,数据等最后一个使用者关闭后才释放,删除方和使用方都不会遇到错误;Windows 的做法则是拒绝删除(10.4 节)。Java 的 substring 越界抛异常,如果定义成"取落在这个区间里的那些字符",越界就不再是错误(10.5 节)。
下面是一个服务端常见的原创例子:连接关闭时取消它的空闲超时定时器。
// 取消定时器。id 不存在(从未创建、已经触发、已经取消)时抛 NoSuchTimer。
void TimerWheel::cancel(TimerId id);
void Connection::close() {
try {
timers_.cancel(idleTimer_);
} catch (const NoSuchTimer&) {
// 超时回调可能刚好已经触发了,也可能 close() 被调用了两次
}
::close(fd_);
}
"定时器已经触发"和"取消"之间存在竞态,这是正常情况而不是错误。可每个调用者都要知道这个异常,写同样的 catch,再写一行注释解释为什么忽略它。
// 确保 id 对应的回调以后不会再被调用。若它已经触发或已被取消,什么也不做。
// 返回这次调用是否真的阻止了一次触发(极少数调用者关心)。
bool TimerWheel::cancel(TimerId id);
void Connection::close() {
timers_.cancel(idleTimer_);
::close(fd_);
}
新定义读起来完全自然:"确保它不会再触发",而它本来就不会再触发,那就什么都不用做。接口少了一个异常,调用代码少了一个分支,close() 被调两次也安全了。返回值把信息留给少数真正关心的调用者,这一点下面"别做过头"一节还会讲到。
可以把这一招整理成几种常见的"改定义"句式(本站整理):
| 原来的定义(会出错) | 改后的定义(不会出错) | 例子 |
|---|---|---|
| 删除 X(X 不在就报错) | 确保 X 不存在 | unset、erase、删除缓存项 |
| 创建 X(X 已在就报错) | 确保 X 存在 | mkdir -p、建表 IF NOT EXISTS |
| 取下标区间(越界就报错) | 取区间与数据的交集 | 子串、切片、分页 |
| 取消 / 关闭(重复调用就报错) | 确保它以后不再发生 / 处于关闭状态 | 定时器、连接、订阅 |
| 查找(找不到就报错) | 返回"可能没有"的结果 | std::optional、空列表 |
OSTEP 第 39 章讲过 unlink() 为什么叫这个名字:删除文件其实只是去掉一个名字到 inode 的链接,inode 的引用计数归零、而且没有进程还打开着它时,数据才真正释放。书中 10.4 节夸的就是这个设计。另外,OSTEP 第 49 章讲 NFS 时强调协议操作被设计成幂等的,客户端超时后可以无脑重发。下面讲屏蔽时你会看到,这正是 NFS 能在客户端屏蔽服务器故障的前提。
② 屏蔽:在底层就地处理
有些异常无法定义掉,但可以在很低的层次处理掉,让上层根本看不到。TCP 在内部重传丢掉的包,应用只看到一个可靠的字节流。书中还举了一个更有争议的例子:NFS 服务器无响应时,客户端会一直重试,应用就挂在那里,控制台上打印"服务器没有响应,仍在重试"。作者认为这比报错更好:应用拿到错误也没什么可做的,要么自己重试(同样挂住),要么层层中止,最后用户的整个工作环境都垮掉,服务器恢复后还得把所有程序重启一遍(10.6 节)。
一个原创例子:分布式键值存储的客户端库。请求发到了非 leader 节点,或者 leader 刚刚换了,这对调用者来说毫无意义,客户端库可以自己处理:
// 读取 key。请求发到非 leader 节点时,节点会回复 NotLeader 并附上它所知道的 leader,
// 客户端自动切换过去重发;调用者看不到这种情况。
// 只有到 deadline 仍得不到答复时才返回 Unavailable。
Result<std::string> KvClient::get(std::string_view key, Deadline d) {
for (;;) {
Reply r = rpc(leaderHint_, GetRequest{key}, d);
if (r.ok()) return r.value();
if (r.notLeader()) { leaderHint_ = r.leader(); continue; }
if (r.timedOut()) {
if (d.expired()) return Unavailable{};
leaderHint_ = nextNode();
continue;
}
return r.error(); // 其他错误(比如权限不足)照常上报
}
}
这一个循环替所有调用者处理掉了"找错节点"和"节点暂时无响应"两种情况,KvClient 的接口变小了,功能却变多了,模块更深。注意两个细节:读操作是幂等的,所以重发安全;如果是写操作,重发可能造成重复写入(10.1 节说的次生异常),需要给请求带上唯一 id 让服务端去重。另外它仍然保留了 deadline:屏蔽的是调用者不需要知道的事,"一直连不上"调用者是需要知道的。
③ 聚合:在高处用一个处理器接住一批
书中 10.7 节的例子来自学生写的 Web 服务器:每个处理 URL 的方法都要取参数,很多学生在每次取参数的地方各包一个 catch,所有 catch 干的都是同一件事。更好的做法是让异常一路传到顶层的分发方法,在那里只写一个处理器。下面是同样思路的原创例子,注意"改之前"用的是返回值,并没有 throw,但按本章的广义定义,它照样是一处处异常处理:
Response handleCreateOrder(const HttpRequest& req, const User& user) {
auto sku = req.json().getString("sku");
if (!sku) return Response::badRequest("missing field 'sku'");
auto qty = req.json().getInt("qty");
if (!qty) return Response::badRequest("missing field 'qty'");
if (*qty <= 0) return Response::badRequest("'qty' must be positive");
if (!user.can(Permission::PlaceOrder)) return Response::forbidden("no permission");
return Response::created(orders_.create(user.id(), *sku, *qty));
}
// handleCancelOrder、handleUpdateAddress……几十个处理函数都是这个样子
真正的业务只有最后一行。几十个处理函数里,同样性质的检查和错误响应写了几百遍,措辞还各不相同。
// 处理一个请求时,任何地方都可以抛 RequestError 来中止这个请求。
// message 在抛出处生成,分发器把它放进错误响应。
struct RequestError : std::runtime_error {
RequestError(int status, std::string message);
int status;
};
Response handleCreateOrder(const HttpRequest& req, const User& user) {
user.require(Permission::PlaceOrder); // 不满足时抛 403
auto sku = req.json().requireString("sku"); // 缺失时抛 400
auto qty = req.json().requireInt("qty", {.min = 1}); // 缺失或越界时抛 400
return Response::created(orders_.create(user.id(), sku, qty));
}
Response Dispatcher::dispatch(const HttpRequest& req) {
try {
return route(req)(req, authenticate(req));
} catch (const RequestError& e) {
return Response::error(e.status, e.what()); // 唯一的处理器
}
}
分发器只知道错误响应长什么样,不知道有哪些具体错误;requireInt 知道怎样描述参数错误,却不知道 HTTP 响应的格式。两份知识各在各的地方,新加的处理函数只要抛 RequestError 就自动接入。
书中把这总结成一个通用模式:系统处理一连串请求时,定义一种"中止当前请求"的异常,在请求循环顶部捕获,清理状态后继续处理下一个请求。作者特别提醒,这类异常要和"整个系统都完了"的致命异常明确区分。上面的分发器只捕获 RequestError,不用 catch (...),原因就在这里。
错误提升(error promotion)是聚合的另一种形式。作者的 RAMCloud 存储系统本来就必须能从整台服务器崩溃中恢复,于是发现单个对象损坏时,它不去单独修复那个对象,而是直接让服务器崩溃,复用那套恢复机制。这样少写了一套恢复代码,而且崩溃恢复被执行得更频繁,其中的 bug 更容易被发现。代价是恢复成本变大,所以只适合罕见的错误:不能每丢一个网络包就让服务器崩溃(10.7 节)。本站补充:系统领域有一篇著名的短文《Crash-Only Software》(Candea 与 Fox,2003),主张让"崩溃后重启"成为唯一的停机和恢复路径;Erlang 社区的"let it crash"也是类似的思路。
④ 直接崩溃
有些错误难以处理、很少发生,处理它们的代码多半永远不会被执行。这时最简单的做法是打印诊断信息后退出。书中的例子是内存耗尽:C 的 malloc 返回 NULL 要求每个调用者都检查,而真有可以释放的内存,程序早就该释放了,所以作者主张用一个包装函数代替 malloc,分配失败就带着信息退出;C++ 的 new 抛 bad_alloc,捕获它也没什么意义,处理代码自己多半还要分配内存(10.8 节)。内部数据结构不一致也属于这一类,它通常意味着 bug:
// 内部不变量被破坏说明有 bug:打印位置和原因后立即终止,不尝试恢复。
[[noreturn]] void fatal(const char* file, int line, const char* expr, const char* msg);
#define KV_CHECK(cond, msg) \
do { if (!(cond)) ::kv::fatal(__FILE__, __LINE__, #cond, (msg)); } while (0)
KV_CHECK(entry.offset + entry.len <= segment.size(), "index entry points past segment end");
能不能崩溃要看应用。对多数程序来说,读写已打开文件时的磁盘硬错误、打不开网络套接字,都可以带着清楚的信息退出;但对一个副本存储系统,遇到 I/O 错误就退出是不可接受的,从副本恢复数据正是它向用户提供的核心价值。
本站整理:遇到一个异常情况时,按这个顺序问自己
- 能不能换个定义,让这种情况变成正常行为?能 → 定义掉。
- 调用者需要知道这件事吗?需要 → 必须暴露,再考虑下面哪种方式让处理的地方最少。
- 能不能在底层就地解决(重试、换节点),而且对调用者无害?能 → 屏蔽。
- 是否有很多地方会抛同样性质的错误、处理方式也一样?是 → 聚合到一个能做出正确反应的高处。
- 罕见、难以恢复、继续运行也没有意义?是 → 直接崩溃(先确认你的应用承受得起)。
边界与反方
定义掉或屏蔽一个异常的前提是:模块外面不需要这个信息。unset 和 substring 满足这个前提,少数关心特殊情况的调用者另有办法拿到信息(就像上面 cancel 的返回值)。书中的反例是一个学生团队的网络模块:它吞掉了所有网络异常,用它的应用无从得知消息丢了或者对端挂了,也就不可能做得可靠。交互里的"吞掉写盘失败"是同一类错误:处理位置少了一个,客户端却会对一笔没落盘的写入收到 "OK"。处理位置的数量不是唯一的目标,它服从于"调用者需要知道什么"。作者在这里说的是:不重要的要藏起来,越多越好;重要的必须暴露。第 21 章会专门讨论怎样判断什么重要。
作者在 10.5 节回应过这个质疑:抛错的写法也许能抓到一些 bug,但它让调用者不得不写更多代码去避开或忽略错误,或者忘了写、运行时冒出意外的异常,这些本身又会造成 bug;整体上,减少 bug 最好的办法是让软件更简单。本站补充:这条论证在"越界是正常需求"时最有力,比如取一段可能偏短的预览。如果越界在你的场景里几乎一定意味着数据损坏(解析带长度前缀的记录,声明的长度超出了缓冲区),那它就是重要信息,应该报错甚至崩溃,而不是悄悄截断。
书里的 ckalloc 是应用为自己做的决定。本站补充:一个通用库在分配失败时直接 abort(),等于替所有使用它的应用做了这个决定,这通常不合适;分配大小由不可信输入决定时(客户端声称要上传 4GB),分配失败也应该变成"拒绝这个请求",而不是让整个服务退出。同样,NFS 无限期挂住的做法作者自己也承认有争议,用户确实经常抱怨;现在常见的 NFS 客户端提供 soft 挂载选项,超时后返回错误,代价正是作者担心的那种连锁中止。哪种更好,取决于应用能不能对错误做出有意义的反应。
很多人从小被教导"尽可能多地检测并报告错误"。作者反对的是由此产生的过度防御:稍有可疑就抛异常,把难题推给调用者,并美其名曰"让调用者自己决定"(10.2 节)。这和"快速失败"并不矛盾,直接崩溃恰恰就是快速失败。本站补充:Go、Rust 这类显式错误值的语言让每个出错点在调用处都看得见,常被认为与异常机制对立。但本章说的"异常"包括特殊返回值,它的论点与机制无关:不论用哪种机制,需要做出处理决定的地方越少越好。Rust 的 ? 运算符让错误可以低成本地往上传到一个聚合点,正好方便了聚合。
以下不在书里,来自 Ousterhout 在 X(原 Twitter)上的回帖。本章最容易被概括成"作者反对异常"。2019 年 Ousterhout 在一串讨论里专门澄清过(2019-02):自己并不反对异常;想说的是异常有时被用得太多,而且常常以制造不必要复杂性的方式被使用。读本章时请把靶子对准"多余的异常"和"把难题推给调用者的异常",而不是异常机制本身。下面讨论组的几条回帖是同一个立场的展开。
以下不在书里,来自书中给出的读者讨论组。有读者问 HTTP 库的超时该怎么设计:不设默认超时,调用可能永远挂住;设了,下载大文件又会被误杀,于是人人都得去改默认值。Ousterhout 的回答是先给一个默认值并允许覆盖,然后改一下"超时"的定义(2023-06):不是"N 秒内没完成",而是"连续 N 秒没有可见的进展"。只要字节还在稳定地到达,文件再大也不会超时,需要改默认值的人自然就少了。
这是 10.3 节那种手法的一个干净例子:没有去"处理"大文件这个特殊情况,而是换了一个定义,让它不再特殊。本站补充:这个定义管不住"每隔几秒只来一个字节"的对端,对不可信的对端还需要一个总时限或最低速率。
有读者问:C++ 里该抛异常,还是返回 std::optional 这样的特殊值?Ousterhout 的看法(2023-07,不在书里):特殊返回值看起来绕开了异常的复杂性,但每个调用者都得检查结果;如果处理这种情况的最好办法是一路退出很深的调用栈,异常带来的总复杂性反而更低。异常写不写进签名,在 Ousterhout 看来差别不大:系统大到一定程度,几乎每个方法都可能抛,开发者很难在脑子里维持一份"调用它会抛什么"的清单。最后说这只是个人意见,没有完美的办法。
另一串里争的是迭代接口:用一个"取下一个,取完返回空"的方法,还是配一对"还有吗 / 取下一个"?Ousterhout 认为两者的接口复杂度差不多(后者照样得规定"取完之后再取"会怎样),并给前者加了一条理由:它是线程安全的,后一种在多线程下,两次调用之间可能被别的线程把最后一个元素取走(2021-06)。
"把错误定义掉"容易被读成"任何输入都要有定义的行为"。讨论组里有两处把边界说清楚了(均不在书里):
- 前置条件是契约,违约的后果不必写。书中有一条接口注释写着"目标缓冲区必须容得下这么多字节"。有读者追问:容不下会怎样?注释应该写明。Ousterhout 明确表示不让步(2024-05):接口说了"必须",就是"不满足就别调用";要实现者为每一种违约情形都给出精确定义,是不合理的负担。也不建议去读代码看违约后会怎样,因为你不能依赖看到的东西,实现者随时可以改。什么时候值得把非法输入变成有定义的行为(抛异常或返回特殊值)?当调用者没法事先保证条件成立时,比如输入来自用户这样的不可信来源。
- 应用真要区别对待的情况,必须如实报告。有读者担心自己抛的异常种类太多。Ousterhout 回答(2018-07):原则上越少越好,但如果上层应用确实要对某种情况做专门处理,就只能为它单独抛一种。设计里最重要的事之一是弄清什么真正重要;重要的东西藏起来只会出问题,能做的是让重要的东西变少,比如把几种情况用同一种方式处理,它们之间的区别就不再重要。这和 10.9 节、第 21 章是同一个意思。
两个来自读者的反例,都对着本章的核心手法。
一位读者照着书改了一个"提交候选时间"的接口:原来重复提交、时间冲突都报错,改成"后来的覆盖先前的"之后,错误处理代码少了很多,但心里不踏实:调用方发来重复数据,可能本身就是调用方的 bug,这下被悄悄吞掉了。Ousterhout 同意这个担心,认为那样太容易出现意料之外的行为,建议加一个类似命令行 -f 的强制参数,让调用方自己选严格还是覆盖(2021-08)。同一串里另一位读者的建议也值得记:先问问这条约束有没有道理;有时把接口围绕的概念换一个,冲突就不存在了。提问者最后采纳的正是这一条。
另一位读者针对 10.4 节 Unix 删除文件的例子(2018-08,Ousterhout 没有回复):一个进程不停写日志把磁盘写满,你删掉日志文件想腾空间,但只要那个进程还开着它,空间就不会释放;而文件已经从目录里消失,反而更难查出是谁在占磁盘。读过 OSTEP 的读者知道原因:删除只是去掉一个目录项。这不推翻书里的结论(两种设计各有代价),但说明"把错误定义掉"并没有让问题消失,只是换了一个地方、换了一批人来面对。定义掉之前要问:原来那个报错,有没有人其实靠它发现问题?
练一练
下面三道题来自训练场:一道识红旗、一道 A/B 对比、一道"给每种异常选技术"的书写题。本章共 23 题。
再加一道,取材于读者讨论组(对应上面"边界与反方"里的"讨论组"一栏):
动手:项目任务
读完本章去做 minikv 的任务 m06。minikv 的存储层把很多正常情况当作错误抛出(比如删除一个不存在的键、读取一个越界的区间),服务端的命令处理函数于是各自带着一长串 catch,同样的错误响应写了很多遍。启动时也有问题:进程崩溃留下的末尾半条记录和日志中间真正的损坏走同一条报错路径,前者让程序拒绝启动,使用者只能手工截断文件。你要做的是:先找出哪些错误可以通过改定义消失,再把剩下的请求级错误聚合到命令分发处;把末尾半条记录在最底层屏蔽掉;为真正不可恢复的中间损坏选择直接崩溃,并给出清楚的说明。注意这个任务会有意改变 DEL 和 GETRANGE 的外部行为,相应的测试要跟着改(任务页里说明了哪些),其余测试保持全绿。
任务 m06:把错误定义掉、在分发处聚合、在最底层屏蔽末尾半条记录、对不可恢复的情况直接崩溃,让 catch 的数量大幅减少。详见 任务页。
cd project/minikv && make test自测题
小结与读后一句话
- 异常(包括特殊返回值)对复杂性的贡献不成比例:处理代码难写、会制造次生异常、很少执行所以很难保证正确。
- 抛出容易、处理难,所以目标是减少必须处理异常的地方。
- 定义掉(改语义)优先;定义不掉的,按位置选屏蔽(底层就地处理)或聚合(高处一个处理器,包括错误提升);罕见且无法恢复的直接崩溃(看应用)。
- 屏蔽和聚合都是把处理器挪到能接住最多异常的地方;只有定义掉能让处理器真正消失。
- 别做过头:调用者需要的信息必须暴露,哪怕接口因此变复杂。
本章里最有力的几招,往往都不是第一个想到的设计:unset 的作者一开始就设计错了。第 11 章讲的正是怎样逼自己在动手之前想出第二个、第三个方案。
读后一句话:用你自己的话写下这一章最重要的一个观点,最好结合你写过的某段错误处理代码。