何时及如何使用异常

何时及如何使用异常

C/C++ Users Journal August, 2004
“你应该何时、为什么和如何使用异常”的清楚的、客观的及可测量的答案”
 
原著: Herb Sutter
翻译:老陈
 
错误处理一项很困难的工作,程序员需要在这方面得到帮助。 —[Stroustrup94] ¤16.2
 
“你应该何时、为什么和如何使用异常而不是错误编码来报告错误?”,在该专栏中,我计划基于以前的工作给一个清晰的和可实践的答案。 ( 参考引用 )
 
Over the years, I've often seen the advice, "Use exceptions for exceptional conditions." But I've also often pointed out that, although this advice is well intentioned, in practice it's trite and unsatisfying, unclear and ambiguous—a nonanswer. Why? The main problem is that "exceptional" is neither objective nor measurable, which means it just leaves open exactly the same set of questions we had when we started, and only rephrases the interpretational question from "when and for what should you use exceptions?" to "okay, fine, so then what's 'exceptional'?"
 
To come up with a clearly understandable and unambiguous prescriptive answer, we have to nail down three things:
  • We need a strict, objective, and measurable definition for what errors are. We also need guidance on where they should be reported and handled.
  • We need clear and understandable safety guarantees we can use to describe error handling.
  • Finally, we need to see what errors should be reported and handled using exceptions.
For the first two, I'm not going to talk about exceptions specifically at all, but about error handling and safety guarantees in general. This is to highlight and emphasize that exceptions are just one of several equivalent mechanisms we can choose for reporting and handling errors. Only in the final section will I return to exceptions specifically and argue, hopefully persuasively, why you should prefer to use exceptions over error codes wherever possible, and point out the few cases where it isn't possible.
 
区分错误和非错误
 
概要:一个函数是一个工作单元。因此, failures should be viewed as errors or otherwise based on their impact on functions。在一个函数 f中, 当且仅当违反了函数 f的前置条件,阻止 f满足它所调用的函数任一前置条件,阻止 f达到自身任一后置条件,或阻止 f重建其负责维护的任一不变式时,失败才是一个错误。 (特别地,在此处我们没有考虑把程序的内部错误,这是和使用断言相关的一个独立的范畴 )
 
在我们能正确地推论详细的错误处理方法 (比如,异常与错误编码对比)和技术 ( 如,安全性保证 )之前,至关重要的是清楚地按照 错误非错误对函数的影响来区别它们。这个条款的关键词是 前置条件后置条件不变式
 
无论你使用那种C++编程风格: 结构化OO泛型,函数是一个基本的工作单元。一个函数将会对它的起始状态做假设(作为前置条件在文档中注明,调用者负责遵守,该函数负责验证),然后执行一个或多个动作(作为结果或后置条件在文档中注明,被调用的函数负责执行)。一个函数可能会为了维持一个或多个不变式而共享责任。特别地, 按定义改变类的数据成员的非私有成员函数是基于其对象的工作单元,必须将对象从一个合法的不变式保护的状态带到另一个合法的状态;在函数体的执行过程中,对象的不变式可能(通常几乎总是会)被打断,只要在成员函数结束的时候重建了不变式,那就是好的。高层的函数将底层的函数组合成一个更大的工作单元。
 
任何阻止函数成功执行的故障是一个错误。因此主要有三类错误:
  • 阻止函数满足它必须调用的函数的前置条件的情况(例如,参数限制)
  • 函数遇到一个阻止其建立后置条件的情况。如果函数返回一个值,那么返回一个有效的值对象是其后置条件。
  • 函数遇到一个阻止其重建由其负责维护的不变式的情况。这是一种特殊的后置条件,特别地适用于成员函数;每个非私有的成员函数的一个基本的后置条件是它必须重建其类的不变式(参考[Stroustrup00] ¤E.2.)。
任何其它的情况都不是一个错误,因此不能作为错误来报告。
 
可能引起错误的代码负责检查和报告错误。特别地,这意味着调用者应该检查并报告违反被调函数前置条件的参数。如果调用者没有这样做,这是一个编程错误,当在调用的函数中检测到违反前置条件的参数时,可以用断言来应付。
 
