Boost C++ 库

...世界上最受尊敬和设计最精湛的 C++ 库项目之一。 Herb SutterAndrei Alexandrescu, C++ 编码标准

泛型组件中的异常安全性

从为 C++ 标准库指定异常安全性中吸取的教训

David Abrahams

dave@boostpro.com

摘要。 本文代表了为响应现实需求而积累的知识: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 还是尝试中止构造一个对象(或者更糟:有人只是忘记调用初始化程序!)

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++ 标准库中,几乎所有基于节点的容器 list、set、multiset、map 和 multimap 上的操作都提供保证。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 涵盖的成熟的数据结构和算法领域中无关紧要,但即使在那里,也在不断取得进展。一个很好的例子是最近的 内省排序 算法 [6],它代表了对已确立的 快速排序 在最坏情况下的复杂度的重大改进。

为了准确确定对标准组件的要求,我查看了一个典型的现实场景。选择的测试用例是“复合容器”。这种由两个或多个标准容器组件构建的容器不仅是常见的需求,而且可以作为在更大的系统中维护不变性的简单代表性案例。

// 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; 
}; 

这个想法是列表充当 set 迭代器的堆栈:每个元素首先进入 set,并将结果位置推送到列表上。不变性很简单:set 和列表应始终具有相同数量的元素,并且 set 的每个元素都应被列表的元素引用。以下 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 

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

  • 第 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++ 报告, 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_copy, uninitialized_fill, 和 uninitialized_fill_n

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

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

7Compare 对象在复制时可能抛出异常的关联容器不能使用此技术,因为 swap 函数可能会失败。

8 这提出了对经常被期望但尚未见到的 container_traits<> 模板的另一种潜在用途:自动化容器选择以满足异常安全约束。

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

10 STL 设计中流行的理念是,只要可以通过调整基本组件在需要时获得该功能,就应排除并非所有用途都必不可少的功能,以提高效率。这偏离了该理念,但通过调整不具有基本保证的基本组件,即使获得基本保证也很困难或不可能。

11 有关如何加强内存子系统的精彩讨论可以在以下书中找到:Steve Maguire, Writing Solid Code, Microsoft Press, Redmond, WA, 1993, ISBN 1-55615- 551-4。

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

13 引入异常安全性的标准草案的更改是在过程后期进行的,当时修正案很可能仅基于更改的单词数量而被拒绝。不幸的是,结果是以牺牲清晰度为代价来换取简洁性。Greg Colvin 负责巧妙的语言法律手段,以最大限度地减少这些更改的范围。