1.栈展开
为了更好的理解C++中的异常处理机制,先解释一下什么是栈展开。如下例所示:
1#include <iostream> 2 3using namespace std; 4 5void f1() throw(int) 6{ // 函数f1会抛出一个整型的异常代码 7 cout << "f1 starts" << endl; 8 int i; // 这个变量会在栈展开的过程中被释放资源 9 throw 100; // 抛出异常,程序开始在栈中搜索对应的异常处理器,即开始栈展开 10 cout << "f1 ends" << endl; // 这行代码不会被执行 11} 12 13void f2 throw(int) 14{ // 函数f2调用了f1,所以抛出异常的类型也是整型 15 cout << "f2 starts" << endl; 16 int j; // 这个变量也会在栈展开的过程中被释放资源 17 f1(); // f1没有搜索到对应的异常处理,因此返回到f2搜索 18 cout << "f2 ends" << endl; // 这行代码也不会被执行 19} 20 21void f3() 22{ 23 cout << "f3 starts" << endl; 24 try 25 { // 函数f3在try里调用f2,并可能会catch一个整型的异常 26 f2(); 27 } 28 catch (int i) 29 { // f2也没有找到异常处理,最后返回了f3并找到了异常处理 30 cout << "exception " << i << endl; 31 } 32 cout << "f3 ends" << endl; 33} 34 35int main() 36{ 37 f3(); 38 return 0; 39}
在C++中,当有异常抛出时,调用栈中的用来储存函数调用信息的部分,会被按顺序依次搜索,直到找到对应类型异常的处理程序为止。上例中,搜索顺序就是f1->f2->f3。f1没有对应类型的catch块,因此跳到了f2,但f2也没有对应类型的catch块,因此跳到f3才能处理掉这个异常。
上例中,寻找相应异常类型处理的过程就叫做栈展开。同时在这一过程中,当从f1返回到f2时,f1里局部变量i的资源会被清空,即调用了对象i的析构函数。同样,在从f2返回到f3时,f2里局部变量j也会被清空,即调用了对象j的析构函数。
2.建议阻止C++中的析构函数抛出异常
C++并不会阻止在类的析构函数中抛出异常,但这是一个非常不好的做法!由于栈展开的前提是已经存在一个未处理的异常,并且栈展开会自动调用函数本地对象的析构函数。如果此时对象的析构函数又抛出一个异常,就会同时有两个异常出现,但C++最多只能同时处理一个异常。因此,此时的程序会自动调用std::terminate()函数,导致程序的闪退或者崩溃。如下例所示:
1#include <iostream> 2#include <vector> 3 4class Widget{ 5public: 6 // ... 7 ~Widget(){ 8 // ... 假设析构函数内部抛出一个异常 9 } 10}; 11 12void doSomething(){ 13 std::vector<Widget> v; 14 15 // ... 局部对象v在此处被自动销毁 16}
当对象v被销毁时,它有责任销毁其内部含有的所有的Widget。假设v内部含有十个Widget,但是在第一个元素析构的时候,有一个异常抛出。此时,其他的九个Widget还是应该被继续销毁。因此,v应该调用它们各自的析构函数。假设在那些析构函数被调用的时候,第二个Widget析构函数也抛出一个异常。此时,C++无法同时处理两个异常,异常程序会结束运行或导致不明行为。注意:使用STL中的其他容器也会出现相同情况。
假设你的析构函数必须执行一个动作,而这个动作可能会在失败时抛出异常,该怎么办?如下例所示:
1#include <iostream> 2#include <vector> 3 4class DBConnection{ 5public: 6 // ... 7 static DBConnection create(); // 这个函数会返回DBConnection对象 8 9 void close(); // 断开与数据的连接 10};
为了能确保客户不忘记在DBConnection对象身上调用close()函数,一个合适的方法是创建一个管理DBConnection对象的类DBConn,并且在其析构函数中调用close()函数。如下例所示:
1#include <iostream> 2#include <vector> 3 4class DBConnection{ 5public: 6 // ... 7 static DBConnection create(); // 这个函数会返回DBConnection对象 8 9 void close(); // 断开与数据的连接 10}; 11 12class DBConn{ 13public: 14 // ... 15 ~DBConn(){ 16 db.close(); // 为了确保客户不会忘记在DBConnection对象上调用close()函数 17 } 18private: 19 DBConnection db; 20}; 21 22// 因此,客户可以写出如下代码: 23 24{ // 开启一个代码块 25 DBConn dbc(DBConnection::create()); // 建立DBConnection对象并由DBConn对象管理 26 // ... 通过DBConn接口使用DBConnection对象 27} // 在代码块结束时,DBConn对象dbc被销毁,因此自动为DBConnection对象调用close()函数
在上述客户写出的代码块中,如果close()函数调用成功,则一切完美。但是,如果该调用会导致异常,DBConn析构函数就会传播异常,即允许它离开这个析构函数。
3.解决方法
针对上述客户写出的代码块中可能存在的问题,有两种方法可以对DBConn的析构函数进行优化。a.如果close()函数抛出异常就立即主动关闭程序,通常通过调用abort()完成。通过std::abort()函数来主动关闭程序,而不是任由程序在某个随机时刻突然崩溃,这样能减少潜在用户风险。对于某些比较严重的异常,可以使用这个方法。
1DBConn::~DBConn 2{ 3 try 4 { 5 (db.close(); 6 } 7 catch (...) 8 { 9 // 记录访问历史 10 std::abort(); 11 } 12}
b.消化因调用close()而产生的异常。栈展开的过程终止于异常被对应类型的catch块捕捉到。因此,在这种情况下只要catch包括了所有可能的异常,析构函数就能消化掉这个异常,防止异常从析构函数中跑出来,从而和别的异常产生冲突。
但是,这种做法的缺点是可能会给程序的稳定运行带来隐患,因为当某些比较严重或者不能处理的异常发生时,我们继续让程序运行,就可能会导致程序的未知行为。如果我们能保证所有的异常都能被正确处理,程序能继续稳定运行,就可以使用这个方法。
1DBConn::~DBConn 2{ 3 try 4 { 5 (db.close(); 6 } 7 catch (...) 8 { 9 // 记录访问历史 10 } 11}
4.更好的解决方法:把可能抛出异常的代码移出析构函数
设计DBConn类的更安全的接口,让其他函数来承担这个风险,而且这样也可以事先在析构函数这样的紧要关头前对异常做出处理。
1#include <iostream> 2#include <vector> 3 4class DBConnection 5{ 6public: 7 // ... 8 static DBConnection create(); // 这个函数会返回DBConnection对象 9 10 void close(); // 断开与数据的连接 11}; 12 13class DBConn 14{ 15public: 16 // ... 17 void close(); // 当需要关闭连接,手动调用此函数 18 19 ~DBConn(); // 析构函数虽然还是要留有备用,但不用每次都承担风险了 20 21 22private: 23 DBConnection db; 24 bool closed; // 显示连接是否被手动关闭 25}; 26 27void DBConn::close() // 当需要关闭连接,手动调用此函数 28{ 29 db.close(); 30 closed = true; 31} 32 33DBConn::~DBConn() // 析构函数虽然还是要留有备用,但不用每次都承担风险了 34{ 35 if (!closed) 36 { 37 try 38 { 39 db.close(); 40 } 41 catch (...) 42 { 43 // 记录访问历史 44 // 消化异常或者主动关闭程序 45 } 46 } 47}
通过上例中的做法:当关闭连接时,我们先手动调用close()方法,这样就算抛出了异常,我们也可以事先处理,然后再调用析构函数。当然,析构函数还是要检查是否被手动关闭并留有备用方案。如果没有被手动关闭,析构函数还是需要在消化掉异常和主动关闭程序中做出选择。
5.总结
(1) 构造函数中绝对不要抛出异常。如果一个被析构函数调用的函数可能会抛出异常,析构函数应该捕捉任何类型的异常,然后消化这样异常或主动关闭程序。
(2) 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么类中应该提供一个普通函数(而不是在析构函数中)执行该操作。