"何时、为了什么以及如何应该使用异常而不是错误代码来报告错误?"在本文中,我将提出一个清晰实用的答案,这个答案建立在之前的研究基础上(参见"参考文献")。
多年来,我经常听到这样的建议:"在异常情况下使用异常。"但我也经常指出,尽管这个建议是善意的,但在实践中它显得陈词滥调且不令人满意,既不清晰也不明确 —— 说了跟没说一样。为什么?主要问题是"异常"既不客观也不可测量,这意味着它只是留下了与我们开始时完全相同的一系列问题,只是将问题从"何时以及为了什么应该使用异常?"改述为"好的,那么,什么是’异常’呢?"
为了提出一个清晰易懂且明确的规范性答案,我们必须确定三件事:
- 我们需要对什么是错误有一个严格、客观且可测量的定义。我们还需要关于在哪里报告和处理它们的指导。
- 我们需要明确易懂的安全保证,用以描述错误处理。
- 最后,我们需要了解应该使用异常报告和处理哪些错误。
对于前两点,我完全不会专门讨论异常,而是讨论错误处理和安全保证。这是为了突出并强调异常只是我们可以选择用于报告和处理错误的几种等效机制之一。只有在最后一部分,我才会回到专门讨论异常,并希望能有说服力地解释为什么你应该尽可能地使用异常而不是错误代码,并指出几种无法使用异常的情况。
区分错误和非错误
函数是一个工作单元,应该根据失败对函数的影响来区分错误或非错误。在函数 f
中,当且仅当失败源于函数 f
的调用者未满足其任何前置条件、函数 f
自身无法满足其任何后置条件,或无法重新建立函数 f
有责任维护的任何不变式时,失败才是错误。特别的,这里我不考虑编程错误,这是一个单独的类别,通常使用断言处理。
在我们能够正确思考特定错误的处理方法和规范之前,根据错误对函数的影响清晰地区分错误和非错误至关重要。本节中的关键词是 前置条件 、 后置条件 和 不变量 。
函数是基本工作单元,无论你是以结构化风格、面向对象风格还是泛型风格编写 C++ 。函数会对其起始状态做出假设,即前置条件,由调用者负责保证,并执行一个或多个操作,即返回值或后置条件,由被调用者负责保证。同时函数可能共同负责维护一个或多个不变量。特别的,non-const 成员函数是定义在对象的工作单元,其总是将对象从一个有效的保持不变量的状态转变为另一个有效状态,在成员函数体内,对象的不变量可以而且几乎总是必须被打破,这是正常的,只要它们在成员函数结束时被重新建立。此外,更高级别的函数将低级别函数组合成更大的工作单元。
错误是任何阻止函数成功的失败。因此,主要有三种错误:
- 导致函数的前置条件无法成立,例如不满足参数限制等。
- 导致函数的后置条件无法成立,例如当函数存在返回值时,无法建立有效的返回值。
- 导致函数无法重新建立其负责维护的不变量,这是一种特殊类型的后置条件,特别适用于成员函数。每个非私有成员函数的一个基本后置条件是必须重新建立其类的不变量。(参见 [Stroustrup00] E.2)
任何其他条件都不是错误,且不应该被报告为错误。
可能导致错误的代码负责检测和报告错误。特别的,这意味着调用者应负责检测和报告被调用函数的前置条件。如果调用者未能做到这一点,那就是一个编程错误,因此,当在被调用的函数内检测到前置条件被违反时,可以使用断言处理它。
考虑一些例子:
-
std::string::insert
– 尝试在特定位置pos
向字符串插入新字符时,pos
的值越界违反了有关参数的前置条件,因为没有有效的起点,所以函数无法成功执行其工作,因此是一个前置条件错误。 -
std::string::append
– 将字符附加到字符串时,如果现有缓冲区已满,且分配新缓冲区失败,这会阻止函数执行其功能并满足其后置条件,因此是一个后置条件错误。 -
无法生成返回值 – 对于有返回值的函数,生成有效的返回值是一个后置条件。如果无法正确创建返回值,例如,如果函数返回一个浮点数,但不存在具有所需数学特性的浮点数值,那就是一个后置条件错误。
-
std::string::find_first_of
– 在字符串中搜索字符时,未能找到字符是一个合法的结果,而不是错误。至少,就通用字符串类而言它不是错误,如果给定字符串的所有者假设字符将存在,而其缺失导致破坏更高级别的不变量,那么更高级别的调用代码应适当地报告关于其不变量的错误。
在函数检测到它自己无法处理且阻止它以任何形式正常或预期操作的错误时,都应报告错误。在有足够上下文处处理错误、转换错误或执行错误策略中定义的界限的地方处理错误,如在 main
和线程主线上。
现在我们已经对什么是错误以及何时报告和处理它有了明确的定义,那么为了稳健的错误报告和处理,我们可以并应该提供什么保证?
在函数中提供安全保证
-
基本保证 – 确保错误总是让你的程序处于有效状态。小心那些破坏不变量的错误。
-
强保证 – 额外保证最终状态要么是原始状态,即有错误时操作被回滚,要么是预期的目标状态。
-
不失败保证 – 额外保证操作永远不会失败。虽然对大多数函数来说这是不可能的,但对于某些函数如析构函数、释放函数和交换函数,这是必须的。
基本、强和不失败保证最初在 [Abrahams96] 中描述,并在 [GotW]、[Stroustrup00] E.2 和 [Sutter00] 中关于异常安全进行了宣传,但它们适用于所有错误处理,无论使用的具体方法如何,所以我将用它们来描述一般的错误处理安全性。
强保证是基本保证的严格超集,不失败保证是强保证的严格超集。一般来说,每个函数都应该提供它能够提供的最强保证,而不会不必要地惩罚不需要该保证的调用代码,并在可能的情况下提供足够的功能性,允许需要更强保证的调用代码实现这一点,参见 vector::insert
示例。
理想情况下,我们应当编写始终成功并因此能够提供不失败保证的函数。且某些函数必须始终提供不失败保证,特别是析构函数、释放函数和交换函数。
然而,大多数函数可能会失败。当错误可能发生时,最安全的方法是确保函数具有事务行为,即要么它完全成功并将程序从一个有效状态转变为另一个有效状态,要么它失败并将程序保持在调用前的状态,即任何对象在失败调用前的可见状态与失败调用后相同,例如全局变量 int
的值不会从 42
变为 43
,这是就是强保证。
最后,如果提供强保证很困难或存在不必要的开销,那么应当提供基本保证,即要么函数完全成功并达到预期的目标状态,要么它不完全成功且使程序处于有效状态,即维护函数知晓并负责维护的不变量,但是不可预测,即它可能是也可能不是原始状态,可能也可能不满足部分后置条件,但请注意所有不变量仍必须重新建立。应用程序的设计必须准备适当处理该状态。
如此这般,没有更低的保证级别。未能满足基本保证总是编程错误。正确的程序应为所有函数至少提供基本保证,即使是那些设计上故意泄漏资源的罕见正确程序,因为即使是在程序立即中止的情况下,也知道这些资源将被操作系统回收。始终保证资源在错误存在时也能正确释放,且数据保持一致状态,除非错误严重到优雅或不优雅终止是唯一选择。
-
失败后重试 – 如果你的程序包含将数据保存到文件的命令,当写入失败时,请确保恢复到用户可以重试操作的状态。一个编辑器在写入错误后不允许更改要保存的文件名是亚稳态。特别的,在数据安全刷新到磁盘之前,不要释放任何数据结构。
-
皮肤 – 如果你编写可换肤应用程序,在尝试加载新皮肤之前不要销毁现有皮肤。防止加载新皮肤失败时,你的应用程序可能会处于不可用状态。
-
std::vector::insert
– 因为std::vector
的内部存储是连续的,将元素插入中间需要将一些现有值移动到下一个位置,为新元素腾出空间。移动操作使用T::operator=
完成,如果该操作可能抛出异常,那么使std::vector::insert
提供强保证的唯一方法是制作容器的完整副本,在副本上执行操作,如果成功,使用不失败的std::vector::swap
交换原始和副本。但如果每次std::vector::insert
自身都这样做,那么每个std::vector::insert
的执行总是会存在构造容器完整副本的空间和时间开销,无论是否需要强保证。这是不必要的损失。相反,那些确实想要强保证的调用者可以自己完成工作。 -
撤回发射的火箭 – 考虑一个函数
f
,作为发射火箭工作的一部分,而FireRocket
函数提供强保证或不失败保证。如果f
可以在发射火箭之前执行所有可能失败的工作,那么它可以被编码为提供强保证。但如果它必须在已经发射火箭后执行其他可能失败的操作,那么它就不能提供强保证,因为它无法收回火箭。无论如何,这样的f
可能应该被分成两个函数,因为单个函数可能不应尝试做多个如此重要的工作,最好给一个函数一个连贯的责任。
现在我们手中有两个关键部分,即对什么是错误以及何时报告和处理它的明确定义,以及我们可以并应该为稳健的错误报告和处理提供什么保证。现在我们可以进入最后一部分的关键问题,何时应该使用异常而不是其他技术,尤其是错误码,来报告错误?
优先使用异常报告错误
优先使用异常而不是错误码来报告错误。只有在不能使用异常时,即当你不控制所有可能的用户代码且不能保证它们用 C++ 编写并使用相同编译器和兼容的编译选项编译,以便异常处理能够工作时,以及对于不是错误的条件,才应当使用状态码报告错误。特别的,当不需要或不可能恢复时,可以使用其他方法,如优雅或不优雅的终止。
过去 20 年创建的大多数现代语言使用异常作为主要或唯一的错误报告机制,这不是巧合。
根据定义,异常是用于报告正常处理的例外情况,即"错误"。在第一节中我们将其定义为违反前置条件、后置条件和不变量。我们使用术语"状态码"涵盖通过代码报告状态的所有形式,包括返回代码、 errno
、 GetLastError
和类似通过返回值或检查代码的策略,并特别用"错误码"指那些表示错误的状态码。
在 C++ 中,通过异常报告错误比通过错误码报告有明显优势,以下这些都使你的代码更健壮。
-
异常不能被静默忽略 – 错误码最可怕的弱点是默认情况下它们能被忽略,要对错误码增加心智,你必须明确编写代码接受错误并响应。程序员意外或懒惰地忽略错误码很常见。这使代码审查更困难。异常不能被静默忽略,若忽略异常,你必须明确捕获它,即使只是
catch(...)
并选择不对其采取行动。 -
异常自动传播 – 错误码默认不传播,要通知更高级别函数获得低级别函数的错误码,编写中间代码的程序员必须明确的手写错误传播代码。而异常默认自动传播直到错误被处理,这直接支持良好的错误处理,即"试图让每个函数都成为防火墙并不是一个好主意。最好的错误处理策略是只在指定的主要接口上处理非本地错误。" —— [Stroustrup94] 16.8
-
异常将错误处理和恢复从控制流的主线中移除 – 当编写错误码的检测和处理时,必然与控制流的主线交织。这使得主控制流和错误处理代码都更难理解和维护。异常处理自然将错误检测和恢复移入不同的
catch
块,也就是说,它使错误处理明显模块化而不是内联意大利面条式代码。这使主线控制更易理解和维护,而且明确区分正确操作和错误检测与恢复不仅仅在审美上有好处。 -
在构造函数和运算符报告错误方面异常处理比其他方法更好 – 大部分情况下构造函数和运算符有预定义的签名,不会预留返回错误码的空间。特别的,构造函数根本没有返回类型,甚至不是
void
,以及,每个operator+
必须精确接受两个参数并返回一个对象。对于运算符,使用错误码虽然是可能的,但是不是理想的,它需要类似errno
的方法,或诸如将状态与对象打包的次优解决方案。对于构造函数,使用错误代码是不可行的,因为 C++ 语言将构造函数异常和构造函数失败紧密绑定,使两者必须同义。如果我们使用类似errno
的方法。FSomeType Object; // 构造一个对象 if (FSomeType::ConstructionWasOK()) { // 测试构造是否成功 } // ...
那么结果不仅难以使用且容易出错,而且会导致构造出存在但实际上不满足类不变量的错误对象,更不用说在多线程应用程序中对
FSomeType::ConstructionWasOK
的调用存在固有的竞争。(参见 [Stroustrup00] E.3.5)
异常处理的主要潜在缺点是它要求程序员熟悉一些因异常的额外控制流而产生的递归习语。例如,析构函数和释放函数不得抛出异常,中间代码必须在面对异常时正确,为实现后者,一个常见的编码习语是安全地在副本上执行可能发出异常的所有工作,然后只有当你知道实际工作已成功时,才可以以仅使用不抛出操作的情况下提交并修改程序状态。但使用错误码也有自己的习语,只是已经存在更长时间,所以更多人已经知道它们,但不幸的是,人们也经常且常规性地忽略它们。买者自慎。
性能通常不是异常处理的缺点。首先,请注意,即使异常处理在你的编译器中默认关闭,你也应该始终打开它,否则,你将无法从 C++ 语言和标准库获得标准行为和错误报告。当打开编译器对异常处理的支持时,可能会导致增加可执行映像的大小,这部分是不可避免的,但在正常处理,即没有发生错误的情况下,抛出异常和返回错误码之间的性能差异通常可以忽略不计。在发生错误的情况下,你可能会注意到性能差异,但如果你抛出得如此频繁,以至于异常抛出和捕获处理的性能开销非常明显,几乎可以肯定是你将异常用于了非真正的错误,因此没有正确区分错误和非错误,参见第一节。如果它们是真正违反前置条件、后置条件和不变量的错误,而且它们确实发生得那么频繁,那么应用程序有更严重的问题。
错误码过度使用的一个症状是应用程序代码需要不断检查琐碎且总是为真的条件,或者更糟糕的情况是未能检查应该检查的错误码。
异常过度使用的一个症状是应用程序代码频繁地抛出和捕获异常,以至于 catch
块的执行频率几乎与其 try
块成功完成的频率一样高。例如,在尝试执行 try
块的过程中,超过 10% 的尝试会导致异常抛出。这样的 catch
块要么不是真正处理真正的错误,即违反前置条件、后置条件或不变量的错误,要么程序有更严重的问题。
例如,参见第一节以及下面的两个例子。
-
构造函数 – 如果构造函数无法成功创建其类型的对象,这与无法建立新对象的所有不变量相同,它应该抛出异常。相反,从构造函数抛出的异常总是意味着对象的构造失败,对象的生命周期从未开始,语言强制要求这一点。
-
成功递归搜索 – 使用递归搜索算法时,可能会尝试通过将结果作为异常抛出来返回结果并方便地展开搜索栈。但异常意味着错误,而找到结果不是错误。此外,在搜索函数的上下文中,找不到结果也不是错误,参见
find_first_of
示例。
在罕见情况下,当你知道以下条件时。
-
异常的好处不适用 – 例如,你知道直接调用者几乎总是必须直接处理错误,传播错误应该从不或几乎从不发生。这非常罕见,因为通常被调用者没有这么多关于所有调用者特性的信息。
-
性能差异确实重要 – 例如,性能差异是根据经验测得的,因此你可能需要在循环中抛出异常。这同样非常罕见,因为它通常意味着该失败实际上根本不是错误,但让我们假设它确实是。
应当考虑使用错误码。
总结
区分错误和非错误。当且仅当失败导致函数无法满足其被调用的前置条件、无法建立其自己的后置条件或重新无法建立其共同负责维护的不变量时,该失败才是错误。其他一切都不是错误。
确保错误始终使你的程序处于有效状态,这是基本保证。小心破坏不变量的错误。
最好额外保证最终状态要么是原始状态,即有错误时操作被回滚,要么是预期的目标状态,这是强保证。
最好额外保证操作永远不会失败。虽然对大多数函数来说这是不可能的,但对于析构函数和释放函数等函数是必需的。
最后,优先使用异常而不是错误码来报告错误。只有在不能使用异常,即当你不控制所有可能的用户代码且不能保证它们用 C++ 编写并使用相同编译器和兼容的编译选项编译,以及对于不是错误的失败时,才使用错误码。
致谢
感谢 Dave Abrahams 、Andrei Alexandrescu 、Scott Meyers 和 Bjarne Stroustrup 对本文的评论和贡献。本文内容摘自由本文作者 Herb Sutter 与 Andrei Alexandrescu 合著的新书《C++ Coding Standards》,该书将于 2004 年 10 月出版。
参考文献
- [Abrahams96] D. Abrahams. "Exception Safety in STLport" (STLport web site, 2001). Available online at http://www.stlport.org/doc/exception_safety.html.
- [Abrahams01] D. Abrahams. "Error and Exception Handling" (Boost web site, 2001). Available online at http://www.boost.org/more/error_handling.html.
- [Alexandrescu03] A. Alexandrescu and D. Held. "Smart Pointers Reloaded" (C/C++ Users Journal, 21(10), October 2003). Available at http://www.moderncppdesign.com/publications/cuj-10-2003.html.
- [Stroustrup94] B. Stroustrup. The Design and Evolution of C++ (Addison-Wesley, 1994).
- [Stroustrup00] B. Stroustrup. The C++ Programming Language, Special Edition (Addison-Wesley, 2000). Note in particular the exception safety appendix, also available online at http://www.research.att.com/~bs/3rd_safe0.html.
- [SuttAlex05] H. Sutter and A. Alexandrescu. C++ Coding Standards (available in October 2004; Addison-Wesley, 2005).
- [Sutter00] H. Sutter. Exceptional C++ (Addison-Wesley, 2000), Items 8-19, 40, 41, and 47.
- [Sutter02] H. Sutter. More Exceptional C++ (Addison-Wesley, 2002), Items 17-23.
- [Sutter04] H. Sutter. Exceptional C++ Style (available in August 2004; Addison-Wesley, 2004), Items 11, 12, and 13.