考虑一些例子:
例1 std:string::insert (前置条件错误)。当试图在一个指明的位置 pos将一个新字符插入 string时,调用者应该检查无效的 pos值,它们可能违反文档中对参数要求的必备条件;例如, pos > size()。如果没有一个有效的开始点, insert函数不能成功地执行其工作。
 
例2 std:string::append (后置条件错误)。当给 string追加一个字符时,如果不能在当前的字符串中分配新的缓冲区,将会完全阻止操作执行文档中说明的功能,并达到文档中说明的后置条件,因此这是一个错误。
 
例3 :不能产生返回值(后置条件错误)。对于返回值对象的函数,产生一个合法的返回对象是其后置条件,如果不能正确地创建返回值( 例如,如果函数返回 double ,但是返回的结果是没有达到精确度要求的 double),这是一个错误。
 
例4 std:string::find_first_of (在string 的上下文中不是错误)。在字符串中搜索一个字符,不能找到字符是一个合理的结果,不是一个错误。至少,这不是通用字符串所关心的错误;如果获得 string的拥有者假设字符应该出现,因此按照更高层次的不变式,它的缺少是一个错误,那么更高层的调用代码应该参照它的不变式恰当地报告错误。
 
Report an error wherever a function detects an error that it cannot deal with itself and that prevents it from continuing in any form of normal or intended operation. Handle an error in the places that have sufficient knowledge to handle the error, to translate it, or to enforce boundaries defined in the error policy (such as on main and thread mainlines).
 
Now that we have a crisp definition of what an error is and when to report and handle it, what guarantees can and should we provide for robust error reporting and handling?
 
在每个函数中,提供使调用者免受性能惩罚的最强的安全性保证,但至少要提供基本的保证
概要:
  • 确保出错后程序仍置于一个有效的状态。这就是基本保证basic guarantee)。注意不变式破坏[invariant-destroying](包括,但不局限于泄漏),它们只是普通的[plain]错误。
  • 优先增加 “操作的最终状态不是初始状态(如果有错误发生,就回滚操作)就是指定的目标状态(如果没有错误发生,就提交操作)”的保证。这就是强保证strong guarantee)。
  • 优先增加“操作不可能失败”的保证。尽管对大多数函数来说,这是不可能的,但这是对于象析构和资源释放这样的函数所要求的。这就是不失败保证no-fail)。
基本、强和不失败(或是已知的 nothrow)保证最初是在[Abrahams96]中讨论,然后按异常安全性分别在 [GotW]、[Stroustrup00] ¤E.2 和[Sutter00]中广为宣传。不管使用的具体函数,它们适用于所有的错误处理,所以一般而言,我们将用它们来描述错误处理的安全性。
 
不失败保证强保证的一个更严格的超集, 强保证基本保证的一个更严格的超集。通常,每个函数应该提供它们能提供的最强的安全性保证,同时不会使不需要这样保证的调用代码免受性能惩罚。在任何可能的地方,如果需要,提供足够的功能允许调用代码获取更强的安全性保证(参考 vector::insert的例子)。
 
理想地是,我们编写的函数总是成功,且因此能提供不失败的保证。某些函数必须提供不失败的保证,如著名的析构,资源释放和交换函数。
 
然而,大多数函数会失败。当出错是可能的,最安全的方法是确保函数支持事务行为:要么完全成功,将程序从原始的合法状态带到预期的有效的目标状态,或失败,则将程序遗留在其调用之前的状态---对象的任何 可见状态在失败的调用前后应保持一致(例如,一个全局的 int值不能从 42变到 43),代码可能调用的任何动作在失败的调用前后应有一样的意义(例如,容器的迭代器不会无效,在前述全局的 int的执行 ++产生 43而不是 44)。这就是 强保证
 
最后,如果提供强保证很难或不必要地昂贵,则提供基本保证:要么程序完全的成功并达到预期的目标状态,或者没有完全的成功,将程序遗留在一个合法的状态中(维护函数 知道负责维护的不变式),但不可预测(它可能处在原始的状态中;全部,部份或不能达到其所有的后置条件;但要注意的是仍需重新建立所有的不变式)。应用程序的设计必须准备恰当地处理那样的状态。
 
