小马的世界

读书笔记-软件设计的哲学【10】让错误不再成为“特殊情况”

2026-06-07 · 30 min read

异常处理(Exception Handling)是软件系统复杂性的最主要来源之一。处理特殊情况的代码,本质上比处理正常情况的代码更难编写;而开发人员往往在定义异常时,并没有充分考虑这些异常将如何被处理。

本章将讨论为什么异常会对系统复杂度产生不成比例的影响,并进一步介绍如何简化异常处理。贯穿本章的核心思想是: 减少必须处理异常的地方。 在某些情况下,我们甚至可以改变操作本身的语义,使正常流程能够覆盖所有情况,从而不再需要报告任何特殊情况(这也正是本章标题的含义)。

为什么异常会增加复杂性

这里所说的“异常(exception)”,指的是任何会改变程序正常控制流的非正常情况。

许多编程语言都提供了正式的异常机制,允许底层代码抛出异常,并由外围代码捕获处理。然而,即使不使用正式的异常机制,异常依然会出现。例如,一个方法可能通过返回特殊值来表示它未能完成预期行为。所有这些形式的异常都会增加系统复杂度。

一段代码可能以多种不同方式遇到异常:

  • 调用方可能提供了错误的参数或配置数据。
  • 被调用的方法可能无法完成请求的操作。例如,I/O 操作可能失败,或者所需资源不可用。
  • 在分布式系统中,网络数据包可能丢失或延迟,服务器可能无法及时响应,或者通信对端可能以意料之外的方式进行交互。
  • 代码可能检测到程序缺陷(Bug)、内部状态不一致,或者遇到自身尚未准备处理的情况。

大型系统必须面对大量异常情况,尤其是分布式系统或需要容错能力的系统更是如此。异常处理代码在整个系统代码中所占的比例,往往相当可观。

异常处理代码天生就比正常流程代码更难编写。异常意味着程序正常执行流程被打断,通常意味着某些事情没有按预期发生,导致操作无法按照原计划完成。

当异常发生时,程序员通常有两种处理方式,而这两种方式都可能十分复杂。

第一种方式是继续向前推进,尽管发生了异常,仍然设法完成当前工作。例如,如果网络数据包丢失,可以尝试重新发送;如果数据损坏,也许可以从冗余副本中恢复。

第二种方式是终止当前正在执行的操作,并将异常向上传递。然而,中止操作本身也可能很复杂,因为异常发生时系统状态可能已经不一致(例如某个数据结构只完成了部分初始化)。异常处理代码必须负责恢复系统一致性,例如撤销异常发生之前已经完成的修改。

此外,异常处理代码还会创造出新的异常机会。

以重新发送丢失的网络数据包为例。也许这个数据包实际上并没有丢失,只是延迟到达。在这种情况下,重新发送将导致接收方收到重复数据包,从而产生新的异常情况,需要额外处理。

再考虑从冗余副本恢复数据的情况:如果冗余副本本身也丢失了怎么办?

恢复过程中产生的二次异常,往往比最初的异常更加隐蔽,也更加复杂。

如果处理方式是终止当前操作,那么新的异常又必须继续向上传递给调用者。为了避免异常不断连锁扩散,开发人员最终必须找到一种方式,在不引入更多异常的前提下处理这些异常。

语言层面对异常的支持通常冗长而笨重,使异常处理代码难以阅读。下面以 Java 的对象序列化与反序列化为例,这段代码从文件中读取一组 Tweet 对象:

try {
    FileInputStream fileStream =
        new FileInputStream(fileName);
    BufferedInputStream bufferedStream =
        new BufferedInputStream(fileStream);
    ObjectInputStream objectStream =
        new ObjectInputStream(bufferedStream);

    for (int i = 0; i < tweetsPerFile; i++) {
        tweets.add((Tweet) objectStream.readObject());
    }

} catch (FileNotFoundException e) {
    ...
} catch (ClassNotFoundException e) {
    ...
} catch (EOFException e) {
    // 不算问题:并非所有 Tweet 文件
    // 都包含完整数量的 Tweet
} catch (IOException e) {
    ...
} catch (ClassCastException e) {
    ...
}

