泛型组件中的异常安全性
从为 C++ 标准库指定异常安全性中汲取的经验教训
David Abrahams
[email protected]
摘要。本文代表了为满足现实世界需求而积累的知识:C++ 标准模板库与异常(C++ 核心语言内置的错误处理机制)之间表现出有用的且定义明确的交互。它探讨了异常安全性的含义,揭示了关于异常和泛型的令人惊讶的错误认识,描述了用于推断程序正确性的宝贵工具,并概述了用于验证异常安全性的自动化测试过程。
关键词:异常安全性,异常,STL,C++
1 什么是异常安全性?
非正式地说,组件的异常安全性意味着它在执行期间发生异常时表现出合理的行为。对于大多数人来说,术语“合理”包括对错误处理的所有常见预期:资源不应泄漏,并且程序应保持在定义明确的状态以使执行能够继续。对于大多数组件来说,它还包括在遇到错误时向调用方报告错误的预期。
更正式地说,我们可以将一个组件描述为最小异常安全,如果在该组件内部抛出异常时,它的不变性保持不变。稍后我们将看到,至少可以区分三种不同的异常安全性级别。这些区别可以帮助我们描述和推断大型系统的行为。
在泛型组件中,我们通常还有一个对异常中立性的期望,这意味着组件类型参数抛出的异常应以不变的形式传播到组件的调用方。
2 错误认识和迷信
到目前为止,异常安全性似乎很简单:它并不构成我们对使用更传统错误处理技术的代码的期望之外的任何内容。然而,从心理学的角度来看,检查这个词可能是有价值的。在 C++ 拥有异常之前,没有人谈论过“错误安全性”。
就好像异常被视为对原本正确的代码的神秘攻击,我们必须保护自己免受这种攻击。不用说,这不会导致与错误处理的健康关系!在标准化过程中,一个需要广泛支持变更的民主过程,我遇到了许多普遍存在的迷信。为了开始讨论泛型组件中的异常安全性,可能值得面对其中的一些。
“模板和异常之间的交互尚不清楚。” 这种错误认识,通常来自那些认为这两种都是新的语言特性的人,很容易消除:根本不存在交互。模板一旦实例化,在所有方面都像普通的类或函数一样工作。推理带有异常的模板行为的一种简单方法是考虑该模板的特定实例化如何工作。最后,模板的泛型性不应引起特别的担忧。尽管组件的客户端提供操作的一部分(除非另有说明,否则可能会抛出任意异常),但这对于使用熟悉的虚函数或简单函数指针的操作也是如此。
“众所周知,编写异常安全的泛型容器是不可能的。” 这种说法经常被听到,与 Tom Cargill 的一篇文章有关 [4],他在其中探讨了泛型堆栈模板的异常安全性问题。在他的文章中,Cargill 提出了一些有用的问题,但不幸的是,他未能提出问题的解决方案。 1 他得出的结论是,解决方案可能不存在。不幸的是,他的文章被许多人认为是这种推测的“证明”。自从它出版以来,已经出现了许多异常安全的泛型组件的例子,其中包括 C++ 标准库容器。
“处理异常会降低代码速度,而模板专门用于获得最佳性能。” C++ 的良好实现不会将单个指令周期用于处理异常,直到抛出一个异常,然后它可以以与调用函数相当的速度进行处理 [7]。这本身就使使用异常的程序的性能等同于忽略错误可能性情况下的程序。使用异常可能会比“传统”错误处理方法产生更快的程序,原因如下。首先,catch 块向编译器指示哪些代码用于错误处理;然后它可以与通常的执行路径分离,从而提高引用局部性。其次,使用“传统”错误处理的代码通常必须在每次函数调用后测试返回值是否存在错误;使用异常完全消除了这种开销。
“异常使得更难以推断程序的行为。” 通常被用来支持这种错误认识的是,在堆栈展开期间如何遵循“隐藏”的执行路径。对于任何期望在从函数返回时销毁局部变量的 C++ 程序员来说,隐藏的执行路径并不新鲜
ErrorCode f( int& result ) // 1 { // 2 X x; // 3 ErrorCode err = x.g( result ); // 4 if ( err != kNoError ) // 5 return err; // 6 // ...More code here... return kNoError; // 7 }
在上面的示例中,在第 6 行和第 7 行中,对 X::~X()
有一个“隐藏”的调用。当然,使用异常,没有用于错误处理的代码可见
int f() // 1 { // 2 X x; // 3 int result = x.g(); // 4 // ...More code here... return result; // 5 }
对于许多更熟悉异常的程序员来说,第二个示例实际上比第一个示例更易读和更易理解。“隐藏”的代码路径包括对局部变量的析构函数的相同调用。此外,它们遵循一个简单的模式,该模式完全像在每个函数调用之后存在一个潜在的返回语句以防出现异常。可读性得到增强,因为正常的执行路径没有被错误处理掩盖,并且返回值可以自然地使用。
异常可以增强正确性的另一种更重要的方式是:允许使用简单的类不变性。在第一个示例中,如果 x
的构造函数需要分配资源,它无法报告失败:在 C++ 中,构造函数没有返回值。当避免异常时,通常的结果是,需要资源的类必须包含一个单独的初始化函数,该函数完成构造工作。因此,程序员永远无法确定,当使用类 X
的对象时,他是在处理一个完整的 X
还是在尝试构造一个 X
的失败尝试(或者更糟的是:有人忘记调用初始化器!)。
3 异常安全性的契约基础
非泛型组件可以孤立地描述为异常安全,但由于它可以由客户端代码配置,因此泛型组件中的异常安全性通常取决于组件与其客户端之间的契约。例如,泛型组件的设计者可能要求在组件的析构函数中使用的操作不抛出任何异常。 2 泛型组件可能会反过来提供以下保证之一
- 基本保证:组件的不变性得到保留,并且没有资源泄漏。
- 强保证:操作要么已成功完成,要么已抛出异常,使程序状态与操作开始之前完全相同。
- 无抛出保证:操作不会抛出异常。
基本保证是异常安全性的一个简单最低标准,我们可以对所有组件都坚持这一点。它只是说,在出现异常后,该组件仍可按以前的方式使用。重要的是,不变性的保留允许销毁组件,可能作为堆栈展开的一部分。这种保证实际上比它最初看起来更有用。如果组件具有许多有效状态,在出现异常后,我们不知道组件处于什么状态;只知道状态是有效的。在这种情况下,恢复的选项有限:要么销毁,要么在进一步使用之前将组件重置为某个已知状态。考虑以下示例
template <class X> void print_random_sequence() { std::vector<X> v(10); // A vector of 10 items try { // Provides only the basic guarantee v.insert( v.begin(), X() ); } catch(...) {} // ignore any exceptions above // print the vector's contents std::cout "(" << v.size() << ") "; std::copy( v.begin(), v.end(), std::ostream_iterator<X>( std::cout, " " ) ); }
由于我们对 v 的唯一了解是在出现异常后它是有效的,因此该函数可以打印任何随机序列的 X
。 3 它是“安全”的,因为它不允许崩溃,但它的输出可能是不可预测的。
强保证提供了完整的“提交或回滚”语义。对于 C++ 标准容器而言,这意味着,例如,如果抛出异常,所有迭代器仍然有效。我们还知道,该容器与抛出异常之前的元素相同。如果失败则不会产生任何影响的事务具有明显的优势:在出现异常的情况下,程序状态简单且可预测。在 C++ 标准库中,几乎所有基于节点的容器列表、集、多集、映射和多映射上的操作都提供强保证。 4)。
无抛出保证是最强的,它表示操作保证不会抛出异常:它总是成功完成。这种保证对于大多数析构函数都是必要的,实际上 C++ 标准库组件的析构函数都保证不会抛出异常。正如我们将在后面看到的那样,无抛出保证由于其他原因而变得很重要。 5
4 法律纠纷
不可避免地,合约会变得更加复杂:可能存在一种等价交换的安排。C++ 标准库中的一些组件对任意类型参数提供一种保证,但为了换取客户类型不会抛出异常的额外承诺,它们会提供更强的保证。例如,标准容器操作 vector<T>::erase
对任何 T
都提供基本保证,但对于复制构造函数和复制赋值运算符不会抛出异常的类型,它提供无抛出保证。6
5 组件应指定哪种级别的异常安全性?
从客户端的角度来看,最强的安全级别是理想的。当然,对于许多操作来说,无抛出保证是不可能的,但强保证呢?例如,假设我们想要对 vector<T>::insert
进行原子操作。将元素插入到向量的中间需要将插入点之后的元素复制到后面的位置,以便为新元素腾出空间。如果复制元素失败,回滚操作将需要“撤销”之前的复制操作……这取决于再次复制。如果复制回去失败(很可能失败),我们将无法满足我们的保证。
一种可能的替代方案是重新定义 insert
,以便每次都在一块新的内存中构建新的数组内容,并且只有在构建成功后才销毁旧的内容。不幸的是,如果采用这种方法,将会产生一个非平凡的成本:插入到向量的末尾可能会导致以前只进行几次复制,现在会导致所有元素都被复制。基本保证是这种操作的“自然”安全级别,它可以在不违反其性能保证的情况下提供这种保证。事实上,库中的所有操作似乎都具有这种“自然”的安全级别。
由于性能要求已经是草案标准中已经确立的部分,并且由于性能是 STL 的主要目标,因此没有尝试指定比这些要求能够提供的更高的安全性。尽管并非所有库都提供强保证,但几乎任何对提供基本保证的标准容器的操作都可以使用上面描述的“制作新副本”策略来使其变为强保证。
template <class Container, class BasicOp> void MakeOperationStrong( Container& c, const BasicOp& op ) { Container tmp(c); // Copy c op(tmp); // Work on the copy c.swap(tmp); // Cannot fail7 }
这种技术可以折叠到一个包装类中,以创建提供更强保证(和不同的性能特征)的类似容器。8
6 我们应该尽可能地利用所有资源吗?
通过考虑特定的实现,我们可以希望发现一种自然的安全级别。利用它来为组件建立要求的危险在于实现可能受到限制。如果有人想出一个更有效的实现,我们想使用它,我们可能会发现它与我们的异常安全要求不兼容。人们可能会认为,在 STL 涵盖的数据结构和算法的探索成熟领域,这应该不会引起任何关注,但即使在那里,也在不断取得进展。一个很好的例子是最近的introsort 算法6,它在最坏情况下复杂性方面比成熟的quicksort 算法有了实质性的改进。
为了确定对标准组件的要求程度,我查看了一个典型的现实场景。所选择的测试用例是“复合容器”。这种容器由两个或多个标准容器组件构成,不仅是常见的需求,而且还是在大型系统中维护不变量的简单代表性案例。
// SearchableStack - A stack which can be efficiently searched // for any value. template <class T> class SearchableStack { public: void push(const T& t); // O(log n) void pop(); // O(log n) bool contains(const T& t) const; // O(log n) const T& top() const; // O(1) private: std::set<T> set_impl; std::list<std::set<T>::iterator> list_impl; };
其理念是,列表充当集合迭代器的堆栈:每个元素首先进入集合,然后将结果位置压入列表。不变式很简单:集合和列表应该始终具有相同数量的元素,并且集合的每个元素都应该由列表的元素引用。以下的 push 函数实现旨在在 set 和 list 提供的自然安全级别内提供强保证。
template <class T> // 1 void SearchableStack<T>::push(const T& t) // 2 { // 3 set<T>::iterator i = set_impl.insert(t); // 4 try // 5 { // 6 list_impl.push_back(i); // 7 } // 8 catch(...) // 9 { // 10 set_impl.erase(i); // 11 throw; // 12 } // 13 } // 14
我们的代码需要库提供什么?我们需要检查非 const 操作发生的行。
- 第 4 行:如果插入失败,但
set_impl
在此过程中被修改,则我们的不变式将被破坏。我们需要能够依靠set<T>::insert
提供的强保证。 - 第 7 行:同样,如果
push_back
失败,但list_impl
在此过程中被修改,则我们的不变式将被破坏,因此我们需要能够依靠 list<T>::insert 提供的强保证。 - 第 11 行:这里我们正在“回滚”第 4 行的插入操作。如果此操作失败,我们将无法恢复我们的不变式。我们绝对依赖
set<T>::erase
提供的无抛出保证。9 - 第 11 行:出于相同的原因,我们还依赖于能够将
i
传递给erase
函数:我们需要set<T>::iterator
的复制构造函数提供无抛出保证。
在标准化过程中,通过这种方式处理问题,我学到了很多东西。首先,为复合容器指定的保证取决于其组件的更强保证(第 11 行中的无抛出保证)。此外,我利用了所有自然的安全级别来实现这个简单的示例。最后,分析揭示了迭代器的要求,我在以前单独考虑操作时忽略了这个要求。结论是我们应该尽可能地提供自然的安全级别。速度更快但安全性更低的实现可以作为标准组件的扩展提供。10
7 异常安全性的自动化测试
作为标准化过程的一部分,我制作了一个 STL 的异常安全参考实现。错误处理代码在现实生活中很少经过严格测试,部分原因是很难导致错误条件发生。经常会看到错误处理代码在第一次执行时就崩溃……在一个发布的产品中!为了增强对实现按预期工作信心的信心,我设计了一个自动化测试套件,基于我同事 Matt Arnold 的一项详尽的技术。
测试程序从基础开始:强化和检测,特别是针对全局运算符 new
和 delete
。11创建了组件(容器和算法)的实例,并选择了类型参数以揭示尽可能多的潜在问题。例如,所有类型参数都给出一个指向堆分配内存的指针,因此泄漏包含的对象将被检测为内存泄漏。
最后,设计了一种方案,可以使操作在每个可能的故障点抛出异常。在每个允许抛出异常的客户端提供的操作开始时,都添加了一个对 ThisCanThrow
的调用。在测试的泛型操作可能抛出异常的任何地方,也必须添加一个对 ThisCanThrow
的调用,例如在全局运算符 new
中,为此提供了经检测的替换。
// Use this as a type parameter, e.g. vector<TestClass> struct TestClass { TestClass( int v = 0 ) : p( ThisCanThrow(), new int( v ) ) {} TestClass( const TestClass& rhs ) : p( ThisCanThrow(), new int( *rhs.p ) ) {} const TestClass& operator=( const TestClass& rhs ) { ThisCanThrow(); *p = *rhs.p; } bool operator==( const TestClass& rhs ) const { ThisCanThrow(); return *p == *rhs.p; } ...etc... ~TestClass() { delete p; } };
ThisCanThrow
只是将“抛出计数器”递减,如果计数器已达到零,则抛出一个异常。每个测试都采用一种形式,该形式在外部循环中将计数器从连续更高的值开始,并反复尝试完成正在测试的操作。结果是操作在执行路径上的每个连续步骤中抛出异常,这些步骤可能失败。例如,这里是一个测试强保证的函数的简化版本:12
extern int gThrowCounter; // The throw counter void ThisCanThrow() { if (gThrowCounter-- == 0) throw 0; } template <class Value, class Operation> void StrongCheck(const Value& v, const Operation& op) { bool succeeded = false; for (long nextThrowCount = 0; !succeeded; ++nextThrowCount) { Value duplicate = v; try { gThrowCounter = nextThrowCount; op( duplicate ); // Try the operation succeeded = true; } catch(...) // Catch all exceptions { bool unchanged = duplicate == v; // Test strong guarantee assert( unchanged ); } // Specialize as desired for each container type, to check // integrity. For example, size() == distance(begin(),end()) CheckInvariant(v); // Check any invariant } }
值得注意的是,这种测试对于泛型组件来说比非泛型组件更容易、侵入性更低,因为可以使用测试特定的类型参数,而无需修改正在测试的组件的源代码。此外,像上面的 StrongCheck
这样的泛型函数在对各种值和操作执行测试方面起到了重要作用。
8 进一步阅读
据我所知,目前只有两种关于 STL 异常安全性的描述。2 STL 的异常安全参考实现的原始规范是一个非正式规范,简单易懂(也非常冗长),并使用本文概述的基本和强保证区分。它明确禁止泄漏,并且在它做出的保证方面与最终的 C++ 标准存在实质性差异,尽管它们在很大程度上是相同的。我希望很快就能发布该文档的更新版本。
C++ 标准1 中对异常安全性的描述只稍微正式一点,但依赖于难以理解的“标准术语”以及偶尔出现的微妙的隐含关系网络。13 特别是,泄漏根本没有直接处理。它确实具有一个优势,即它是标准。
异常安全 STL 的原始参考实现 [5] 是对旧版 SGI STL 的改编,专为具有有限功能的 C++ 编译器设计。虽然它不是完整的 STL 实现,但代码可能更容易阅读,并且它说明了一种有用的基类技术,用于消除构造函数中的异常处理代码。完整的测试套件 [3] 用于验证参考实现已成功用于验证所有最近版本的 SGI STL,并且已适应测试另一个供应商的实现(该实现失败)。正如文档页面所述,它似乎也有能力发现隐藏的编译器错误,尤其是在优化器与异常处理代码交互的地方。
参考文献
- 国际 标准 ISO/IEC 14882,信息技术-程序设计语言-C++,文档编号 ISO/IEC 14882-1998,可从 http://webstore.ansi.org/ansidocstore/default.asp 获取。
- D. Abrahams,STLport 中的异常安全性,可在 http://www.stlport.org/doc/exception_safety.html 获取。
- D. Abrahams 和 B. Fomitchev,异常处理测试套件,可在 http://www.stlport.org/doc/eh_testsuite.html 获取。
- Tom Cargill,“异常处理:虚假的安全感”,C++ Report,1994 年 11 月至 12 月,也可在 http://www.awprofessional.com/content/images/020163371x/supplements/Exception_Handling_Article.html 获取。
- B. Fomitchev,改编的 SGI STL 1.0 版,包含 D. Abrahams 编写的异常处理代码,可在 http://www.stlport.org 获取。
- D. R. Musser,“内省排序和选择算法”,软件-实践与经验 27(8):983-993, 1997。
- Bjarne Stroustrup,C++ 的设计与演变。Addison Wesley,阅读,MA,1995 年,ISBN 0-201-54330-3,第 16.9.1 节。
脚注
1 在 Cargill 的案例中,对解决方案的最大阻碍可能是他自身选择的不幸组合:他为容器选择的接口与他对安全性的特定需求不兼容。通过更改其中任何一个,他可能会解决问题。
2 通常不建议从 C++ 中的析构函数中抛出异常,因为析构函数本身可能在由另一个异常引起的堆栈展开过程中被调用。如果允许第二个异常在析构函数之外传播,程序将立即终止。
3 当然,在实践中,此函数将成为一个非常糟糕的随机序列生成器!
4 值得注意的是,修改算法通常无法提供强保证:要回滚范围中已修改的元素,必须使用operator=
将其设置回其先前值,而operator=
本身可能会抛出异常。在 C++ 标准库中,此规则有一些例外,其回滚行为仅包含销毁:uninitialized_copy
、uninitialized_fill
和 uninitialized_fill_n
。
5 C++ 标准库的客户端提供的所有类型参数都要求它们从其析构函数中不抛出异常。作为回报,C++ 标准库的所有组件至少提供基本保证。
6 在 C++ 标准中可能已经对许多修改算法做出了类似的安排,但由于标准化过程的时间限制而从未考虑过。
7 Compare
对象在复制时可能会抛出异常的关联容器无法使用此技术,因为交换函数可能会失败。
8 这表明container_traits<>
模板(长期以来一直希望但尚未出现)的另一种潜在用途:自动容器选择以满足异常安全性约束。
9 可能会试图用try
/catch
块包围擦除操作以减少对set<T>
的要求以及在发生异常时出现的麻烦,但最终这只是在回避问题。首先,擦除只是失败了,在这种情况下,没有可行的替代方法可以产生必要的结果。其次,更一般地说,由于其类型参数的可变性,通用组件很少能确保任何替代方案都会成功。
10 STL 设计中的主要理念是,对所有用途都不必的功能应优先考虑效率,只要可以通过调整基本组件在需要时获得该功能即可。这偏离了这种理念,但要通过调整没有该保证的基本组件来获得甚至基本保证都将很困难或不可能。
11 关于如何强化内存子系统的精彩讨论可以在以下内容中找到:Steve Maguire,编写可靠代码,Microsoft Press,雷德蒙德,WA,1993 年,ISBN 1-55615-551-4。
12 请注意,此技术要求正在测试的操作是异常中立的。如果操作尝试从异常中恢复并继续,则抛出计数器将为负数,并且随后可能失败的操作将不会针对异常安全性进行测试。
13 对草案标准引入异常安全的更改是在过程的后期进行的,当时修正案很可能仅因更改的单词数量而被拒绝。不幸的是,结果在一定程度上以简洁为代价,牺牲了清晰度。Greg Colvin 负责巧妙的语言律师工作,以最大限度地减少这些更改的范围。