所有的都在这;没有更低的层次了。不能 满足最低的基本保证通常是程序的臭虫。正确的程序对所有的函数至少满足基本保证;甚至少数的正确程序谨慎地按设计会泄漏资源,特别是在程序会立即退出的情况,这样做是因为知道操作系统将会收回这些资源。总是组织代码以便就算是在出现错误的时候也可以正确地释放资源及让数据处在一致的状态中(除非错误非常严重以至于优雅的或非优雅的中止是唯一的选项)
 
例1 :失败后重试。如果你的程序包括了一个保存数据到文件的命令,且写操作失败了,确认可以回复到调用者可以重试操作的状态。特别地,不要在数据已经安全地刷新到磁盘之前释放任何数据结构。例如, 我们所知道的一个文件编辑器不允许在写错误出现后改变要保存的文件名,这对于进一步的恢复不是最理想的状态。
 
例2 :外壳(Skins 。如果你正在写一个可以更换外壳的应用程序,不要在试图装载新的外壳之前销毁已存在的外壳。如果装载新的外壳失败,你的应用程序可能以一个不安全的状态结束。
 
3 std::vector::insert。因为 vector<T>的内部存储是连续的,在中间插入一个元素需要通过将一些已存在的对象移到一个位置来为新元素安排空间。这种位置的改变使用 T::T(const T&)T::operator=,如果任一个操作会失败(通过抛出异常),让insert提供强保证的唯一方法就是对容器做完整的复制,在复制的容器上执行插入,如果成功,通过不会失败的 vector<T>::swap来交换原始和复制后的容器。但是如果那样的操作每次都由 insert自己来做,不管调用者需不需要强保证,每一个 vector::insert的调用将会由于完成一个容器的完整拷贝而招至空间和性能上的惩罚。那是不需要的昂贵开销。取而代之的是,那些确实需要强保证的调用者可以由他们自己来做这样的工作,并且给予他们充分的工具去做这些工作。(最好的情况:安排被包含的类型不会在它们的拷贝构造函数和拷贝赋值操作符中抛出异常。最坏的情况:做一个拷贝,插入[ insert]到拷贝的容器中,当操作成功,交换[ swap]原始和拷贝后的容器)。
 
例4 :没有发射的火箭。考虑一个函数 f,其一部份工作是发射火箭,它使用 FireRocket函数来提供强的或不失败的保证。如果 f在发射火箭前执行了所有可能失败的工作,则可以编写提供了强保证的 f 。但是如果在发射火箭后 f必须执行其它可能失败的操作,由于不能将火箭带回来,则不能提供强保证。(无论如何,这样的 f可能应该被分成两个函数,因为单个的函数可能不会试图做有如此显著不同意义的多份工作;给一个实体一个内聚的职责。)
 
Now we have in hand two key pieces: a crisp definition of what an error is and when to report and handle it, and what guarantees we can and should provide for robust error reporting and handling. Now we can move on to the key question in the final part: When should you use exceptions instead of other techniques (notably error codes) to report errors?
 
优先选择异常来报告错误
概要:优先使用异常来报错,而不是通过错误代码。当不能使用异常(当你无法控制所有可能调用的代码,且保证它将会用C++来编写,用同样的编译器和兼容的编译选项来编译以便异常处理将起作用)和不是错误的情况,采用错误编码(例如:返回代码或 errno)。当不可能或不需要恢复时,可以使用诸如优雅或不优雅的终止方法。
 
在过去20年创建的最现代的语言并不是同时都使用异常作为它们首选的(或仅有的)错误报告机制。
 
几乎按定义,异常是为了报告正常处理过程中的 例外---即在第一节中定义,违反了前置条件,后置条件或不变式的 错误。我将用术语“状态编码” 来覆盖所有通过编码来报告状态的形式(包括返回编码, errnoGetLastError函数,和类似的返回或取回编码的策略),而“错误编码”说明意味错误的状态编码。
在C++中,通过异常比通过错误码来报告错误有清楚的优势,这些优势将会使你的代码更加强壮:
 
  • 异常不能被安静地忽略:错误编码最糟的弱点是它们被缺省的忽略;为了最轻微地注意错误编码,你不得不显式地编写接受错误和响应它的代码。对程序员来说意外地(或偷懒)没有注意到错误编码是很普通的。这使代码复审更难。异常不能被安静地忽略;为了忽略异常,你必须显式地catch它(即使仅是使用catch(…))并选择不要处理它。
  • 异常会自动地传播:缺省的情况下不会跨过作用域传播错误编码,为了把底层的错误编码通知给高层调用函数,程序员必须显式的手工编写代码来传播错误。异常自动地跨过作用域传播直到它们被确切处理了。(“把每一个函数做成防火墙并不是一个好主意”--- [Stroustrup94, ¤16.8] )
  • 异常处理将错误处理和恢复从主控制流中剔出:当检测和处理错误编码的代码被完全地编写出来后,它们必然散布在程序的控制流程主线中(并因此令人迷惑)。这也使主控制流程和错误处理代码都更难于理解和维护。异常处理自然地将错误检测和恢复移到catch块中;更确切地说,它使错误处理代码成为明显的模块而不是混在一起的意大利面条[inline spaghetti]。区分正确的操作和错误及恢复并不只是收获美观,也使控制流程主线更加容易理解和维护。
  • 从构造函数和操作符重载函数中报告错误,异常处理比其它的选择要好:拷贝构造函数和操作符重载函数有预先确定的函数标志,没有为返回编码留下空间。特别地,构造函数根本就没有返回类型(甚至不是void),另,例如,每一个operator+必须正好获取两个参数并返回一个对象(规定类型的对象;条款26)。对于操作符重载函数,如果异常处理不是理想的,使用错误编码至少是可能的;它类似于errno一样的方法,或者象在对象内打包状态这样的劣质解决方案。对于构造函数,使用错误编码不是切实可行的,因为C++语言紧紧地将构造函数异常和构造函数失败绑在一起,以至于它们就是一个意思;如果用类似于errno一样的方法来替代,例如:
 
SomeType anObject;                                  // 构造一个对象
if( SomeType::ConstructionWasOk() ) {   // 测试构造是否已经成功工作
// …
 
于是,不仅结果丑陋易错,还导致一些隐藏产生的确实不满足该类型不变式的对象---别介意多线程程序中对 SomeType::ConstructionWasOk调用固有的条件竞争。([Stroustrup00] ¤E.3.5.))
 
异常处理潜在的主要缺点是要求程序员熟悉少量由于异常 不同的执行流程而产生的惯用法。例如:析构函数和资源释放函数必然不会失败(条款51), 插入[ try catch 间的代码]的代码在异常面前必须正确(参考);为了达到后面的这个目的,有一个普通的惯用法就是在先执行确实有可能发生异常的工作,然后,当你知道真正的工作已经成功了,你就仅使用那些提供 no-fail保证的操作来提交或更改数据(Guru of the week 条款9,10,13)。但另一方面,使用错误码也有它自己的惯用法,这些惯用法 已经存在很长一段时间了,所以更多的人们知道它们---但是,不幸地是,一般也例行公事地忽视了它们。 Caveat emptor[ 指小心]。
 
性能通常不是异常处理的缺点。首先,注意,你应该总是在你的编译器中打开异常处理的选项,就算缺省的情况下它是关闭的,否则你不能从C++语言(如 operator new)和标准库的操作(如STL容器的插入操作)中得到标准的行为和错误报告。 [注意: 异常处理的实现会增加你执行文件映像的大小(这个部份不能被避免),但在没有异常抛出的地方只会招致零或接近于零的性能负载,尽管有些流行的编译器没有这么做,但有些编译器就是这样做的。 This is irrelevant to the performance of using exceptions versus error codes because it is about turning on your compiler's support for doing any exception handling at all—and that should always be turned on, otherwise the language and Standard Library won't report errors correctly.] 你的编译器对异常的处理一旦打开, 典型地,在正常的处理过程中抛出异常和返回错误代码之间的差异可以忽略不计(在没有错误发生的情况下)。你可能注意到确实在有异常抛出时有性能差异,但是如果你抛出异常是如此的频繁以至于异常处理的抛出/捕获的性能负担确实值得注意,几乎肯定的是你在那些不是真正的错误的条件下使用异常,因此也没有正确地区分错误和非错误(第一节)。如果它们确实是错误,并且确实违反了前置、后置条件和不变式,另外如果它们发生的如此频繁,这说明应用程序有非常严重的问题。
 
过度使用错误代码的征兆是应用程序需要对不重要的正确条件进行持续地检查,或者(更坏)不能检查应该被检查的错误代码。
 
过度使用异常的征兆是应用程序的代码非常频繁地抛出和捕获异常以至于catch块和 try几乎执行的一样频繁 (for example, more than, say, 10 percent of attempts to execute the try block result in exceptions being thrown). 这样的 catch块要么不是在处理真正的错误(违反了前置、后置条件和不变式),要么程序处于严重问题中。
 
例如,参考第一节中的例子,用“抛出异常”替换“报告错误”。这里还有两个:
 
例1 :构造函数(不变式错误)。如果构造函数不能成功地创建它的类型的对象,这就等同于说它不能建立所有新对象的不变式,它应该抛出异常。反过来说,从构造函数抛出异常总是说时对象的构造函数失败,对象的生命周期决没有开始;这点由语言强制。
 
例2 :成功的树的递归搜索。当用递归的算法搜索树时,将结果作为异常抛出的方式来返回结果可能很有诱惑(可以方便地展开搜索堆栈)。但是异常意味着错误,查找结果不是一个错误([Stroustrup00])。(注意,在搜索函数的上下文,找不到结果当然也不是一个错误;参考 find_first_of的例子)。
 
在很少的情况中,如果 你知道:
 
  • 异常处理的好处不适用(例如,你知道直接的调用者几乎必须直接处理错误,所以错误传播要么不会发生,要么几乎不发生。这是一种非常少的情况,因为通常被调者不会有关于其所有调用者足够的信息。),以及
  • 实际测量异常处理的性能负载超过了使用错误码,而变得重要了:(性能有差异的测量是以经验为主的,那也就是说你可能处在内部循环或经常抛出异常;后一种情况是很少的,因为它通常意味着这种条件下的异常根本不是错误,不过让我们假设不知何故它确实是吧。)
考虑使用错误码。
总结
区分错误和非错误。 当且仅当违反了函数去满足它所调用的函数的前置条件,阻止其建立后置条件,或无法重建其负责维护的任一不变式时,失败才是一个错误。 其它的任何失败都不是错误。
确保出错后程序仍置于一个有效的状态。这就是 基本保证(basic guarantee)。注意 不变式破坏[ invariant-destroying](包括,但不局限于泄漏),它们只是 普通的[ plain]错误。
 
优先增加 “操作的最终状态不是初始状态(如果有错误发生,就回滚操作)就是指定的目标状态(如果没有错误发生,就提交操作)”的保证。这就是 强保证(strong guarantee)。
 
优先增加“操作不可能失败”的保证。尽管对大多数函数来说,这是不可能的,但这是对于象析构和资源释放这样的函数所要求的。这就是 不失败保证(no-fail)。
优先使用异常来报错,而不是通过错误代码。当不能使用异常(当你无法控制所有可能调用的代码,且保证它将会用C++来编写,用同样的编译器和兼容的编译选项来编译以便异常处理将起作用)和不是错误的情况,采用错误编码(例如:返回代码或 errno)。
 
致谢
 
对于 Dave Abrahams Andrei Alexandrescu Scott Meyers 、和 Bjarne Stroustrup 关于本篇的注释和捐献表示感谢。这篇材料是从新书 C++ Coding Standards 移动而来 (我和 Andrei Alexandrescu 合著, 2004 年十月出版)
 
引用
1.      [Abrahams96] D. Abrahams. "Exception Safety in STLport" (STLport web site, 2001). Available online at http://www.stlport.org/doc/exception_safety.html.
2.      [Abrahams01] D. Abrahams. "Error and Exception Handling" (Boost web site, 2001). Available online at http://www.boost.org/more/error_handling.html.
3.      [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.
4.      [Stroustrup94] B. Stroustrup. The Design and Evolution of C++ (Addison-Wesley, 1994).
5.      [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.
6.      [SuttAlex05] H. Sutter and A. Alexandrescu. C++ Coding Standards (available in October 2004; Addison-Wesley, 2005).
7.      [Sutter00] H. Sutter. Exceptional C++ (Addison-Wesley, 2000), Items 8-19, 40, 41, and 47.
8.      [Sutter02] H. Sutter. More Exceptional C++ (Addison-Wesley, 2002), Items 17-23.
9.      [Sutter04] H. Sutter. Exceptional C++ Style (available in August 2004; Addison-Wesley, 2004), Items 11, 12, and 13.
已标记关键词 清除标记
相关推荐
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页