专题:C++中的异常处理

一篇原来发在网易博客的文章

怎样才能成为专家?在我涉足过的所有领域,答案都一样:

1. 掌握基础知识。

2. 将相同的内容在学习一遍,但这一次,请将你的注意力集中在细节上

----这些细节的重要性,你头一次可能并没有认识到。

Herb   Sutter


理解处理异常的意义,异常出现的几种主要情形,异常处理的一般做法,几种表现形式和相关的规范,和如何自定义异常,以及异常对效率的影响。

      以前,我一听到“异常安全”这几个字,就想到了异常“免疫”,以为这是要设计不抛出异常的程序,但是后来才知道不是那么回事儿,正确的理解应该是:即使程序发生异常(甚至继而引起程序退出,崩溃),也要保证资源安全。不过这时候的资源安全就有两种,允许部分资源状态改变和所谓的commit and rollback;当然最好的异常安全就是没有异常(那就很安全了)。在继续往下说之前,我觉得有几点概念还是要说清楚的,内置类型的赋值,拷贝是不会发生异常的;异常一般发生在因资源不足申请不到资源的时候。

      我们知道C中是没有异常这个概念的。那么我们最好先弄清楚为什么要引入异常机制,引入之后解决什么问题,解决问题的方式是什么,规范是什么,有什么技巧,最终的效果如何。

      先看看异常出现的原因,这里引用[B.3]中的话:

...一个库的作者可以检查出运行时错误,但一般说却不知道怎样去处理他们。库的用户知道怎样对付这些错误,但却又无法去检查它们--要不然这些错误就会在用户的代码里处理了,不会留给库去发现。提供异常的概念就是为了有助于处理这类问题。这里的基本想法是,让一个函数在发现自己无法处理的错误时抛出一个异常,希望它的(直接或者间接)调用者能够处理这个问题希望处理这类问题的函数可以表明它将要捕捉这个异常。

      我们可以从这段话中知道,异常这个概念出现的原因,不过异常的应用场景可就不止这些了!异常可以连接出现异常和处理异常的两方的代码,让发现异常问题的代码把问题告诉处理问题的代码,这样就可以增强代码的健壮性,而不至于一碰到异常就退出程序;或者隐藏问题不报以致于出现处理逻辑上,数据上的问题;再或者设置全局的errno,但是一般又没什么人去检测这些错误信息。

      除了异常安全,我在读《Exceptional C++》的时候,还看到另一个概念:异常中立。大概的意思应该是捕捉到异常之后,直接将异常抛出,不隐瞒异常,也不处理异常。这个意思就是我发现异常了,但是这个异常我不处理,直接把异常向外抛,那些个调用者们谁爱处理处理。这个时候异常处理并不是为了保证代码安全,而是一种报告错误地机制而已。

      如果我们用异常捕捉和异常处理模块的话,一般的形式有两种:try-block和function-try-block。下面内容摘自[B.2] 15章:

try-block:

      try compound-statement handler-seq

function-try-block:

      try ctor-initializeropt compound-statement handler-seq 

handler-seq:

      handler handler-seqopt 

 

      可以看出,try-block是用于普通的异常捕捉,而function-try-block是用于构造函数的初值化列表。不过我们很少见过后者:

C::C()

try

    :A(/*...*/)//代表基类

    ,b_(/*...*/){//代表成员

    //......构造函数体

}catch(...){

   //......异常处理

}

      但是这种用法几乎没什么用,[B.2] 15.3.15中说:

15. The currently handled exception is rethrown if control reaches the end of a handler of the function-try-block of a constructor or destructor. Otherwise, a function returns when control reaches the end of a handler for the function-try-block (6.6.3). Flowing off the end of a function-try-block is equivalent to a return with no value; this results in undefined behavior in a value-returning function (6.6.3).

      同时《More Exceptional C++》中条款18中也提到:

