7. 错误处理

错误处理作为一种备受争议的话题,我认为将长期存在下去。许多人在这个问题上有强烈的固有认知,其中一些是基于各种应用领域中扎实的经验——过去 50 多年已经有了很多相关的技术积累。在错误处理领域,性能、通用性、可靠性的需求往往发生冲突。

与 C++ 一样,问题不是我们没有解决方案,而是有太多解决方案。从根本上讲,很难通过单一的机制来满足 C++ 社区的多样化需求,但是人们往往只看到问题的一部分,就以为他们掌握了解决问题的终极方案 [Stroustrup 2019a]。

7.1 背景

C++ 从 C 语言中继承了各种基于错误返回码的机制,错误可以用特殊值、全局状态、局部状态和回调等多种方式表达。例如:

double sqrt(double d);  // 当 d 为负数时,设置 errno 为 33
int getchar();          // 遇到文件结尾返回 -1
char* malloc(int);      // 如果分配出错,返回 0

C++ 的早期用户(1980 年代)发现这些技术令人困惑,也不足以解决所有问题。返回 (值,错误码) 对变得流行,但这更增加了混乱和变化。例如:

Result r = make_window(arguments); // Result 是 (值,错误码) 对
if (r.error) {
    // ... 错误处理 ...
}
Shape* p = r.value;

繁琐的重复错误检查使代码变得混乱。使用错误码时,很难将程序的主要逻辑与错误处理区分开。程序的主线(业务逻辑)与大量奇怪和模糊的错误处理代码紧密耦合在一起。对于那些错误处理本身就是主要的复杂逻辑而言,这种基于错误返回码的处理方式可能会带来严重的问题。

使用包含 (值,错误码) 对的类会带来巨大的成本。除了检测错误码的成本外,许多 ABI(应用程序二进制接口)甚至不使用寄存器来传递小的结构体,所以 (值,错误码) 对不仅传递了更多的信息(是通常数量的两倍),而且也使传递的性能有数量级的降低。可悲的是,在许多 ABI 中,尤其那些针对嵌入式系统的 ABI(专为 C 代码设计),这个问题直到今天(2020 年)依然存在。

此外,在出错的构造函数中没有真正好的方法来使用错误码来处理故障(构造函数没有返回值),还有那些过去流行的具有复杂类层次结构的系统,子对象创建中各种潜在错误也很难通过错误码的方式处理。

还有,对于所有传统的错误处理技术,最令人头疼的是人们会忘记检查错误。这一直是错误的主要根源,并且在 2020 年的今天依旧如此。C++ 异常机制的主要目标是使不完整或复杂的错误处理中的错误最小化。

C++ 异常是在 1988–89 年设计的,旨在解决当时普遍存在的复杂且容易出错的错误处理技术。它们记录在 ARM(The Annotated C++ Reference Manual)[Ellis and Stroustrup 1990] 中,并作为标准基础文档 [Stroustrup 1993] 的一部分被 ANSI C++ 所采用。

与其他语言的异常设计相比,用于 C++ 的异常设计由于需要结合使用 C++ 代码和其他语言(尤其是 C)的代码而变得复杂。考虑一个 C++ 函数 f() 调用一个 C 函数 g(),该函数又调用一个 C++ 函数 h()。现在 h() 抛出异常由 f() 捕获。通常,C++ 函数不知道被调用函数的实现语言。这样的场景使我们不能通过修改函数签名以添加“异常传播参数”,或隐式地向返回类型添加返回码的方法做错误处理。

与使用其他技术相比,异常与 RAII(§2.2)一起解决了许多棘手的错误处理问题(例如,如何处理构造函数中的错误以及那些远离错误处理代码的错误),而且所需的时间成本要小得多(与 1990 年代中期所用的技术相比通常不到 3%,甚至更便宜)。虽然异常从来没有引起争议,但我还是低估了它们引起争议的可能性。

7.2 现实中的问题