仅仅是最基础的 try-catch 样板代码,其行数就已经超过了正常业务逻辑代码本身,更不用说真正处理这些异常的代码了。

此外,异常处理代码与正常流程代码之间的关联并不直观。例如,很难看出每种异常究竟是在什么位置产生的。

一种替代方案是把代码拆分成多个独立的 try 块;极端情况下,甚至每一行可能抛出异常的代码都可以单独放在一个 try 中。这样虽然能够更清晰地显示异常来源,但大量的 try 块会割裂程序流程,使代码更难阅读;同时,一些异常处理逻辑还可能不得不在多个 try 块中重复出现。

要确保异常处理代码真正有效也并不容易。

某些异常(例如 I/O 错误)很难在测试环境中稳定复现,因此对应的处理逻辑也很难被充分测试。异常在实际运行系统中出现的频率通常并不高,因此异常处理代码往往很少被执行。

结果就是,相关缺陷可能长期潜伏而不被发现;等到真正需要这些异常处理逻辑的时候,它们很可能无法正常工作。

正如我常说的一句话:

从未被执行过的代码,通常是不能工作的。
最近的一项研究发现,在分布式数据密集型系统中,超过 90% 的灾难性故障都是由错误的异常处理造成的。当异常处理代码发生故障时,由于其执行频率极低,因此问题往往很难调试和定位。

过多的异常(Too many exceptions)

程序员往往会因为定义了不必要的异常,而进一步加剧异常处理带来的问题。

大多数程序员从学习编程开始就被教导:发现并报告错误非常重要。因此,他们常常将其理解为“发现的错误越多越好”。这种观念容易导致一种过度防御(over-defensive)的编程风格:任何看起来稍微可疑的情况都会被视为异常并立即拒绝处理。结果便是系统中充斥着大量没有必要的异常,从而增加了整体复杂度。

我自己在设计 Tcl 脚本语言时就犯过这样的错误。

Tcl 中有一个 unset 命令,用于删除变量。我将 unset 设计成:如果变量不存在,则抛出错误。

当时我认为,如果有人试图删除一个不存在的变量,那一定是程序中的 Bug,因此 Tcl 应该报告这个错误。

然而,unset 最常见的用途之一,其实是清理之前某个操作产生的临时状态。特别是在某个操作执行到一半就中止的情况下,往往很难准确知道到底创建了哪些变量。

因此,最简单的做法往往是把所有可能创建过的变量都删除一遍。

unset 的这种定义却让事情变得很尴尬:开发者不得不把 unset 调用包裹在 catch 语句中,以捕获并忽略 unset 抛出的错误。

回头来看,unset 的这种设计是我在 Tcl 设计过程中犯下的最大错误之一。

当遇到棘手情况时,人们很容易倾向于通过抛出异常来回避问题:与其思考一种干净优雅的处理方式,不如直接抛出异常,把问题甩给调用者。

有些人可能会认为这种做法赋予了调用者更大的灵活性,因为每个调用者都可以根据自己的场景采用不同的处理策略。

然而,如果连你自己都不知道该如何处理某个特殊情况,那么调用者大概率也不知道。在这种情况下生成异常,只不过是把问题转嫁给别人,同时增加整个系统的复杂度。

类抛出的异常本身也是其接口(interface)的一部分。 抛出大量异常的类,其接口往往更加复杂;而抛出异常较少的类,其接口通常更简洁。

异常是接口中尤其复杂的一种组成部分。它不仅会影响当前方法的调用者,还可能在被捕获之前沿着调用栈向上传播多个层级,因此影响到更高层的调用者(以及它们的接口)。

抛出异常很容易;处理异常却很困难。因此,异常带来的复杂度主要来自异常处理代码,而不是异常产生代码。