...如果处理程序没有以抛出异常的方式退出(既没有重新抛出最初的异常,也没有抛出什么新的东西),那么,在控制抵达构造函数或者析构函数的catch block的末尾时,最初的异常也会被自动地重新抛出,就像处理程序的最后一个语句是“throw”一样。

      所以,最终是我们无法在构造函数和析构函数中隐藏异常不报让构造函数继续运行的。在构造函数的function-try-block中我们唯一可以做的就是转化基类或成员子对象抛出的异常;而析构函数是不应该抛出异常的。

      从这里我们可以看得出一个事实,就是编译器的运行规则是我们可以利用以达到异常安全的唯一依赖。所以,还是得研读C++标准,"以"臻化境。

      如果异常是发生在构造函数中,那么这个对象就不会被构造出来,同时也就不会有对应的析构函数被调用。所以,如果在构造函数中发生了无法释放的资源的话,以后就没有机会释放内存了,我们要想尽办法把发生异常的构造函数中的资源泄露处理好,后面会介绍几种防止泄露的方法。

      前面说明了一下C++引入异常的原因,以及捕获异常的类型,那么下面看一下捕获到异常之后的处理模块。

handler:

      catch ( exception-declaration ) compound-statement 

exception-declaration:

      attribute-specifier-seqopt type-specifier-seq declarator 

      attribute-specifier-seqopt type-specifier-seq abstract-declaratoropt 

      ... 

      关于处理异常的异常对象声明要注意下面的内容。接收异常对象的数据类型可以有值传递,指针和引用。如果是使用值传递的(注意这样会发生两次拷贝),那么要防止出现异常对象的切割现象(书面语“切片”)的发生。如果要使用指针或者引用类型的话,一定要保证这个数据在进入catch模块中时还是有效的,否则会发生错误;如果在堆上创建了这个抛出的异常对象,那么要在catch中释放资源,不过即使这样,也不符合C++的标准异常(bad_alloc,bad_cast……)数据是对象而不是指向对象的指针这一规范,所以最好不要使用指针的方式。最后也是最好的方式就是使用引用的方式,并且也可以减少对象拷贝的次数。还有一点要注意的是在catch中的异常声明要和throw出来的数据类型严格匹配,不允许发生隐式转换(比如char到int的转换之类的)。

      最后是主动(重新)抛出异常的模块。