当然总有一些应用不适合使用异常,例如:

  • 内存严重受限系统,异常处理所需的运行期支持内存会占用应用程序功能所需要的内存。
  • 工具链不能保证异常抛出后能够迅速做出响应的硬实时系统(例如 [Lockheed Martin Corporation 2005])。
  • 系统依赖于多台不可靠的计算机,因此立即崩溃并重新启动是对付那些无法在本地处理的错误的合理(且几乎是必要的)方式。

因此,大多数 C++ 实现仍然保留了非异常机制的错误处理方式。另一方面,也存在一些通过错误码无法提供良好解决方案的场景:

  • 构造函数失败——由于构造函数没有返回值(不算被构造对象本身),单纯依赖 RAII 的方式必须替换为通过对对象状态的显式检查来处理错误。
  • 运算符——没有办法从 ++*-> 中返回错误码。你将不得不使用非本地错误指示或使用繁琐的运算符名称,例如 multiply(add(a,b),c) 而不是 (a+b)*c。[^1]
  • 回调——使用回调的函数应该能够调用具有多种可能错误的函数(通常,回调是 lambda 表达式(§4.3.1))。
  • 非 C++ 代码——我们无法通过那些没有专门做错误码处理的非 C++ 函数传递错误。
  • 调用链深处新的错误类型——必须准备调用链上的每个函数来处理或传播一种新的错误(例如,程序中的网络错误,而它并不是专门为通过网络访问数据而预先设计的)。
  • 忘记处理返回码——有一些精巧的方案来试图确保统一检查错误码,但是它们要么不完整,要么依赖于在遗漏检查时使用异常或程序终止(例如 [Botet and Bastien 2018])。

此外,还有一些与使用异常有关的现实问题:

  • 有些人不愿意引入异常机制,是因为他们的代码由于无原则使用指针而形成了一团乱麻。通常,这些人将他们的批评指向异常,而不是他们的陈旧代码。
  • 有些人(很多)根本不理解甚至不知道 RAII(§2.2),而只是把异常当作返回错误码的一种替代机制来用。通常,把 try-catch 当作 if-then 的一种形式来用的话,代码比正确使用错误码或 RAII 要更丑陋、更繁琐、更缓慢。
  • 异常的许多实现速度很慢,是因为实现者把 C++ 的异常与其他类型的异常(例如微软的结构异常)统一处理,优先考虑调试(例如 GCC 在 throw 后两次遍历堆栈来保存回溯),使用单一机制为各种语言服务(每一种都很糟糕),或者只是没有在异常处理优化上花费很多开发精力。
  • 这些年来,异常处理的性能相对较慢,是因为我们在优化非异常方面花费了大量精力。我怀疑还有很大的优化机会。例如,Gor Nishanov 报告说,通过一些与 Windows 和 Linux 上的协程实现相关的简单优化,使速度提高了 1000 倍 [Nishanov 2019a]。不过,大幅改善空间占用可能会更难实现。一些最近的实验看起来还比较不错 [Renwick et al. 2019]。
  • 为了使异常被接受,我们不得不添加了异常规约 [Stroustrup 2007]。但异常规约从来没有提供支持者们所声称的更好的可维护性,而确实提供了反对者(包括我)所诟病的冗长和开销。一旦异常规约出现在语言中,许多人就觉得使用它们是受到鼓励的,并将由此产生的问题归咎于异常机制本身。具有讽刺意味的是,那些坚定支持异常规约的人转而去帮助设计 Java 了。异常规约在 2010 年被宣布废弃,并最终在 2017 年被移除(§4.5.3)。作为部分替代方案,C++11 引入了 noexcept 作为一种更简单、更有效的控制异常的机制(§4.5.3)。
  • 通过指定要捕获的异常类型来捕获异常往往使 throwcatch 的实现与运行期类型识别(RTTI [Stroustrup 2007])纠缠在一起,这导致了效率低下和复杂性。特别是,它会导致内存被消耗(被 RTTI 所需的数据消耗),即使应用程序从不依赖 RTTI 来区分异常,对于简单的场景也很难做优化。而且,依赖 RTTI 使得使用动态链接匹配很难优化。基本上,异常处理实现是针对罕见的最复杂的情况进行优化的。当一个具有嵌套异常的类被添加到标准库中,人们甚至被鼓励在最简单的情况下使用它时,情况就更糟了。对于可以静态分析的类层次结构(在许多嵌入式系统中),以常量时间进行快速类型匹配是可能的 [Gibbs and Stroustrup 2006]。由于异常是平台 ABI 的一部分,这就使得要改变早期的过度设计非常之困难。
  • 有人坚持只使用一种错误处理方法,并且通常得出这样的结论:由于异常不适用于每种情况,因此该方法必须是错误码。那些由错误码所带来的问题也就仅仅是“不方便而已”。
  • 一些人相信那些关于异常机制的基于最坏情况和/或不切实际的比较的低效传闻,例如在添加异常后保留错误码处理方式,将不完整的错误处理与基于异常的处理进行比较,或者使用异常来做简单的错误处理,而不是把异常用于无法在本地处理的错误。很少有关于异常及其替代方案成本的认真调查。我怀疑关于异常的虚假传说比任何事实都具有更大的影响力。

