促进 C++库

……其中一个世界。 -赫伯·萨特安德烈亚历山德雷斯库,C类++编码标准

例外——通用部件中的安全

从指定中吸取的教训例外-C++标准库的安全

大卫·亚伯拉罕斯

dave@boostpro.com

摘要。本文代表了为满足现实需要而积累的知识:C++标准模板库展示了有用的定义明确的与异常的交互,错误处理内置于核心C++语言中的机制。它探索了例外安全的含义,揭示了关于异常和泛型描述了有价值的工具关于程序正确性的推理,并概述了一个自动化的验证例外安全性的测试程序。

关键词:异常安全、异常,STL、C++

1什么是例外安全?

非正式地,部件的例外安全意味着在期间引发异常时表现出合理的行为其执行。对于大多数人来说“合理”包括所有通常的期望错误处理:资源不应泄漏,以及程序应保持在定义良好的状态,以便可以继续执行。对于大多数组件,它还包括当遇到错误时向来电者报告。

更正式地说,我们可以将组件描述为exception-safe如果从该异常中抛出异常组件,它的不变量是完整的。稍后,我们会看到至少可以有三种不同的例外安全级别有用的区分。这些区别可以帮助我们描述和推理大型系统的行为。

在通用组件中,我们通常有一个额外的对…的期望例外中立,这意味着组件的类型参数引发的异常应该是未更改地传播到组件的调用方。

2神话和迷信

到目前为止,例外安全似乎很简单:它不是使用更多的代码,所构成的内容超出了我们的预期传统的错误处理技术。这可能是值得的,然而,从心理学的角度来研究这个词。在C++之前,没有人说过“错误安全”例外情况。

似乎异常被视为神秘袭击基于其他正确的代码,其中我们必须保护自己。不用说,这不会导致通过错误处理建立健康的关系!期间标准化,一个需要广泛参与的民主进程支持更改,我遇到了许多广泛持有的迷信。甚至开始讨论通用组件的异常安全性,这可能是值得的面对其中一些人。

“模板和异常之间的交互是没有被很好地理解。”这个神话,经常从那些认为这些都是新的语言特征的人,很容易处理:根本没有交互。模板,一次实例化,在各个方面都像普通类或功能。推理有例外的模板是考虑该模板的实例化有效。最后,通用性不应引起特别关注。虽然组件的客户端提供部分操作(可能,除非另有规定,否则抛出任意异常)使用熟悉的虚拟函数或简单函数指针。

“众所周知,不可能编写例外安全通用容器。”此索赔是经常听到关于汤姆·卡吉尔的一篇文章[4]他在其中探索了通用堆栈模板的异常安全问题。卡吉尔的文章提出了许多有用的问题,但不幸的是,未能提出解决方案问题。1他最后建议解决方案可能是不可能的。不幸的是,他的文章被许多人解读为这种推测的“证据”。自从它出版以来,已经有很多这样的例子例外安全通用组件,其中包括C++标准库容器。

“处理异常会降低代码速度模板是专门用来获得最佳效果的性能。”好的C++实现不会用一个指令周期处理异常直到抛出一个,然后才能快速处理与调用函数相比[7].仅此一项即可使用异常的程序性能与忽略错误可能性的程序。使用异常可以导致比其他“传统”错误处理方法原因。首先,catch块向编译器指示哪个代码用于错误处理;然后就可以了与通常的执行路径分离,改进了参考。第二,使用“传统”错误的代码处理通常必须在以下情况下测试返回值是否有错误每个函数调用;完全使用异常消除了这种开销。

“例外情况使推理变得更加困难程序的行为。”通常引用以支持这个神话就是“隐藏的”执行路径在堆料放卷过程中跟随。隐藏的执行路径是对于任何需要局部变量的C++程序员来说都不是什么新鲜事从函数返回时被销毁:

错误代码f(int和result)//1{                                  // 2 X X;//错误代码错误=x.g(结果);//4if(err!=kNoError)//5返回err;//6// ...此处有更多代码。。。return kNoError;//7}

在上面的示例中,有一个“隐藏”调用X: :#X()在第6行和第7行中。授予,使用异常,没有专门用于错误处理的代码可见:

整数f()//1{                       // 2 X X;//int结果=x.g();//4// ...此处有更多代码。。。返回结果;//5}

对于许多更熟悉异常的程序员来说第二个例子实际上更易读和理解比第一次好。“隐藏的”代码路径包括对局部变量析构函数的相同调用。此外,他们遵循简单的行为模式确切地好像每个函数后面都有一个潜在的返回语句发生异常时调用。可读性增强是因为错误处理不会影响正常的执行路径,并释放返回值以供自然使用。

