Boost C++ 库

……世界上最受推崇和设计精良的 C++ 库项目之一。 Herb SutterAndrei Alexandrescu,《C++ 编码规范

泛型组件中的异常安全性

从为 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 
} 

对于许多更熟悉异常的程序员来说,第二个示例实际上比第一个示例更易读易懂。“隐藏”代码路径包括对局部变量的析构函数的相同调用。此外,它们遵循一个简单的模式,其作用完全如同在发生异常的情况下,每个函数调用后都有一个潜在的 return 语句。可读性得到了增强,因为正常的执行路径没有被错误处理所掩盖,并且返回值可以自然地使用。

异常可以增强正确性的另一种更重要的方式是:允许简单的类不变式。在第一个示例中,如果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; 
}; 

其思想是列表充当集合迭代器的堆栈:每个元素首先进入集合,然后将结果位置压入列表。不变式很简单:集合和列表应该始终具有相同数量的元素,并且集合的每个元素都应该由列表的元素引用。推送函数的以下实现旨在在集合和列表提供的自然安全级别内提供保证。

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 

我们的代码需要库做什么?我们需要检查发生非常量操作的行。

  • 第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的穷举技术。

测试程序从基础开始:强化和检测,特别是全局运算符newdelete11创建组件(容器和算法)的实例,并选择类型参数以揭示尽可能多的潜在问题。例如,所有类型参数都指向堆分配的内存,以便将泄漏的包含对象检测为内存泄漏。

最后,设计了一种方案,可以在每个可能的故障点导致操作抛出异常。在每个允许抛出异常的客户端提供的操作的开头,都添加了对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异常安全性的描述。STL参考异常安全实现的原始规范[2]是一个非正式规范,简单易懂(也冗长),并使用了本文中概述的基本保证的区别。它明确禁止内存泄漏,并且在它做出的保证方面与最终的C++标准存在实质性差异,尽管它们在很大程度上是相同的。我希望很快能提供这份文档的更新版本。

C++标准中对异常安全性的描述[1]只是稍微更正式一些,但是依赖于难以阅读的“标准术语”和偶尔微妙的暗示网络。13 特别是,内存泄漏根本没有直接处理。它的优势在于它是标准。

异常安全STL的原始参考实现[5] 是对旧版本SGI STL的改编,专为功能有限的C++编译器设计。虽然它不是完整的STL实现,但代码可能更容易阅读,并且它演示了一种有用的基类技术,用于消除构造函数中的异常处理代码。用于验证参考实现的完整测试套件[3] 已成功用于验证所有最新版本的SGI STL,并已改编用于测试另一个厂商的实现(该实现失败了)。正如文档页面上所述,它似乎也有能力揭示隐藏的编译器错误,尤其是在优化器与异常处理代码交互的地方。

参考文献

  1. 国际标准 ISO/IEC 14882,《信息技术-程序设计语言-C++》,文件编号 ISO/IEC 14882-1998,可从http://webstore.ansi.org/ansidocstore/default.asp获取。
  2. D. Abrahams,《STLport中的异常安全性》,可从http://www.stlport.org/doc/exception_safety.html获取。
  3. D. Abrahams 和 B. Fomitchev,《异常处理测试套件》,可从http://www.stlport.org/doc/eh_testsuite.html获取。
  4. Tom Cargill,“异常处理:虚假的安全感”,C++ Report,1994年11-12月,也可从 http://www.awprofessional.com/content/images/020163371x/supplements/Exception_Handling_Article.html获取。
  5. B. Fomitchev,《改编的SGI STL 1.0版》,D. Abrahams编写了异常处理代码,可从http://www.stlport.org获取。
  6. D. R. Musser,“内省排序和选择算法”,《软件-实践与经验》27(8):983-993, 1997。
  7. Bjarne Stroustrup,《C++的设计与演变》。Addison Wesley,Reading,MA,1995,ISBN 0-201-54330-3,第16.9.1节。

脚注

1 在Cargill的案例中,对解决方案最大的阻碍可能是他自身选择的不幸组合:他为容器选择的接口与他对安全性的特定要求不兼容。通过更改其中任何一个,他可能已经解决了这个问题。

2 在C++中,通常不建议从析构函数中抛出异常,因为析构函数本身可能在由另一个异常引起的堆栈展开期间被调用。如果允许第二个异常传播到析构函数之外,程序将立即终止。

3 当然,在实践中,这个函数将是一个极其糟糕的随机数生成器!

4 值得注意的是,变异算法通常无法提供保证:要回滚范围中修改的元素,必须使用operator=将其设置回其先前值,而operator=本身也可能抛出异常。在C++标准库中,此规则有一些例外,其回滚行为仅包括销毁:uninitialized_copyuninitialized_filluninitialized_fill_n

5 C++标准库的客户端提供的所有类型参数都要求其析构函数不抛出异常。作为回报,C++标准库的所有组件至少提供基本保证。

6 在C++标准中,可能对许多变异算法做了类似的安排,但由于标准化过程的时间限制而从未考虑过。

7 如果关联容器的Compare对象在复制时可能抛出异常,则无法使用此技术,因为交换函数可能会失败。

8 这表明了人们经常希望但尚未见到的container_traits<>模板的另一个潜在用途:自动选择容器以满足异常安全约束。

9 人们可能会试图用try/catch块包围erase操作,以降低对set<T>的要求以及在发生异常时出现的问题,但最终这只是回避了问题。首先,erase 刚刚失败,在这种情况下,没有可行的替代方法来产生必要的结果。其次,更普遍地说,由于其类型参数的可变性,泛型组件很少能保证任何替代方案都会成功。

10 STL设计的普遍理念是,对所有用途都不必要的函数应该为了效率而被省略,只要该函数可以在需要时通过调整基本组件来获得。这偏离了该理念,但是通过调整还没有该保证的基本组件,即使是获得基本保证也是困难或不可能的。

11 关于如何加强内存子系统的精彩讨论可以在以下文献中找到:Steve Maguire,《编写可靠的代码》,Microsoft Press,Redmond,WA,1993,ISBN 1-55615-551-4。

12 请注意,此技术要求被测试的操作是异常中性的。如果操作试图从异常中恢复并继续执行,则抛出计数器将为负数,并且随后可能失败的操作将不会测试其异常安全性。

13 引入异常安全性的草案标准的更改是在流程后期进行的,那时修正案很可能仅仅根据更改的字数而被拒绝。不幸的是,结果在某种程度上以简洁性为代价损害了清晰度。Greg Colvin负责巧妙的语言技巧,以最大限度地减少这些更改的范围。