最终结果是 C++ 社区分裂为异常和非异常阵营。事实上,“不要异常”是一种方言,而方言是标准要避免的事情之一(§3.1)。对于个人组织或社区而言,方言可能有一些优势,但它使代码和技能的共享变得复杂,因此损害了整个 C++ 社区。

有人声称,异常机制的问题在于它违反了零开销原则(例如 [Sutter 2018b])。对比通过终止应用来响应错误的处理方案,任何错误处理机制显然都是开销,也都违反了零开销原则(除非考虑到处理终止的成本,例如在另一个处理器中)。在我们设计异常时,我们考虑了这些,并认为开销是可接受的。理由是:异常情况很少见;除非抛出异常,否则没有运行期开销;并且用于实现异常的表可以保存在虚拟内存中 [Koenig and Stroustrup 1989]。在虚拟内存不可用或内存不足的情况下,使用表来实现异常可能成为一个严重问题。我们当时设计异常时主要关注的是,需要某种形式的错误传播和错误处理的系统。在这种情况下,零开销可以解释为“异常与以在同样严格程度的错误处理下的错误码使用相比没有额外开销”。

如今,错误处理的混乱比以往任何时候都严重,处理错误的替代技术比以往任何时候都多,从而造成很大的混乱和危害。假设有 N 种错误处理方式,又有人提出了一个新的解决方案,只要旧的解决方案不被抛弃,现在我们就必须应对 N+1 种方式(“N+1 问题”)。如果一个组织有 M 个程序,使用了 N 个库,我们甚至可能有 N*M 个需要处理的问题。异常的引入可以看作是将处理错误的常用方法从 7 种增加到了 8 种。2015 年,Lawrence Crowl 撰写了一份问题分析报告 [Crowl2015a] 对这个问题进行了分析。

基础库的作者对多种错误处理方案的问题感受最为深刻。他们不知道他们的用户喜欢什么,他们的用户可能有很多不同的偏好。C++17 文件系统库(§8.6)的作者们选择了把接口重复一遍:对于每个操作,他们提供两个函数,一个在错误的情况下抛出异常,另一个函数则通过设置标准库的 error_code 参数将错误码通过参数传递出来:

bool create_directory(const filesystem::path& p); // 出现错误时抛异常
bool create_directory(const filesystem::path& p, error_code& ec) noexcept;

当然,这有点冗长,只会取悦那些仅喜欢异常或 error_code 的人。也要注意作者提供了 bool 返回值,这样人们就不必一直使用 try 或直接测试 error_code 了。事实上,文件系统(在我看来相当正确)使用异常来处理罕见的错误并不能让那些认为异常有根本缺陷的人满意,特别是,它仍要求存在异常支持。

7.3 noexcept 规约