有一种更重要的方式可以让异常增强正确性:通过允许简单的类不变量。第一个示例,如果x个的构造函数应该需要要分配资源,它无法报告故障:在C++,构造函数没有返回值。通常的结果是例外是需要资源的类必须包括一个单独的初始化器函数,用于完成作业建筑工程。因此,程序员永远无法确定,当类的对象X(X)无论他是谁处理一个成熟的X(X)或者一些失败的尝试构造一个(或者更糟的是:有人只是忘记调用初始值设定项!)

3例外安全的合同基础

非通用组件可以描述为例外安全是孤立的,但因为它可以由客户端进行配置代码、异常—通用组件中的安全性通常取决于组件与其客户端之间的合同。对于例如,通用组件的设计者可能需要在组件的析构函数中使用的操作抛出任何异常。2通用组件可能,作为回报,提供以下保证之一:

  • 这个基本的保证:组件被保留,并且没有泄漏任何资源。
  • 这个坚强的保证:操作成功完成或引发异常,离开程序状态与操作前完全相同起动。
  • 这个不抛售保证:手术将不抛出异常。

基本保证是一个简单的最低标准例外情况—我们可以保存所有组件的安全性。上面写着简单地说,在发生异常后,组件仍然可以使用和以前一样。重要的是,保持不变量允许要销毁的组件,可能是堆料放卷。这种保证实际上不如它有用可能首先出现。如果组件具有许多有效状态,发生异常后,我们不知道组件的状态是什么中;只有状态是有效的。中的恢复选项这种情况是有限的:要么销毁要么重置组件设置为某些已知状态,然后再继续使用。考虑一下以下示例:

模板<X类>无效打印随机序列(){ 标准::矢量<X>v(10);//10项向量尝试{//仅提供基本的担保v.insert(v.begin(),X());} catch(…){}//忽略上面的任何异常//打印矢量的内容标准::cout“(”<<v.size()<<“)”;标准::复制(v.begin(),v.end(),标准::ostream_iterator<X>(标准::cout,“”);}

因为我们所知道的v在异常之后是有效,该函数可以打印X(X)第条。它在中“安全”不允许崩溃的感觉,但其输出可能不可预测。

这个坚强的保证提供全部“commit-or-rollback”语义。在C的情况下++标准容器,这意味着,例如,如果抛出异常-所有迭代器都保持有效。我们也知道容器具有与之前相同的元素引发了异常。如果交易无效失败有明显的好处:程序状态简单出现异常时可预测。在C++标准中库,几乎所有基于节点的操作容器列表、集合、多集合、映射和多映射提供坚强的担保。4).

这个不抛售保证是最有力的,并且它表示操作保证不会抛出异常:它总是成功完成。此担保是对于大多数析构函数都是必需的,实际上C++标准库组件都保证不会抛出例外情况。这个不抛售担保结果是我们将看到,由于其他原因,这一点很重要。5

4法律纠纷

不可避免地,合同会变得更加复杂:维持现状的安排是可能的。C中的一些组件++标准库为任意类型提供了一个保证参数,但提供更有力的保证以换取来自客户端类型的附加承诺将被抛出。例如,标准容器操作向量<T>::擦除给出基本的任何担保T型,但对于其副本的类型构造函数和复制赋值运算符不会引发,它会给出这个禁止投掷担保。6

5组件的例外安全级别指定?

从客户的角度来看安全性最好。当然禁止投掷对于许多操作来说,保证是完全不可能的,但是关于坚强的保证?例如,假设我们想要的原子行为向量<T>::插入.插入中间需要在插入点后复制元素移到后面的位置,为新元素腾出空间。如果复制元素可能会失败,回滚操作会需要“撤消”以前的副本。。。哪一个取决于再次复制。如果复制失败(因为很可能会),我们没有达到我们的保证。

一个可能的选择是重新定义插入在新的每次都会占用一段内存,并且只会销毁旧内容当它成功的时候。不幸的是,有一个非平凡的如果采用此方法,则会产生成本:在以前可能只产生了几个副本的向量现在将复制每个元素。这个基本的保证是安全的“自然”水平操作,它可以在不违反其性能保证。事实上图书馆似乎有这样一个“自然”的水平安全。

因为性能要求已经是标准草案中已确立的部分,因为性能是STL的主要目标,没有尝试指定比这些范围内所能提供的更多的安全性要求。虽然不是所有的库都提供坚强的保证,几乎所有标准操作提供基本的可以做出保证坚强的使用“制作新副本”策略如上所述:

模板<class Container,class BasicOp>void MakeOperationStrong(容器和容器,常量基本操作和操作){ 容器tmp(c);//副本cop(tmp);//处理副本c.掉期(tmp);//不能失败7}

可以将此技术折叠到包装器类中,使提供更强保证的类似容器(和不同的性能特征)。8

我们应该拿走我们能得到的一切吗?

通过考虑特定的实施,我们可以希望辨别出自然的安全等级。使用此选项的危险为组件建立需求是实现可能会受到限制。如果有人来通过我们希望使用的更高效的实现,我们可能会发现这与我们的例外安全不相容要求。人们可能会认为这与涵盖了数据结构和算法的丰富领域尽管如此,STL仍在取得进展。A很好最近的例子内向分类算法[6],表示根深蒂固的快速排序.

准确确定标准的需求量组件,我查看了一个典型的现实世界场景选择的测试用例是“复合容器”由两个或多个标准容器构成的容器组件,不仅是通常需要的,而且是一个简单的保持较大不变量的典型案例系统:

//SearchableStack—可以有效搜索的堆栈//对于任何值。模板<T类>类SearchableStack{ 公众:void push(常数T&T);//O(对数n)void pop();//O(对数n)布尔包含(const T&T)const;//O(对数n)const T&top()const;//O(1)私人:标准::set<T>set_impl;标准::列表<std::集合<T>::迭代器>list_impl;};

其思想是该列表充当集合迭代器的堆栈:每个元素首先进入集合,然后生成位置被推到列表上。不变量是简单明了:集合和列表应该始终具有相同数量的元素,集合中的每个元素都应该由列表的元素引用。以下内容push函数的实现旨在提供坚强的保证在自然安全水平内由集合和列表提供:

模板<类别T>//1void SearchableStack::push(const T&T)//2{                                                       // 3 集合::迭代器i=set_impl.insert(T);//4尝试//5{                                                   // 6 list_impl.push_back(i);//7}//8捕捉(…)//9{                                                   // 10 set_impl.erase(i);//11抛出;//12}                                                   // 13 }                                                       // 14

我们的代码对库有什么要求?我们需要检查发生非接触操作的线路:

  • 第4行:如果插入失败,但设置impl在这个过程中被修改,我们的不变量就被破坏了。我们需要能够依赖坚强的来自的担保集合<T>::插入.
  • 第7行:同样,如果后推(_B)失败,但列表_ impl在过程中被修改,我们的不变量被破坏了,所以我们需要能够依赖坚强的列表中的担保::插入。
  • 第11行:我们正在“回滚”在第4行插入。如果这次行动失败,我们将无法恢复我们的不变量。我们完全依赖这个不抛售担保人设置<T>::擦除.9
  • 第11行:出于同样的原因,我们也依赖于能够通过擦除功能:我们需要不抛售副本担保建造师集合<T>::迭代器.

通过这样处理这个问题,我学到了很多在标准化过程中。首先,为复合容器依赖于更强的保证从其组件(不抛售符合要求的担保11). 此外,我还利用了实现这个简单示例的安全性。最后,分析揭示了我之前对迭代器的要求在单独考虑操作时被忽略。这个结论是我们应该提供尽可能多的天然安全等级。更快但更不安全实现始终可以作为标准组件。10

7异常安全自动测试

作为标准化过程的一部分,我制作了一个STL的例外安全参考实现。错误处理代码很少在现实生活中经过严格测试,部分原因是很难导致错误条件发生。常见的错误处理代码是第一次执行时崩溃。。。在运输产品中!增强实施有效的信心正如宣传的那样,我设计了一个基于我的同事马特·阿诺德提供了详尽的技术。

测试程序从基础开始:加固和仪器,尤其是全球运营商的仪器新的删除.11组件的实例(容器和算法)已创建,带有类型参数被选择来揭示尽可能多的潜在问题。对于例如,所有类型参数都被赋予指向堆分配内存,以便泄漏包含的对象被检测为内存泄漏。

最后,设计了一个可能导致操作的方案在每个可能的故障点抛出一个异常。开始允许的每个客户端提供的操作抛出异常,调用ThisCanThrow游戏补充。打电话给ThisCanThrow游戏还必须添加正在测试的泛型操作可能抛出的所有位置异常,例如在全局运算符中新的,其中仪表替换件为提供。

//将其用作类型参数,例如vector<TestClass>结构测试类{ 测试类(int v=0):p(ThisCanThrow(),新int(v)){}测试类(const TestClass&rhs):p(ThisCanThrow(),新int(*rhs.p)){}const TestClass&operator=(const TestClass&rhs){ThisCanThrow();*p=*rhs.p;}bool运算符==(const TestClass&rhs)const{ThisCanThrow();返回*p==*rhs.p;}……等等。。。~TestClass(){删除p;}};

ThisCanThrow游戏简单地减少“掷”计数器”,如果它已达到零,则抛出一个例外。每个测试都采用一种形式,计数器从在外循环中依次增大值并重复尝试完成正在测试的操作。结果是操作在每个后续步骤中引发异常可能会失败的执行路径。例如,这是用于测试坚强的保证:12

extern int gThrowCounter;//投掷计数器void ThisCanThrow(){ 如果(gThrowCounter--==0)抛出0;} 模板<类值,类操作>void StrongCheck(常量值&v,常量操作&op){ bool-succeeded=false;for(long nextThrowCount=0;!succeeded;++nextthrowCourt){ 值重复=v;尝试{ gThrowCounter=下一个ThrowCount;op(重复);//尝试该操作成功=true;} catch(…)//捕获所有异常{ bool unchanged=duplicate==v;//测试坚强的担保断言(不变);} //根据需要专门针对每种容器类型进行检查//完整性。例如,size()==距离(begin(),end())检查不变量(v);//检查任何不变量} }

值得注意的是,这种测试更容易,也更少与非泛型组件相比,泛型组件具有侵入性,因为测试特定的类型参数可以在没有修改被测试组件的源代码。也,通用函数,如StrongCheck(强检查)以上是有助于对各种数值进行测试和操作。

8进一步阅读

据我所知,目前只有两种描述STL异常安全可用。原始规范[2]供参考STL的例外安全实施是非正式的规范,简单且自我解释(也冗长),以及使用基本-强有力的-保证区别在本文中概述。它明确禁止泄漏,并且与最终的C++标准在虽然它们基本上是相同的,但它保证了这一点。我希望如此以尽快生成此文档的更新版本。

C++标准中对异常安全的描述[1]只是稍微多一点正式,但依赖于难以阅读的“标准”和偶尔微妙的暗示网络。13特别是,泄漏是根本没有直接治疗。它的优点是标准。

原始参考实现[5]例外安全STL是对旧版本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。亚伯拉罕,STLport中的异常安全,可在http://www.stlport.org/doc/exception_safety.html.
  3. D。亚伯拉罕和B.福米切夫,异常处理测试套件,可用http://www.stlport.org/doc/eh_testsuite.html.
  4. 汤姆嘉吉公司,“异常处理:错误的安全感,”C++报告,1994年11月至12月,另请访问http://www.awprofessional.com/content/images/020163371x/supplements/Exception_Handling_Article.html.
  5. B。福米切夫,改编的SGI STL版本1.0,具有异常处理D.Abrahams编写的代码,可在网址:http://www.stlport.org.
  6. D。R.穆瑟,“内省排序和选择算法,”软件-实践和经验27(8):983-993,1997
  7. 比亚内斯特劳斯特鲁普,C语言的设计和发展++.艾迪生韦斯利,雷丁,马萨诸塞州,1995年,ISBN 0-201-54330-3,章节16.9.1。

脚注

1可能是卡吉尔案件中解决方案的最大障碍是他不幸的选择组合:界面他选择的集装箱与他的特点不符安全要求。通过改变其中一个,他可能已经解决了这个问题。

2通常情况下从C++中的析构函数抛出异常是不可取的,因为析构函数本身可以在另一个异常导致的堆叠展开。如果第二个异常被允许传播到析构函数之外程序立即终止。

在实践中当然,这个函数会产生一个非常糟糕的随机性序列生成器!

4值得一提变异算法通常不能提供坚强的保证:回滚范围,必须使用将其设置回其以前的值操作员=,它本身可能会抛出。在C中++标准库,此规则有一些例外,其回滚行为仅包含销毁:未初始化副本,未初始化填充、和未初始化的填充n.

5所有类型参数需要C++标准库的客户端提供不要从他们的破坏者那里扔。作为回报,所有组件C++标准库的基本的担保。

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

7关联容器,其比较物体可能会抛出复制时的异常不能使用此技术,因为交换函数可能会失败。

8这表明惯用但尚未被发现的另一种潜在用途container_traits<>模板:自动集装箱选择以满足例外安全限制。

9一个可能是试图用一个尝试/抓住阻止以减少要求设置<T>以及存在的问题在出现异常的情况下出现,但最终只是乞求这个问题。首先,擦除失败,在这种情况下没有可行的替代方法来产生必要的结果。其次,更普遍地说,由于其可变性类型参数泛型组件很少能保证任何替代方案都会成功。

10流行的STL的设计理念是不是所有用途都必需的效率,只要能够获得该功能当需要时,通过调整基础组件。这与这种哲学,但要做到甚至获得基本的通过调整基础来保证还没有的组件。

11一个优秀的关于如何增强内存子系统的讨论,请参阅:Steve Maguire,《编写实体代码》,微软出版社,雷蒙德,华盛顿州,1993年,ISBN 1-55615-551-4。

12请注意技术要求被测试的操作例外-中性。如果操作试图从异常并继续,抛出计数器将为负数,并且不会测试可能失败的后续操作为了例外安全。

13对的更改制定了引入例外安全的标准草案在进程后期,修正案可能被拒绝仅仅基于修改的单词的数量。不幸的是,结果在一定程度上损害了清晰度简洁。格雷格·科尔文负责语言律师需要尽量减少这些变化。