减少异常处理所造成损害的最佳方法,就是 减少必须处理异常的地方

本章剩余部分将介绍四种减少异常处理点数量的技术。

让错误不再成为“错误”(Define errors out of existence)

消除异常处理复杂度的最佳方法,是设计 API 时让根本不存在需要处理的异常—— 让错误从定义层面消失(define errors out of existence)

这种做法听起来似乎有些离经叛道,但在实践中却非常有效。继续以上面提到的 Tcl unset 命令为例:当 unset 被要求删除一个不存在的变量时,它不应该抛出错误,而应该什么都不做并直接返回。

我本应稍微调整一下 unset 的定义:

与其把它定义为“删除一个变量”,不如把它定义为“确保一个变量不再存在”。在第一种定义下,如果变量不存在,unset 无法完成自己的工作,因此抛出异常似乎合理。但在第二种定义下,即使传入一个不存在变量的名称,也完全符合预期。因为此时目标状态已经达成——该变量本来就不存在。

因此,unset 只需直接返回即可,不再存在任何需要报告的错误情况。

示例:Windows 中的文件删除

文件删除提供了另一个“让错误消失”的例子。Windows 操作系统不允许删除正在被某个进程打开的文件。这一直是开发者和普通用户持续不断的痛苦来源。为了删除一个正在使用中的文件,用户必须在系统中查找到底是哪个进程打开了该文件,然后结束那个进程。有时候,用户甚至会直接放弃,重启整个系统,只为了删除一个文件。

Unix 操作系统对文件删除的定义则优雅得多。在 Unix 中,如果一个文件在删除时仍然处于打开状态,系统不会立即删除它。相反,系统会先将该文件标记为“待删除”,然后删除操作立即返回成功。文件名会从目录中移除,因此其他进程无法再通过该名称打开这个文件;同时,也可以创建一个同名的新文件。

但原有文件的数据仍然保留。已经打开该文件的进程仍然能够像往常一样读取和写入它。当所有持有该文件的进程都关闭该文件后,其数据才会被真正释放。Unix 的这种做法实际上让两类错误从定义上消失了。

第一,如果文件正在被使用,删除操作不再返回错误;删除请求会成功,并且文件最终一定会被删除。
第二,删除一个正在使用中的文件不会给那些正在使用该文件的进程制造新的异常情况。

针对这个问题,还有一种可能的解决方案:立即删除文件,并将所有已经打开的文件句柄标记为失效。

这样一来,其他进程后续对该文件的任何读写操作都会失败。

然而,这种方案会给这些进程带来新的错误和异常处理负担。Unix 选择了另一条道路:允许这些进程继续正常访问该文件;通过延迟真正删除文件,使错误从根本上消失。

Unix 允许进程继续读写一个已经“注定要被删除”的文件,这看起来或许有些奇怪。但我从未遇到过因此造成重大问题的情况。

无论对于开发者还是普通用户来说,Unix 对文件删除的定义都比 Windows 的定义简单得多,也更容易使用。

示例:Java 的 substring 方法

作为最后一个例子,来看 Java 中的 String 类以及它的 substring 方法。

给定字符串中的两个索引位置,substring 返回从第一个索引开始,到第二个索引之前结束的子字符串。然而,如果任意一个索引超出了字符串范围,substring 就会抛出 IndexOutOfBoundsException 异常。

这个异常其实没有必要,而且让该方法变得更加复杂。我经常遇到这样的场景:一个或两个索引可能超出了字符串范围,而我希望提取与指定区间重叠的所有字符。遗憾的是,这迫使我必须先检查每个索引,然后将它们修正到合法范围内——负数修正为 0,超出末尾的值修正为字符串长度。

原本只需要一行代码的方法调用,最终却膨胀成 5~10 行代码。

如果 substring 能够自动完成这种调整,那么它会更容易使用。

例如,它可以定义如下 API:

返回所有满足索引大于等于 beginIndex 且小于 endIndex 的字符(如果存在的话)。

这是一个简单而自然的 API 设计。