使用 noexcept§4.5.3),人们可以抑制所有从函数抛出的异常,并允许调用者忽略抛出异常的可能性。

使用 noexcept 可以使担心性能问题(或真或假)的人们放心。它也可以通过减少控制路径的数量来改善优化效果,但前提是程序员不要通过测试返回码将这些路径添加回去。许多低级函数,例如大多数 C 函数,都不存在异常。

使用 noexcept 可以简化错误处理(如果一个函数不抛出异常,我们就不需要捕获任何异常),也可以使其复杂化(如果一个函数不能抛出异常,但又可能会失败,我们必须使用其他错误处理机制)。特别是,在异常抛出与其处理程序之间的路径上的 noexcept,会把一个异常变成程序终止运行。因此,对于一个处于维护期的程序,在函数中使用使用 noexcept,可能会导致先前正确的程序失败。

请注意,异常被添加到 C++ 中的一个重要原因是为了支持那些在发生错误时也决不可以无条件中止的应用。异常仅表示发生了故障,并且从 main() 到抛出点的路径上的任何代码都可以对其进行处理。特别是,这样可以支持一个重要场景:在终止之前进行一些本地清理(例如,刷新输出缓冲区,或向日志文件添加错误报告)。

7.4 类型系统的支持

解决 C++ 中的逻辑和性能问题的传统方法是将计算从运行期挪到编译期。显然,将异常与静态类型系统集成的可能性在 1980 年代被认真考虑过,后来又反复被重新考虑。如果异常是函数类型的一部分,那么程序就会有更好的类型检查,函数就更能自我描述,异常处理也更容易优化。

不将异常作为类型系统的一部分的一个主要原因是,如果异常是函数类型的一部分,那么对该函数可能抛出的异常集的更改将要求所有调用该函数的函数重新编译。在一个大多数主要程序都由许多单独开发的库组成的世界里,这将导致灾难性的脆弱,及无法管理的相互依赖 [Stroustrup 1994]。

函数指针方面也有相关的明显问题。在大多数主要的 C++ 程序中都有很多 C 风格的代码,现在仍然如此。C 风格的泛型代码(例如,qsort 的比较函数参数)和回调(例如,在 GUI 中)的主要参数化机制均会用到函数指针。

如果我需要一个指向函数的指针,并且异常是类型系统的一部分,那么,我要么决定始终从所指向的函数中获取异常,要么不接受异常,要么以某种方式处理这两种选择。除非将对类型查询的支持或基于异常的重载添加到语言中,否则都很难两者兼顾。确定了要接受哪种类型函数指针参数后,我现在必须调整调用函数中的错误检查方式以匹配所接受的函数指针类型。即使这些可以在 C++ 语言中处理,也将影响与 C 的交互:这时如何将指向 C++ 函数的指针传递给 C?例如,如何处理从 C 中回调依赖异常的 C++ 的函数?显然,C++ 函数中的异常不会消失,因此我们将有四种选择:错误码、编译期检查的异常(例如 [Sutter 2018b])、当前异常和 noexcept。只有当前的异常和非本地错误码不会影响类型系统或调用约定(ABI 接口)。幸运的是,很少有函数需要两个指针指向函数,否则我们将面临选择 16 种方案的风险。因此,如果接受异常类型系统(就当前的异常而言),混乱将是全方面的。

在现代 C++ 中,此类问题将以其他回调机制的不同形式继续存在,例如具有要被调用的成员函数的对象、函数对象和 lambda 表达式。

我的结论(得到 WG21 的认可)过去和现在都是,在 C++ 的静态类型系统中添加异常会导致系统脆弱、代码复杂性显著增加、严重的不兼容性以及与 C 代码交互的问题。这一点在 1989 年就得到了重视。

7.5 回归基础

从根本上讲,我认为 C++ 需要两种错误处理机制:

  • 异常——罕见的错误或直接调用者无法处理的错误。
  • 错误码——错误码表示可以由直接调用者处理的错误(通常隐藏在易于使用的检测操作中或作为 (值,错误码) 对从函数返回)。

