C++的异常机制使得程序付出某些代价:资源泄漏的可能性增加了;写出具有你希望的行为的构造函数与析构函数变得更加困难;执行程序和库程序尺寸增加了,同时运行速度降低了等等。
但是为什么使用异常呢?C程序使用错误代码(Error code)来判断异常状态,这种做法的问题是:异常可能被忽略,如果一个函数通过设置一个状态变量或返回错误代码来表示一个异常状态,没有办法保证函数调用者将一定检测变量或测试错误代码。
C程序能够仅通过setjmp和longjmp来完成与异常处理相似的功能。但是当longjmp在C++中使用时,它存在一些缺陷,当异常栈展开时不能对局部对象调用析构函数。所以setjmp和longjmp不能够替换异常处理。如果你需要一个方法,能够通知不可被忽略的异常状态,并且搜索栈空间(searching the stack)以便找到异常处理代码时,还需要确保局部对象的析构函数必须被调用,这时你就需要使用C++的异常处理。
09:使用析构函数防止资源泄漏
以对象管理资源,可以在对象的析构函数中释放资源。因为不论控制流如何离开区块,不管是正常离开,还是因为异常,对象都会被销毁,其析构函数也就自然被调用。因此,这种做法也就保证了异常发生时不会发生资源泄漏。这也就是所谓的RAII(资源获取即初始化),因为几乎总是在获取资源后,同一语句内以它初始化某个管理对象。
10:在构造函数内防止内存泄漏
下面的代码:
BookEntry::BookEntry(const string& name, const string& address, const string& imageFileName, Const string& audioClipFileName) : theName(name), theAddress(address), theImage(0), theAudioClip(0) { if (imageFileName != "") { theImage = new Image(imageFileName); } if (audioClipFileName != "") { theAudioClip = new AudioClip(audioClipFileName); } }
上面的代码看似正常,但是当调用theAudioClip = new AudioClip(audioClipFileName)发生异常时,theImage所指向的对象该由谁来删除呢?
析构函数只会析构已构造完全的对象,因此在构造函数发生异常时,BookEntry的析构函数不会执行。所以,必须设计构造函数,使其能够自我清理,通常这只需要将所有可能的异常进行捕捉,执行清理工作,然后重新抛出异常即可:
BookEntry::BookEntry(const string& name, const string& address, const string& imageFileName, const string& audioClipFileName) : theName(name), theAddress(address), theImage(0), theAudioClip(0) { try { if (imageFileName != "") { theImage = new Image(imageFileName); } if (audioClipFileName != "") { theAudioClip = new AudioClip(audioClipFileName); } } catch (...) { // 捕获所有异常 delete theImage; // 完成必要的清除代码 delete theAudioClip; throw; // 继续传递异常 } }
实际上,一个更好的答案是遵循上一条的忠告,以对象管理资源,将theImage和theAudioClip所指的资源交由局部对象管理,也就交由智能指针进行管理。
注意:不用为BookEntry中的非指针数据成员操心,在类的构造函数被调用之前数据成员就被自动地初始化。所以如果BookEntry构造函数体开始执行,对象的theName, theAddress 和 thePhones数据成员已经被完全构造好了。这些数据可以被看做是完全构造的对象,所以它们将被自动释放,不用你介入操作(实际上,在析构函数中,也从来不用手动释放过它们)。比如下面的测试代码:
class Inner { public: Inner() { printf("this is inner ctor\n"); } ~Inner(){ printf("this is inner dtor\n"); } }; class Test { public: Test() { printf("This is test ctor\n"); throw 3.0; printf("after throw 3.0\n"); } ~Test() { printf("this is Test dtor\n"); } private: Inner inner; }; int main() { try { Test t; } catch (double &d) { printf("catch %f\n", d); } printf("over\n"); }
代码执行结果如下:
this is inner ctor This is test ctor this is inner dtor catch 3.000000 over
如果想要捕获在初始化列表中可能发生的异常,则需要使用函数try块(function try block)语法:
template <class T> Handle<T>::Handle(T *p) try : ptr(p), use(new size_t(1)) { // function body } catch(const std::bad_alloc &e) { handle_out_of_memory(e); }
关键字 try 出现在成员初始化列表之前,并且测试块的复合语句包围了构造函数的函数体。catch 子句既可以处理从成员初始化列表中抛出的异常,也可以处理从构造函数函数体中抛出的异常。
11:禁止异常流出析构函数
有两种情况下会调用析构函数。第一种是当对象在正常状态下被销毁,也就是对象离开它的生存空间,或是被显式地delete。第二种是当对象被异常处理机制,也就是异常传播过程中的栈展开(stack-unwinding)过程中,对象被销毁。
因此,当析构函数被调用时,可能有一个异常正在激活状态(无法在析构函数中区分是否有异常已被激活),这种情况下,如果析构函数内部又发生了异常且析构函数没有捕获该异常,则C++会调用terminate函数,直接结束程序。这种情况可能不是你愿意看到的,因此,需要在析构函数中使用try catch捕获异常。
不允许异常传递到析构函数外面还有第二个原因:如果一个异常被析构函数抛出而没有在函数内部捕获住,那么析构函数的执行就是不完全的,也就意味着部分清理工作没有完成:
Session::Session() { logCreation(this); startTransaction(); // 开始一个数据库事务 } Session::~Session() { logDestruction(this); endTransaction(); // 结束数据库事务 }
在析构函数中,如果logDestruction(this)抛出异常,则数据库事务就不会结束。因此,这里也需要使用try catch捕获异常,结束事务。
12:理解“抛出一个异常”与“传递一个参数”或“调用一个虚函数”间的差异
从语法上看,在函数里声明参数与在catch子句中声明参数几乎没有什么差别。但是实际上,调用函数时,程序的控制权最终还会返回到函数的调用处,但是当抛出一个异常时,控制权永远不会回到抛出异常的地方。所以他们还是有差别的:
void passAndThrowWidget() { Widget localWidget; throw localWidget; }
因为在处理异常的时候会释放局部存储,所以被抛出的对象就不能再局部存储。因此,上面的throw localWidget语句,将进行localWidget的拷贝操作。用 throw 表达式初始化一个称为异常对象的特殊对象。异常对象由编译器管理,而且保证驻留在可能被激活的任意 catch 都可以访问的空间。这个对象由 throw 创建,并被初始化为被抛出的表达式的副本。异常对象将传给对应的 catch,并且在完全处理了异常之后撤销。
因此,异常对象通过复制被抛出表达式的结果创建,该结果必须是可以复制的类型。当抛出一个表达式的时候,被抛出对象的静态编译时类型将决定异常对象的类型。
Widget& rw = localSpecialWidget; // rw 引用SpecialWidget throw rw; //它抛出一个类型为Widget的异常
这里抛出的异常对象是Widget类型,即使rw引用的是一个SpecialWidget。因为rw的静态类型是Widget,而不是SpecialWidget。
空的throw语句是重新抛出当前的异常对象,而不是catch参数,比如下面的代码:
class Except { public: Except(int a=0):value(a) {} int value; }; void throwexcept() { try { throw Except(3); } catch (Except ee) { printf("1catch except value is %d\n", ee.value); ee.value = 5; printf("now except value is %d\n", ee.value); throw; } } int main(){ try { throwexcept(); } catch (Except ee){ printf("2catch except value is %d\n", ee.value); } printf("over\n"); }
在throwexcept函数中,捕获异常后,修改的是参数ee,而不是异常对象,因此,重新throw之后,再次捕获时,异常对象的value值没有变。结果如下:
1catch except value is 3 now except value is 5 2catch except value is 3 over
当catch的参数是对象,而不是对象的引用时,需要付出两次复制的成本,一次是任何exception都会产生的异常对象,一次是异常对象复制到catch参数。如果catch的参数是引用,则只付出一次复制异常对象的成本。
如果catch的参数是指针,虽然可以避免复制(这里的异常对象成了指针,复制的是指针),但是当指针指向局部对象时,该局部对象会在异常离开scope时被析构,从而产生未定义行为。
异常与catch子句相匹配的过程中,仅有两种转换可能发生:一是继承架构中的类转换,针对base的catch子句,可以处理derived类型的异常,该规则适用于by value, by reference, by pointer。比如C++的异常继承体系中,range_over和overflow_error都继承自runtime_error:
catch (runtime_error) ... // 可以捕捉类型为runtime_error, catch (runtime_error&) ... // range_error,或overflow_error类型 catch (const runtime_error&) ... // 的异常 catch (runtime_error*) ... // 可以捕捉类型为runtime_error*, catch (const runtime_error*) ... // range_error*,或overflow_error*类型的异常
第二种允许发生的转换是从一个“类型指针”转为“void指针”,因此,catch (const void*)可以捕获任何指针类型的异常
捕获异常采用最先匹配策略,因此,绝对不要将针对base class的catch子句放在针对derived class的catch子句之前。
13:以by reference的方式捕捉exception
传递异常有三种方式:by pointer, by value和by reference。
by pointer的方式虽然最有效率(只是复制指针,而不是复制对象),但是为了让指针所指物在控制权离开scope之后依然存在,指针所指物不能是局部对象,只能是全局或静态对象,或者是new出来的heap对象。但是catch异常的代码可能不知道该如何处理指针,不知道是否需要对指针执行delete,所以,不推荐使用by pointer的方式传递异常。
by value的情况,每当抛出时,需要复制两次,而且一旦catch参数为base class,而throw一个derived class,则会发生切割问题,所以也不推荐by value的方式。
所以,推荐以by reference的方式传递异常,它没有上述两种方式的缺点。
14:谨慎使用异常说明符(exception specifications)
异常说明符明确指出一个函数可以抛出什么样的异常,如果函数抛出了一个不在异常说明符中的异常,标准库中的std::unexpected函数就会被调用,该函数默认是调用std::terminate函数结束进程。这可能不是预期的行为。异常说明符在C++11中已废弃。
如果函数A调用了函数B,B函数抛出了不在A异常说明符中的异常,这就会导致运行时调用std::unexpected函数。
因此,应该避免在拥有类型参数的函数模板中使用异常说明符,因为不知道模板的类型参数有可能会抛出什么样的异常。
std::unexpected函数调用当前的std::unexpected_handler(一个函数指针),该指针默认指向std::terminate。用户可以通过调用std::set_unexpected设置新的std::unexpected_handler,新的std::unexpected_handler函数可以重新抛出一个异常,该异常如果在异常说明符中,异常栈展开将继续进行,异常就能传递下去。如果新异常不在异常说明符中,但是异常说明符中包含了std::bad_exception,则抛出std::bad_exception,其他情况下,std::terminate就会被调用。
为了防止非预期的异常发生,可以用新的异常取代非预期的异常:
class UnexpectedException {}; void convertUnexpected() { throw UnexpectedException(); } void fun() throw(UnexpectedException) { std::set_unexpected(convertUnexpected); throw 3.0; } int main() { try { fun(); } catch (UnexpectedException) { printf("catch UnexpectedException\n"); } catch (double) { printf("catch double\n"); } catch (std::bad_exception) { printf("catch bad_exception\n"); } }
fun函数中,double类型的异常被抛出,double不在异常说明符中,导致convertUnexpected函数被调用,该函数抛出了在异常说明符中的UnexpectedException异常,因此栈展开得以继续,在main中能捕获到该异常,打印出” catch UnexpectedException”。
如果将fun的异常说明符改为throw(std::bad_exception),则新的UnexpectedException也不再异常说明符中,但是栈展开还是能继续,打印出” catch bad_exception”。
如果fun的异常说明符为throw(),则直接调用std::terminate,打印出:terminate called after throwing an instance of 'UnexpectedException'。
异常说明符还有一个缺点,它会造成“较高层次的调用者已经准备好要处理发生的异常时,unexpected函数却被调用了”:
static void logDestruction(Session *objAddr) throw(); Session::~Session() { try { logDestruction(this); } catch (...) { } }
尽管Session的析构函数明确指出可以捕获任何logDestruction抛出的异常,然而因为logDestruction带有一个异常说明符,保证不抛出任何异常。但是一旦logDestruction抛出了异常,unexpected函数就会被调用,程序被终止,根本没有给Session析构函数catch一个机会。
15:了解异常处理的成本
为了在运行时处理异常,程序要记录大量的信息:无论执行到什么地方,程序都必须能够识别出如果在此处抛出异常的话,哪些对象需要被析构;程序必须在每一个try语句块的进入点和离开点做记号;对于每一个try块,必须记录与其相关的catch子句以及这些catch子句能够捕获的异常类型。这种信息的记录不是没有代价的。
而且,运行时的比对工作(以确保符合异常说明符)也不是免费的,当异常被抛出时销毁适当对象并找出正确的catch子句也不是免费的。
因此,异常处理是有代价的,即使你没有使用try,throw或catch关键字,你同样得付出一些代价。
让我们先从不使用任何异常处理特性也要付出的代价谈起:你需要空间建立数据结构来记录哪些已被完全构造(参见条款10),你也需要CPU时间保持这些数据结构不断更新。这些开销一般不是很大,但是采用不支持异常的方法编译的程序一般比支持异常的程序运行速度更快所占空间也更小。
大部分支持异常的编译器生产商都允许你自由决定是否在生成的代码里包含异常支持能力。如果你确定你程序的任何部分都不使用try,throw或catch,也确定所连接的程序库也没有使用try,throw或catch,就可以采用不支持异常处理的方法进行编译,这可以缩小程序的尺寸和提高速度,否则你就得为一个不需要的特性而付出代价。
使用异常处理的第二个开销来自于try块,无论何时使用它,也就是当你想能够捕获异常时,那你都得为此付出代价。不同的编译器实现try块的方法不同,粗略估计,如果你使用try块,代码的尺寸将增加5%-10%,运行速度也同比例减慢。这还是假设程序没有抛出异常,只是代码中出现try语句块的成本而已。因此为了减少开销,你应该避免使用无用的try块。
编译器为异常说明符生成的代码类似于面对try语句块的作为,所以一个异常说明符通常具有与try语句块相同的成本。
现在我们来到了问题的核心部分,看看抛出异常的开销。事实上我们不用太关心这个问题,因为异常应该是很罕见的,根据80-20规则(参见条款16),这样的事件不会对整个程序的性能造成太大的影响。但是抛出一个异常,到底会有多大的冲击?答案是与一个正常的函数返回相比,可能会比较大。通过抛出异常从函数里返回可能会慢3个数量级。这个开销很大。但是仅仅当你抛出异常时才会有这个开销,一般不会发生。
上面提到的数据,比如说程序的尺寸将增大5%-10%,抛出异常执行会慢3个数量级,这些数据也许未必准确,但是重要的是了解本条款所描述的成本,以及采取必要的措施:只要可能就尽量采用不支持异常的方法编译程序;把try块和异常说明符限制在非用不可的地点;并且只有在确为异常的情况下才抛出异常。如果你在性能上仍旧有问题,利用分析工具(profiler)分析你的程序,以决定异常支持是否是一个起作用的因素。如果是,那就考虑选择其它的编译器,能在C++异常处理方面具有更高实现效率的编译器。