它让 IndexOutOfBoundsException 从定义层面消失了。

即使一个或两个索引为负数,或者 beginIndex 大于 endIndex,方法行为依然是明确且定义良好的。这种做法在增强方法功能的同时,也简化了 API,从而使其变得更加“深”(deep)。

许多其他编程语言已经采用了这种思路。

许多语言都采用了这种“无错误(error-free)”的设计思路。例如,在 Python 中,对超出范围的列表切片操作会返回空结果,而不会抛出异常。当我主张“让错误从定义中消失”时,有时会有人反驳说:抛出异常能够帮助发现 Bug;如果把错误都从定义中消除了,软件岂不是会变得更加容易出错?

也许正因为这种考虑,Java 的设计者才决定让 substring 抛出异常。这种“保留错误”的做法确实有可能发现某些 Bug,但它同时也增加了复杂性,而复杂性又会催生新的 Bug。在这种设计下,开发者必须编写额外代码来避免或忽略这些错误,而这些额外代码本身就增加了出错的可能性;或者开发者可能忘记编写这些额外代码,于是在运行时抛出意料之外的异常。

相比之下,让错误从定义中消失能够简化 API,并减少必须编写的代码量。

归根结底,减少 Bug 的最佳方法,是让软件更简单。

屏蔽异常(Mask exceptions)

减少异常处理点数量的第二种技术是 异常屏蔽(exception masking)

这种方法的核心思想是:在系统较低层检测并处理异常情况,从而使更高层的软件无需感知这些异常。

异常屏蔽在分布式系统中尤其常见。

例如,在 TCP 这样的网络传输协议中,数据包可能由于损坏、网络拥塞等各种原因而丢失。

TCP 会在协议内部自动重传丢失的数据包,从而屏蔽数据包丢失这一异常情况。最终所有数据都能够成功到达,而客户端完全不知道中间发生过丢包。

一个更具争议性的异常屏蔽例子出现在 NFS 网络文件系统中。

如果 NFS 文件服务器崩溃,或者由于任何原因无法响应,请求方会不断重新发送请求,直到问题最终得到解决。

客户端上的底层文件系统代码不会向调用应用程序报告任何异常。

正在执行的文件操作(以及对应的应用程序)只是暂停在那里,直到操作最终能够成功完成。

如果等待时间超过一个较短阈值,NFS 客户端会在用户终端上输出类似下面的信息:

“NFS server xyzzy not responding, still trying.”
(NFS 服务器 xyzzy 无响应,仍在继续尝试。)

NFS 用户经常抱怨应用程序在等待服务器恢复期间会被挂起。

许多人建议 NFS 应该直接终止当前操作并抛出异常,而不是一直等待。

然而,报告异常实际上只会让情况变得更糟,而不是更好。

当应用程序失去对文件的访问能力时,它其实没有太多事情可以做。一种可能的做法是让应用程序自行重试文件操作,但这同样会导致应用程序被挂起。而且,与其要求每个文件系统调用点都实现重试逻辑,不如在 NFS 层统一处理重试要简单得多(编译器之类的应用程序根本不应该操心这种事情!)。

另一种选择是让应用程序终止操作,并向自己的调用者返回错误。但调用者大概率也不知道该怎么办,因此它们最终也会终止执行。

这样一来,用户的整个工作环境都会崩溃。在文件服务器宕机期间,用户依然无法完成任何工作;而等到服务器恢复后,他们还必须重新启动所有应用程序。

因此,对于 NFS 来说,最好的选择就是屏蔽这些错误,并让应用程序暂时挂起。

采用这种方式后,应用程序完全不需要编写任何处理服务器故障的代码;一旦服务器恢复运行,它们就能无缝继续工作。

如果用户实在不愿意继续等待,也始终可以手动终止应用程序。异常屏蔽并不适用于所有场景,但在适用的情况下,它是一种非常强大的工具。

它能够让类变得更“深”(deeper):因为它减少了接口中的复杂度(用户需要了解的异常更少),同时又增加了功能——即提供了屏蔽异常的能力。

