Loading... # 5.4 异常处理 异常处理提供了一个系统化且健壮的方法来处理那些无法在它们被检测到的地方就地处理完成的错误。 传统的异常处理方法(在C、C++和其他语言中)包括: * 返回错误代码(`return`非零值) * 设置错误状态指示器(如`errno`) * 调用错误处理函数 * 使用`longjmp`直接从上下文跳转到错误处理代码 * 每次调用时传递一个指向状态对象的指针 在考虑异常处理时,必须将其与其他处理error的可能方式进行对比。比较时,合理的考虑范畴包括: * 编程风格 * 错误处理代码的稳健性和完整性 * 运行时系统(内存)开销 * 处理一个错误的额外开销 考虑一个平凡的例子: ```cpp double f1(int a) { return 1.0 / a; } double f2(int a) { return 2.0 / a; } double f3(int a) { return 3.0 / a; } double g(int x, int y, int z) { return f1(x) + f2(y) + f3(z); } ``` 这段代码不包含错误处理程序。在C++异常处理机制出现之前,有几种技术可以检测和报告错误: ```cpp void error(const char *e) { // handle error } double f1(int a) { if (a <= 0) { error("bad input value for f1()"); return 0; } else return 1.0 / a; } int error_state = 0; double f2(int a) { if (a <= 0) { error_state = 7; return 0; } else return 2.0 / a; } double f3(int a, int *err) { if (a <= 0) { *err = 7; return 0; } else return 3.0 / a; } int g(int x, int y, int z) { double xx = f1(x); double yy = f2(y); if (error_state) { // handle error } int state = 0; double zz = f3(z, &state); if (state) { // handle error } return xx + yy + zz; } ``` 理想情况下,真正的程序会使用一致的错误处理风格,但这种一致性在大型程序中往往很难实现。请注意,error_state技术不是线程安全的,除非实现提供对线程unique static数据的支持,而且用`if (error_state)`产生的分支可能会干扰处理器中的管道优化。还要注意的是,在`error()`可能不会终止程序,这种情况下程序很难高效率地使用它。然而,这里的关键点是,任何处理不能在本地处理的错误的方法都意味着空间和时间的额外开销。它还会使程序的结构复杂化。 使用“异常”,这个例子可以写成这样: ```cpp struct Error { int error_number; Error(int n) : error_number(n) {} }; double f1(int a) { if (a <= 0) throw Error(1); return 1.0 / a; } double f2(int a) { if (a <= 0) throw Error(2); return 2.0 / a; } double f3(int a) { if (a <= 0) throw Error(3); return 3.0 / a; } int g(int x, int y, int z) { try { return f1(x) + f2(y) + f3(z); } catch (Error &err) { // handle error } } ``` 在考虑异常处理的开销时,我们必须要通盘考虑可选的异常处理技术的成本。 Exception的使用将错误处理代码与正常的程序执行流隔离开来,而且与error code方案不同,它无法被忽略。另外,在抛出异常时自动销毁栈上对象,使程序更不容易泄漏内存或其他资源。使用exception,一旦发现问题,它就无法被忽略——如果不能捕捉和处理异常,就会导致程序终止。关于使用异常的技术的讨论,见 "The C++ Programming Language"的附录E [BIBREF-30]。 异常处理的早期实现导致了代码规模的显著增加和一定的运行时overhead。这导致一些程序员会选择避免使用它,编译器供应商也提供开关来抑制异常的使用。在一些嵌入式和资源受限的环境中,由于担心overhead,或者由于异常机制的实现不能满足项目的可预测性要求,exception的使用被有意地排除。 我们可以区分三种overhead的来源: - **try块** 与每个try-block或catch子句相关的数据和代码。 - **常规内容** 与正常执行的函数相关的数据和代码,如果不存在exception,可能就不会需要的内容——例如错过的优化机会。 - **抛出异常** 与抛出异常相关的数据和代码。 在使用传统技术处理错误时,每种来源都有相应的开销。 ## 5.4.1 异常处理的实现细节与技术 异常处理(Exception Handling,简称EH)的实现必须解决几个问题: * **try块** 为异常的捕获建立上下文。 * **捕获** EH的实现必须提供一些运行时的类型识别机制,以便在抛出异常时找到捕获条款。 RTTI和EH功能所需的信息有一些重叠,但不完全相同:EH的类型信息机制必须能够将派生类与基类相匹配,即使是没有虚函数的类也是如此。同时也要能够识别诸如`int`这样的内置类型。另一方面,EH类型信息不需要支持下移或交叉转换。 由于这种重叠,一些实现要求在启用EH时要启用RTTI。 * **异常的清理** 没有被重新抛出的异常必须在退出catch子句时被销毁。异常对象的内存必须由EH实现来管理。 * **具有非平凡析构的自动存储期或临时对象** 如果对象在构造后、生命周期结束前发生异常,则必须调用析构函数——即使没有`try-catch`的存在也是如此。EH的实现需要跟踪所有这些对象。 * **具有非平凡析构的对象的构造** 如果在构造过程中发生异常,所有构造完成的基类和子对象必须被销毁。这意味着EH实现必须跟踪对象的当前构造状态。 * `throw`**表达式** 被抛出的异常对象的副本必须被分配到EH实现提供的内存中。然后必须使用EH类型信息找到最接近的匹配的catch子句。最后,在控制权转移到catch子句之前,必须执行自动存储期对象、临时对象和部分构造对象的析构。 * **强制异常规范** 必须检查抛出的类型与*异常规范*中允许的类型列表的一致性。如果发现不匹配,必须调用*意外处理程序*。 * **operator new** 如果在构建具有动态存储期的对象时抛出了一个异常,在调用部分构建对象的析构函数后,必须调用相应的`operator delete`来释放内存。 这里同样可以使用一个类似于`try-catch`实现的机制。 实现方案在如何平衡上述成本方面各不相同。 两种主要的策略是: - “代码”方法,即代码与每个`try-catch`块相关联; - “表”方法,使用编译器生成的静态表。 当然,还存在一些混合的方法,本文只讨论上述两种主要方案。 ### 5.4.1.1 “代码”方法 使用这种方法的实现必须动态地维护辅助数据结构,以管理执行上下文的捕获和传输,还需要维护动态数据结构,以跟踪在发生异常的情况下需要被解绑的对象。这种方法的早期实现使用setjmp/longjmp来返回到上层上下文。然而,使用特定用途的代码来完成此项工作可以获得更好的性能。也可以通过系统地使用(编译器生成的)返回代码来实现这种模式。代码方法处理5.4.1中确定的问题的典型方法如下: * **try块** 保存执行环境,并在`try`块入口处向EH栈中`push`一个捕获代码的引用。 * **具有非平凡析构的自动存储期或临时对象** 对于每个已构造的对象,提前注册它的析构函数以备使用。典型的实现在EH栈内使用链式存储的列表来存储。如果抛出了一个异常,这个列表会决定哪些对象需要被析构。 * **具有非平凡析构的对象的构造** 一个众所周知的实现是在构建基类和子对象时为它们增加一个计数器。如果在构建过程中出现异常,该计数器将被用来确定哪些部分需要被销毁。 * `throw`**表达式** 在找到catch子句后,为EH栈中位于`throw`*表达式* 和选中的`catch`子句之间的所有构造的对象[^throwobj] 调用析构函数。恢复与catch子句相关的执行环境。 #### 5.4.1.1.1 “代码”方法的空间开销 - 异常处理的成本与对象无关,所以对象的大小不受影响。 - 异常处理意味着一种RTTI的形式,这可能需要增加一些代码大小、数据大小。 - 异常处理代码需要插入到每个`try-catch`的对象代码中。 - 注册析构需求的代码被插入到对象代码中,用于每个具有非平凡析构的栈对象。 - 检查被调用的函数的*抛出规范*具有开销。 #### 5.4.1.1.2 “代码”方法的时间开销 * 在进入每个`try`块时: * 提交包围`try`块的变量变化 * 保存执行上下文(入栈) * 存储对应的`catch`块(入栈) * 在离开每个`try`块时: * 移除执行上下文 * 移除记录的`catch`子句 * 调用一般函数时: * 如果函数存在*异常规范*,记录以供检查 * 创建局部或临时对象时: * 记录全部 * `throw`或者re-`throw`时: * 定位到匹配的`catch`子句(如果有的话)——这会导致一些运行时检查(可能类似于RTTI检查) 如果找到,那么: * 销毁记录的局部变量 * 检查调用过程的的函数的*异常规范* * 使用`catch`子句相关的上下文 否则: * 调用`terminate_handler`(终止处理) * 进入每个`catch`子句时: * 移除相关`catch`子句 * 离开每个`catch`子句时: * 抛弃当前`exception`对象(如有必要,销毁之) “代码”模型将代码和相关数据结构分布在整个程序中。这意味着不需要单独的运行时支持系统。这样的实现可以是可移植的,与将C++翻译成C或其他语言的实现兼容。 “代码”模型有两项主要的缺点: - 相关的栈和运行时间开销对于`try-block`入口来说可能很高; - 即使没有抛出异常,也必须对自动的、临时的和部分构造的对象记录在EH栈上。 也就是说,仅仅因为异常“有可能”产生,与错误处理无关的代码就会变慢。这与始终检查错误状态或返回值的错误处理策略类似。 这种(在此模式下,不可避免的)记录的成本在不同的实现中差异很大。然而,一个供应商报告说,从C++到ISO C的翻译器的速度影响约为6%。这通常被认为是一个非常好的结果。 ### 5.4.1.2 “表”方法 使用这种方法的典型实现将生成一些只读表,用于确定当前的执行环境、定位捕获条款和跟踪需要销毁的对象。“表”方法处理5.4.1中确定的问题的典型措施如下: * **try块** 这种方法不产生任何运行时开销。所有的记录都是预先计算好的,作为程序计数器和异常情况下要执行的代码之间的映射。表增加了程序镜像的大小,但可以从工作集上移除以提高引用局部性。表可以放在ROM中,或者在有虚拟内存的宿主系统中,可以保持被换出的状态,直到实际抛出一个异常。 * **具有非平凡析构的自动存储期或临时对象** 正常执行没有运行时间成本。只有在出现异常的情况下,才有必要侵入正常执行。 * `throw`**表达式** 静态生成的表被用来定位匹配的处理程序(*handler*)和这之间需要销毁的对象。同样,在正常的执行过程中没有任何运行时开销。 #### 5.4.1.2.1 “表”方法的空间开销 - 异常处理的成本与对象无关,所以对象的大小不受影响。 - 异常处理隐含RTTI的一种应用,意味着代码和数据大小的一些增加。 - 这个模型使用静态分配的表和一些库的运行时支持。 - 检查被调用的函数的*抛出规范*具有开销。 #### 5.4.1.2.1 “表”方法的时间开销 * 在进入每个`try`块时: * 某些实现将`try`块外围变量的执行状态提交,另一些使用更为复杂的状态表解决[^foot7] 。 * 在离开每个`try`块时: * 无额外开销 * 调用一般函数时: * 无额外开销 * 创建局部或临时对象时: * 无额外开销 * `throw`或者re-`throw`时: * 取决于是否有匹配的`catch`子句 如果有,那么: * 销毁全部在`throw`与`catch`子句间的局部的、临时的、部分构造的变量。 * 检查异常是否符合`throw`和*handler*之间所有函数的异常规范。 * 将控制流转到`catch`子句 否则: * 调用`terminate_handler` [^termi] * 进入每个`catch`子句时: * 无额外开销 * 离开每个`catch`子句时: * 无额外开销 这种方法的主要优点是,在管理try-catch或对象记录方面没有栈或运行时开销。除非有异常被抛出,否则不会产生运行时的开销。 缺点是实现起来比较复杂,而且不容易翻译成另一种高级语言(如C)的实现。静态表可能相当大。这对有虚拟内存的系统来说可能不是一个负担,但对一些嵌入式系统来说,其成本可能很高。所有相关的运行时成本只在抛出异常时发生。然而,由于需要检查潜在地大且复杂的状态表,响应一个异常所需的时间可能很大且不定,并且取决于程序的大小和复杂性。这需要考虑到异常可能出现的频率。极端的情况是,一个为不经常发生的异常而优化的系统,第一次抛出的异常可能会导致磁盘访问。 一个供应商报告说,生成的表对代码和数据空间的影响约为15%。这也许可以进一步优化,因为这个供应商没有对空间进行优化的需求。 ## 5.4.2 异常处理开销的可预见性 ### 5.4.2.1 `try-catch`表现的预测 对于一些程序来说,难以预测将控制权从`throw`表达式传递到正确的的`catch`子句所需的时间是一个问题。这种不确定性来自于销毁自动对象的需要,以及——在“表”模型中——来自于查阅表的需要。在某些,特别是那些有实时性要求的系统中,能够准确地预测操作需要花费多长时间是很重要的。 由于这个原因,目前的异常处理实现可能不适合某些应用。然而,如果可以静态地确定调用树,并且使用EH实现的表方法,就有可能静态地分析将控制权从一个给定的`throw`表达式转移到相应的`catch`子句所需的事件序列。然后可以对每个事件进行静态分析,以确定它们对开销的影响,并将整个事件序列汇总到一个单一的成本域(最坏情况、最好情况、无界的、不确定的)。这样的分析在原则上与目前用于异常以外的代码的时间估计方法没有区别。 对EH表示的保留意见之一是,在自动对象被销毁时、`throw`表达式之后且控制权传递给`catch`子句之前,可能会有不可预知的时间。应该可以准确地确定EH机制本身的成本,而任何被调用的析构函数的成本都需要按照确定任何其他函数的成本的方式来确定。 鉴于这样的分析,“不可预测”一词是不恰当的。成本可能是相当可预测的,有一个很好确定的上下限。在某些情况下(递归上下文,或条件调用树),成本可能无法静态确定。对于实时应用程序,一般来说,最重要的是有一个确定的时间域,在上下限之间有一个小的偏差。实际的执行速度往往不那么重要。 ### 5.4.2.2 异常规范 一般来说,*异常规范*必须于运行时检测,例如: ```cpp void f(int x) throw (A, B) { // whatever } ``` 在一个简单的实现中,将产生大致相当于以下程序的代码: ```cpp void f(int x) { try { // whatever } catch (A&) { throw; } catch (B&) { throw; } catch (...) { unexpected(); } } ``` 原则上,静态分析(特别是全程序分析)可以用来消除这些测试。这对于不支持动态链接的应用程序来说可能特别重要,这些应用程序不会大或复杂到让分析失效,也不会频繁变化而使分析变得昂贵。根据实现不同,空的异常规范可能对优化特别有帮助。 使用空的异常规范应该可以减少开销。具有空的异常规范的函数的调用者可以根据被调用的函数永远不会抛出任何异常的知识来进行优化。特别是,在一个不能抛出异常的区块中,带有析构函数的对象不需要对异常进行保护。也就是说,在“代码”模型中不需要注册,而在“表”模型中不需要为该对象建立表项。比如: ```cpp int f(int a) throw (); char g(const std::string& s) { std::string s2 = s; int maximum = static_cast<int>(s.size()); int x = f(maximum); if (x < 0 || maximum <= x) x = 0; return s2[x]; } ``` 在这里,编译器不需要为了考虑在构建`s2`之后抛出异常的可能性而调整行为。 当然,没有要求编译器必须执行这种优化。然而,一个用于高性能的编译器很可能会执行这种优化。 [^termi]: 当`terminate_handler`因为没有找到合适的异常处理而被调用,局部变量是否被析构、栈是否被解开是实现定义的。 [^foot7]: 在这样的实现中,这显著地使变量变得部分`volatile`,并可能因此而影响其他优化。 [^throwobj]: 译者注:例如,所抛出的对象。 © 允许规范转载 打赏 赞赏作者 赞 1 如果觉得我的文章对你有用,请随意赞赏