throw-expression: 

      throw assignment-expressionopt

      C++11之前,对于throw还有一种用法,就是在函数参数列表后面跟上一个可能抛出的异常列表,但是C++11中不推荐这种做法,代之以干净简洁的noexcept。所以不推荐使用throw(异常列表)的方法来声明函数,不过throw()的用法却可以用来声明此函数做了不抛出异常的承诺,new/delete的一种形式就使用了这种用法,用来兼容C运行时库。但事实是你还是可以抛出异常,毕竟承诺只是承诺……关于noexcept可以参考这篇文章。

      《C++编程剖析》第11、12、13节中还提到了throw声明对于函数原型的影响,以及声明了throw列表之后却抛出(或者不在声明列表中的)异常之后,程序的行为。当然结果是声明也没有用。因为如果这样的话,最终程序会将异常抛到最外层的unexpected exception处理函数中,这个函数也是直接terminate掉程序而已。所以C++中这个异常设施,可用性较差,只是能用而已;而且,异常列表的声明会让编译器生成一些类似try-catch的代码,进而导致性能的下降。所以,目前C++的异常规格在实用的时候就变成了:1. 不要为函数加上异常规格,2.空异常规格列表也可以省略。

      如果发生异常,但是没有捕获到该异常,那么程序会调用terminate()立即结束程序。

      下面看一下如何防止发生异常之后造成资源泄露,也就是异常安全的措施。同时也提前说明下,要达到异常安全并不一定要使用try-catch。(至此,就已经说明,异常安全不是“异常免疫”了,只能是异常之后不出现更大的损失)

      首先是(类、函数)设计,《Exceptional C++》中说道,“异常不安全”和“拙劣的设计”通常是密切相关的。一个很典型的例子就是stack的接口设计中pop()的实现。如果让pop()同时实现弹出对象和删除栈顶对象,那是不可能异常安全的。唯一的解决方案就是把这个操作分开为pop()和top()两者来实现,同时也体现了一个函数只完成一种操作的原则(SOLID原则之S)。同时也应该尽量减少不必要的继承,以减少基类的异常对子类异常的影响,当然了最好还要减少成员对象,因为没有对象就不会异常 :-)。

      处理好构造和析构若干函数

      构造函数中一般会发生成员变量初始化,基类初始化,这个叫做资源获取即初始化RAII,要保证基类异常安全,成员变量也要异常安全。做到这一点是不容易的,这里我们可以使用的工具有:使用临时对象(编译器会保证异常退出的时候释放临时对象的资源),使用智能指针(简化try-catch的代码,实现自动释放资源的功能),以及配合swap方法(参考《Effective C++》中的条款25)。当然这就要求这些对象的析构函数不能抛出异常。关于这一点在《More Effective C++》和《Exceptional C++》,《More Exceptional C++》中有相关的讲述。

      成对定义new/delete

      其实如果使用new操作符,但是忘记了delete,就程序员编程的角度来说,这不能算做异常,这应该是bug,或者错误,是程序员犯的错误;而异常是逻辑上,理论上没有错误,结果运行出现不受控制的情况。但是这里还要提一下,如果我们要定义自己的new/delete操作符的时候,要注意成对出现,否则很容易以为使用自定义的new,而后可以使用系统的标准delete就可以;而是事实,编译器会调用形式匹配的对应的delete,如果找不到,就没有delete被调用。关于这一点《Effective C++》中有叙述。

      理解对象的生命期

      对象的生命期的起始点应该是从构造函数成功执行完毕之后到析构函数开始之前。在这两个点以外的时间段中,如果位于构造函数成功之前,对象不存在,如果构造函数中发生异常,不会调用对象的析构函数,因为没有对象;如果位于析构函数中,对象析构到什么程序,我们无法得知,一些虚函数也不能调用。关于这一点可以参考《Effective C++》中的条款9。

      使用pimpl技术

      使用pimpl技术,不仅可以降低改变代码引起的编译时间,还可以为使用pimpl对象的类提供满足单一原则的成员初始化。为了加强代码的异常管理,可以使用智能指针来管理这个pimpl成员。不过这种办法不能消除异常,只是从结构上对异常有所改善。

      

      使用异常的try-throw-catch结构会增大程序体积和降低运行效率,因为编译器要生成维护这种结构的代码。

      总之,如果要想写异常安全的代码,你要了解编译器让你写的代码到底做了什么,以及用什么方法可以按照编译器可以处理的方式来达到异常安全。

 

      我之前说过异常安全的应用场景不止于防止资源泄露,起码我看到过使用throw异常来主动中断程序,跳到接收异常的地方,做消息循环的用法。所以我们应该从另一种角度理解异常,并使用这种机制做一些应用。

 

      在STL中有一些已经定义好的异常类,我们可以通过继承这些类得到自定义的异常;当然也可以自定义不继承这些类的类,异常类跟普通的类并无差别。

 

      最后得声明一下,尽管写了这些内容,我还是不太了解异常这家伙,始终感觉它就像是在代码之间游荡的幽灵……要控制这个幽灵是很困难的事情!

 

参考资料

Book

[B.1] Stanley B. Lippman,《C++ Primer 5th》中文版/英文版中18.1 Exception Handing。

[B.2] C++标准文档,ISO/IEC 14882,第15章,18.8。

[B.3] Bjarne Stroustrup,《C++程序设计语言(特别版)》第14章,附录E 标准库的异常时安全性;《The C++ Programming Language 4th Edition》第13章。

[B.4] Scott Meyers, 《Effective C++》条款8,29;《More Effective C++》第五章。

[B.5] Herb Sutter,《Exceptional C++》第二章 Exception-Safety Issues and Techniques;《More Exceptional C++》第三章 Exception-Safety Issues and Techniques。《C++编程剖析》第11、12、13、22、23条。

 

Doc

[D.1] Bjarne Stroustrup,noexcept -- preventing exception propagation

 

Wiki

[W.1] SOLID面向对象设计

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值