异常屏蔽是 将复杂度向下层转移(pulling complexity downward) 的典型例子。

异常聚合(Exception aggregation)

减少异常相关复杂度的第三种技术是异常聚合(exception aggregation)。其核心思想是:用一处代码统一处理大量异常,而不是为许多独立异常分别编写不同的处理逻辑。换句话说,不要为每个异常单独设置处理器,而是把它们集中到一个地方统一处理。

考虑 Web 服务器中缺失参数的处理问题。一个 Web 服务器会维护一组 URL。当服务器收到请求 URL 时,会将请求分发到对应的服务方法(service method),由该方法处理 URL 并生成响应。

URL 中通常包含多个参数,这些参数用于生成最终响应。

每个服务方法都会调用某个底层方法(我们称之为 getParameter)来从 URL 中提取自己所需的参数。如果 URL 中缺少所需参数,getParameter 就会抛出异常。

在软件设计课程中,当学生实现这样的服务器时,很多人会为每一次调用 getParameter 单独包裹一个异常处理器,以捕获 NoSuchParameter 异常。

这样做的结果就是产生大量功能几乎完全相同的异常处理代码(本质上都只是生成错误响应)。

顶部代码负责将请求分发给 Web 服务器中的多个处理方法之一,每个方法负责处理一种特定 URL。

这些方法(下方)通过 HTTP 请求获取参数。

每一次调用 getParameter 都拥有独立的异常处理器,因此造成了大量重复代码。

更好的方法是对异常进行聚合。

不要在各个服务方法内部捕获异常,而是让这些异常一路向上传播到 Web 服务器最顶层的分发方法。

然后在这一层使用一个统一的异常处理器,捕获所有此类异常,并为缺失参数生成合适的错误响应。

在 Web 服务器这个例子中,聚合策略还可以进一步推广。除了参数缺失之外,在处理网页请求过程中还可能出现许多其他错误。

例如:

  • 参数格式不正确(服务方法期望得到整数,但收到的是 "xyz");
  • 用户没有执行该操作所需的权限;

在这些情况下,最终结果都应该是返回一个错误响应。后续内容将继续说明如何进一步统一处理这些异常。

该代码在功能上与原来的等价,但异常处理被聚合到了一个地方:分发器(dispatcher)中的单个异常处理器捕获来自所有 URL 专用方法的 NoSuchParameter 异常。

这些错误之间的差别仅仅体现在返回响应中的错误消息内容不同(例如“URL 中缺少参数 quantity”或者“参数 quantity 的值 xyz 非法,必须是正整数”)。

因此,所有最终需要返回错误响应的情况,都可以由同一个顶层异常处理器统一处理。错误消息可以在异常抛出时生成,并作为异常对象中的一个字段保存下来。

例如,getParameter 可以生成如下错误消息:

“parameter 'quantity' not present in URL”

顶层处理器只需从异常对象中提取该消息,并将其放入错误响应即可。

前面描述的这种聚合方式,从封装(encapsulation)和信息隐藏(information hiding)的角度来看具有良好的特性。

顶层异常处理器封装了如何生成错误响应的知识,但它并不了解具体错误的细节;它只是使用异常中携带的错误消息。

getParameter 方法则封装了如何从 URL 中提取参数的知识,同时也知道如何用人类可读的方式描述参数提取失败的问题。

这两类知识本来就高度相关,因此放在同一个地方是合理的。然而,getParameter 对 HTTP 错误响应的语法一无所知。

随着 Web 服务器不断增加新功能,可能会出现更多类似 getParameter 的方法,并产生各自的错误。

如果这些新方法采用与 getParameter 相同的方式抛出异常(例如继承自同一个异常基类,并在异常中包含错误消息),那么它们无需对现有系统进行任何额外修改即可接入:

顶层异常处理器会自动为这些错误生成对应的错误响应。

这个例子展示了一种普遍适用的异常处理设计模式。