考虑代码:

void user()
{
    vector<string> v{"hello!"};
    for (string s; cin>>s; )
        v.push_back(s);
    auto ps = make_unique<Shape>(read_shape(cin));
    Smiley_face face{Point{0,0},20};
    // ...
}

这个例子人造的,但其编程风格并非不典型。我们可以从中看出,user() 函数里有很多发生不太可能的错误的可能性:内存耗尽、读取错误、构造失败(例如,在 Smile_face 的多层次结构中出现错误等)。另外,使用 unique_ptr<Shape> 可以防止内存泄漏。如果我们使用显式错误码而不是异常,那么这个函数中至少需要进行五次错误检查,源代码数量将翻倍,并需要在各个构造函数中进行更多检查。没有 RAII(及其与异常的集成),代码将进一步膨胀。一般来说,更多的代码意味着更多的错误。当添加的代码使控制流程复杂时,尤其如此。这一点经常被那些通过小例子论证的人所忽视。对于小例子来说,“就一项测试”关系不大,相对也很难漏掉。

另一方面,有些错误是预料得到的,我们更愿意使用某种形式的错误码来对其进行检查:

ifstream f {"Myfile"};
if (!f) {
    // ... 处理错误 ...
}
// ... 使用 f ...

在这里,为方便起见,错误码隐藏在输入流的状态里。

因此,在理想情况下,应该只有两种错误处理的方法,但是我真的不知道如何达到这样一种理想状态。仅仅 (值,错误码) 对就有十几种变体被广泛使用(例如 std::map::insert()),并且还有一些新的变体也在 2011 年的 WG21 中被讨论(如 [Botet and Bastien 2018; Sutter 2018b])。即使委员会能就其中一个方案达成一致,也仍然会有至少十几个广泛使用的错误处理方案,每个方案都有一大群忠实的追随者支持,许多方案都有数百万行难以更动的代码。

很少有关于异常的性能和 C++ 中返回码可靠性的认真研究([Renwick et al. 2019] 是一个例外)。但是,有许多不科学的小研究和许多大声表达的意见——常常声称异常天生就比各种形式的错误码检查慢。这与我的经验不符。就我所知,还没有任何严谨的研究发现在现实的例子中错误码能胜出“很多”,或者异常能胜出“很多”。在这一讨论场景下,“很多”表示整数倍的差异,而不是几个百分点。

运行一个简单的性能测试:进行一个 N 层深度的调用序列,然后报告错误。如果错误很少见,例如 1:1000 或 1:10000 的错误率,并且调用嵌套很深,例如 100 或 1000,则异常处理要比明确的错误码判断方式快得多。如果调用深度为 1,并且错误发生的概率为 50%,则显式判断错误码测试将大获全胜。调用深度和错误概率决定了这些测试之间的差异。我要问一个简单而潜在有用的问题:“一个错误要多罕见才被看作是异常情况”?不幸的是,答案是“这要看情况”。这取决于代码、硬件、优化器、异常处理的实现,等等等等。C++ 异常的设计假设答案至少在 1:100 的范围。换句话说,错误指示的传播要远比显式的处理更为常见。

空间占用问题可能比运行期问题更难解决。对于那些遇到不能在本地处理的错误就可以立即终止的系统,我可以想象这样一个实现,在遇到 throw 时立即终止程序。但是如果要传播和处理错误,那么就不可避免,需要面对选择各种困难的折中。

对于错误处理这团乱码,任何解决方案都很可能遇到 N+1 问题(§4.2.5)[Stroustrup 2018a]。

奇怪的是,当初 C++ 引入异常时,人们担心的问题之一就是异常不够通用。许多人认为恢复(resumption)语义必不可少 [Stroustrup 1993]。当时我的猜测是,允许恢复将使异常处理的速度至少再降低两倍。

[^1]: 译注:原文如此。实际上 Bjarne 的这个写法仍然是返回对象而不是错误码,因此仍需使用异常。不用异常的写法还要啰嗦得多。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8