错误处理作为一种备受争议的话题,我认为将长期存在下去。许多人在这个问题上有强烈的固有认知,其中一些是基于各种应用领域中扎实的经验——过去 50 多年已经有了很多相关的技术积累。在错误处理领域,性能、通用性、可靠性的需求往往发生冲突。
与 C++ 一样,问题不是我们没有解决方案,而是有太多解决方案。从根本上讲,很难通过单一的机制来满足 C++ 社区的多样化需求,但是人们往往只看到问题的一部分,就以为他们掌握了解决问题的终极方案 [Stroustrup 2019a]。
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++ 函数不知道被调用函数的实现语言。这样的场景使我们不能通过修改函数签名以添加“异常传播参数”,或隐式地向返回类型添加返回码的方法做错误处理。
f()
g()
h()
与使用其他技术相比,异常与 RAII(§2.2)一起解决了许多棘手的错误处理问题(例如,如何处理构造函数中的错误以及那些远离错误处理代码的错误),而且所需的时间成本要小得多(与 1990 年代中期所用的技术相比通常不到 3%,甚至更便宜)。虽然异常从来没有引起争议,但我还是低估了它们引起争议的可能性。
当然总有一些应用不适合使用异常,例如:
因此,大多数 C++ 实现仍然保留了非异常机制的错误处理方式。另一方面,也存在一些通过错误码无法提供良好解决方案的场景:
++
*
->
multiply(add(a,b),c)
(a+b)*c
此外,还有一些与使用异常有关的现实问题:
throw
noexcept
catch
最终结果是 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 参数将错误码通过参数传递出来:
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 了。事实上,文件系统(在我看来相当正确)使用异常来处理罕见的错误并不能让那些认为异常有根本缺陷的人满意,特别是,它仍要求存在异常支持。
bool
try
使用 noexcept(§4.5.3),人们可以抑制所有从函数抛出的异常,并允许调用者忽略抛出异常的可能性。
使用 noexcept 可以使担心性能问题(或真或假)的人们放心。它也可以通过减少控制路径的数量来改善优化效果,但前提是程序员不要通过测试返回码将这些路径添加回去。许多低级函数,例如大多数 C 函数,都不存在异常。
使用 noexcept 可以简化错误处理(如果一个函数不抛出异常,我们就不需要捕获任何异常),也可以使其复杂化(如果一个函数不能抛出异常,但又可能会失败,我们必须使用其他错误处理机制)。特别是,在异常抛出与其处理程序之间的路径上的 noexcept,会把一个异常变成程序终止运行。因此,对于一个处于维护期的程序,在函数中使用使用 noexcept,可能会导致先前正确的程序失败。
请注意,异常被添加到 C++ 中的一个重要原因是为了支持那些在发生错误时也决不可以无条件中止的应用。异常仅表示发生了故障,并且从 main() 到抛出点的路径上的任何代码都可以对其进行处理。特别是,这样可以支持一个重要场景:在终止之前进行一些本地清理(例如,刷新输出缓冲区,或向日志文件添加错误报告)。
main()
解决 C++ 中的逻辑和性能问题的传统方法是将计算从运行期挪到编译期。显然,将异常与静态类型系统集成的可能性在 1980 年代被认真考虑过,后来又反复被重新考虑。如果异常是函数类型的一部分,那么程序就会有更好的类型检查,函数就更能自我描述,异常处理也更容易优化。
不将异常作为类型系统的一部分的一个主要原因是,如果异常是函数类型的一部分,那么对该函数可能抛出的异常集的更改将要求所有调用该函数的函数重新编译。在一个大多数主要程序都由许多单独开发的库组成的世界里,这将导致灾难性的脆弱,及无法管理的相互依赖 [Stroustrup 1994]。
函数指针方面也有相关的明显问题。在大多数主要的 C++ 程序中都有很多 C 风格的代码,现在仍然如此。C 风格的泛型代码(例如,qsort 的比较函数参数)和回调(例如,在 GUI 中)的主要参数化机制均会用到函数指针。
qsort
如果我需要一个指向函数的指针,并且异常是类型系统的一部分,那么,我要么决定始终从所指向的函数中获取异常,要么不接受异常,要么以某种方式处理这两种选择。除非将对类型查询的支持或基于异常的重载添加到语言中,否则都很难两者兼顾。确定了要接受哪种类型函数指针参数后,我现在必须调整调用函数中的错误检查方式以匹配所接受的函数指针类型。即使这些可以在 C++ 语言中处理,也将影响与 C 的交互:这时如何将指向 C++ 函数的指针传递给 C?例如,如何处理从 C 中回调依赖异常的 C++ 的函数?显然,C++ 函数中的异常不会消失,因此我们将有四种选择:错误码、编译期检查的异常(例如 [Sutter 2018b])、当前异常和 noexcept。只有当前的异常和非本地错误码不会影响类型系统或调用约定(ABI 接口)。幸运的是,很少有函数需要两个指针指向函数,否则我们将面临选择 16 种方案的风险。因此,如果接受异常类型系统(就当前的异常而言),混乱将是全方面的。
在现代 C++ 中,此类问题将以其他回调机制的不同形式继续存在,例如具有要被调用的成员函数的对象、函数对象和 lambda 表达式。
我的结论(得到 WG21 的认可)过去和现在都是,在 C++ 的静态类型系统中添加异常会导致系统脆弱、代码复杂性显著增加、严重的不兼容性以及与 C 代码交互的问题。这一点在 1989 年就得到了重视。
从根本上讲,我认为 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(及其与异常的集成),代码将进一步膨胀。一般来说,更多的代码意味着更多的错误。当添加的代码使控制流程复杂时,尤其如此。这一点经常被那些通过小例子论证的人所忽视。对于小例子来说,“就一项测试”关系不大,相对也很难漏掉。
user()
Smile_face
unique_ptr<Shape>
另一方面,有些错误是预料得到的,我们更愿意使用某种形式的错误码来对其进行检查:
ifstream f {"Myfile"}; if (!f) { // ... 处理错误 ... } // ... 使用 f ...
在这里,为方便起见,错误码隐藏在输入流的状态里。
因此,在理想情况下,应该只有两种错误处理的方法,但是我真的不知道如何达到这样一种理想状态。仅仅 (值,错误码) 对就有十几种变体被广泛使用(例如 std::map::insert()),并且还有一些新的变体也在 2011 年的 WG21 中被讨论(如 [Botet and Bastien 2018; Sutter 2018b])。即使委员会能就其中一个方案达成一致,也仍然会有至少十几个广泛使用的错误处理方案,每个方案都有一大群忠实的追随者支持,许多方案都有数百万行难以更动的代码。
std::map::insert()
很少有关于异常的性能和 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