如果一个系统持续处理一系列请求,那么定义一种异常往往非常有用:当该异常被抛出时,它会终止当前请求、清理系统状态,然后继续处理下一个请求。

这种异常只需要在接近系统请求处理循环顶部的位置统一捕获一次。

在处理某个请求的过程中,任何位置都可以抛出这种异常,以终止当前请求;同时还可以定义多个不同的子类,用于表示不同类型的问题。

需要注意的是,这类异常必须与那些会导致整个系统终止的致命异常明确区分开来。

异常聚合最适合的场景是:异常在被处理之前,会沿调用栈向上传播多个层级。

这样一来,更多方法产生的异常就可以在同一个地方统一处理。

这与异常屏蔽(exception masking)恰好相反:

异常屏蔽通常适用于在较低层的方法中处理异常。

在异常屏蔽场景中,低层方法通常是一个会被许多其他方法调用的库函数;如果让异常继续向上传播,反而会增加需要处理该异常的地方。

异常屏蔽和异常聚合有一个共同点:

它们都会把异常处理器放置在能够捕获最多异常的位置,从而消除大量原本需要单独编写的处理器。

另一个异常聚合的例子来自用于崩溃恢复的 RAMCloud 存储系统。

RAMCloud 由一组存储服务器组成,每个对象都会保存多个副本,因此系统能够从各种故障中恢复。

例如,如果某台服务器崩溃并丢失全部数据,RAMCloud 可以利用其他服务器上的副本重新构建丢失的数据。

错误也可能发生在更小的范围内。

例如,一台服务器可能发现某个单独对象已经损坏。RAMCloud 并不会为每一种错误分别设计独立的恢复机制。相反,它会将许多较小的错误“提升(promote)”为较大的错误。理论上,RAMCloud 完全可以通过备份副本恢复单个损坏对象。但它并没有这么做。

相反,一旦发现对象损坏,它会直接让保存该对象的整台服务器崩溃。

RAMCloud 之所以采用这种做法,是因为服务器崩溃恢复机制本身已经相当复杂,而这种设计能够最大限度减少需要实现的恢复机制种类。

服务器崩溃恢复机制本来就是不可避免需要实现的,因此 RAMCloud 也将其用于其他类型错误的恢复。这样做减少了需要编写的代码量,同时也使服务器崩溃恢复逻辑被更频繁地执行。结果就是,恢复代码中的缺陷更容易被发现和修复。

把单个损坏对象提升为整台服务器崩溃的一个缺点是:恢复成本会显著增加。

但在 RAMCloud 中这并不是问题,因为对象损坏本身非常罕见。然而,对于频繁发生的错误,错误提升(error promotion)未必合理。

例如,如果每丢失一个网络数据包就让服务器崩溃一次,那显然是不现实的。理解异常聚合的一种方式是:

它用一个通用机制替代多个针对特定情况设计的专用机制。这个通用机制能够处理多种不同场景。这再次体现了通用机制(general-purpose mechanism)的优势。

直接崩溃(Just crash?)

减少异常处理复杂度的第四种技术,就是在适当情况下 直接让应用程序崩溃

在大多数应用程序中,总会存在一些错误:

这些错误既不值得处理,也难以处理,甚至根本无法处理,而且发生频率极低。对于这种错误,最简单的应对方式往往是:

输出诊断信息(diagnostic information),然后直接终止应用程序。

一个典型例子是内存分配过程中出现的“内存耗尽(out of memory)”错误。

以 C 语言中的 malloc 函数为例:当无法分配所请求大小的内存块时,它会返回 NULL。这种设计并不理想,因为它假定每一次调用 malloc 的地方都会检查返回值,并在内存不足时采取适当措施。而实际应用中往往包含大量 malloc 调用,因此在每次调用后都进行检查会显著增加复杂度。如果程序员忘记编写检查代码(这种情况相当常见),那么当内存耗尽时,程序最终会解引用空指针并崩溃。

这种崩溃反而掩盖了真正的问题所在。此外,当应用程序发现内存耗尽时,它实际上也没有太多事情可做。

理论上,应用程序可以尝试寻找不再需要的内存并释放它。但如果这些内存真的不再需要,那么应用程序原本就应该提前释放它们,从而根本不会发生内存耗尽。

现代系统通常拥有非常充足的内存,因此内存几乎不会真正耗尽;而一旦发生,往往意味着程序本身存在某种 Bug。因此,专门为内存耗尽设计复杂的恢复逻辑通常得不偿失:

引入了大量复杂性,却只能获得极其有限的收益。一个更好的做法是定义一个新的方法 ckalloc

ckalloc 内部调用 malloc,检查返回结果;如果发现内存耗尽,则输出错误信息并直接终止应用程序。应用程序永远不会直接调用 malloc,而是一律调用 ckalloc。在 C++ 和 Java 等较新的语言中,如果内存耗尽,new 操作符会抛出异常。

然而,捕获这种异常并没有太大意义,因为异常处理器本身很可能还需要继续分配内存,而这同样会失败。动态分配内存是现代应用程序最基础的组成部分之一。如果内存已经耗尽,那么继续运行通常没有意义;与其勉强维持运行,不如在发现问题时立即崩溃。还有许多其他类型的错误,也适合直接终止应用程序。

对于大多数程序而言,如果在读取或写入已打开文件时发生 I/O 错误(例如硬盘损坏),或者无法打开网络套接字,那么应用程序几乎没有什么有效的恢复手段。在这种情况下,输出明确的错误信息并终止程序,是一种合理的选择。这些错误本身发生频率极低,因此通常不会显著影响应用程序整体可用性。

如果应用程序发现内部错误,例如数据结构处于不一致状态,那么同样适合直接终止并输出错误信息。这种情况通常意味着程序中存在 Bug。

是否适合在某种错误发生时直接崩溃,取决于具体应用场景。例如,对于一个带有数据副本机制的存储系统来说,在发生 I/O 错误时直接终止并不合理。系统必须利用副本数据恢复丢失的信息。这些恢复机制确实会为程序增加大量复杂度,但恢复数据本身正是该系统向用户提供价值的核心组成部分。

做得过头(Taking it too far)

只有当异常信息在模块之外并不重要时,“让错误从定义中消失”或在模块内部屏蔽异常才是合理的。本章中的例子,例如 Tcl 的 unset 命令和 Java 的 substring 方法,都属于这种情况。即使在极少数情况下调用者确实关心这些异常所代表的特殊情况,也通常可以通过其他方式获取相关信息。

然而,这种思想也有可能被推向极端。

例如,在一个网络通信模块中,有一个学生团队屏蔽了所有网络异常:当发生网络错误时,模块会捕获错误、丢弃错误,然后假装什么都没发生一样继续运行。这样做的结果是:使用该模块的应用程序根本无法知道消息是否已经丢失,也无法知道对端服务器是否已经失效。缺少这些信息之后,就根本无法构建健壮的应用程序。在这种场景下,即使异常会增加模块接口的复杂度,模块仍然必须将这些异常暴露出来。与软件设计中的许多问题一样,对于异常处理也必须区分什么是重要的、什么是不重要的。不重要的事情应当被隐藏,而且隐藏得越彻底越好。但当某件事情很重要时,就必须将其暴露出来。

总结

任何形式的特殊情况都会让代码更难理解,并增加出现 Bug 的概率。

本章重点讨论了异常,因为异常是特殊情况代码最重要的来源之一。同时,本章也讨论了如何减少必须处理异常的地方。最有效的方法是重新定义操作语义,从根本上消除错误情况。

对于那些无法通过重新定义而消除的异常,则应尽量寻找机会:

  • 在较低层对其进行屏蔽,从而限制其影响范围;
  • 或者将多个特殊情况处理器聚合成一个更通用的处理器。

这些技术结合起来,可以显著降低整个系统的复杂度。