构造函数可以抛出异常。
C++标准指明析构函数不能、也不应该抛出异常。
在步入正题前,我们先来讲讲什么叫栈展开(stack unwinding),才能更好理解C++异常(exception)的机制是怎样运作的:
void f1() throw(int){ //函数f1会抛出一个整型的异常代码
cout<<"f1 starts"<<endl;
int i; //这个变量会在栈展开的过程中被释放资源
throw 100; //抛出异常,程序开始在栈中搜索对应的异常处理器,即开始栈展开
cout<<"f1 ends"<<endl; //这行代码不会被执行
}
void f2 throw(int){ //函数f2调用了f1,所以抛出异常的类型也是整型
cout<<"f2 starts"<<endl;
int j; //这个变量也会在栈展开的过程中被释放资源
f1(); //f1没有搜索到对应的异常处理,因此返回到f2搜索
cout<<"f2 ends"<<endl; //这行代码也不会被执行
}
void f3(){
cout<<"f3 starts"<<endl;
try{ //函数f3在try里调用f2,并可能会catch一个整型的异常
f2();
}catch(int i){ //f2也没有找到异常处理,最后返回了f3并找到了异常处理
cout<<"exception "<<i<<endl;
}
cout<<"f3 ends"<<endl;
}
int main(){
f3();
return 0;
}
在C++里,当有异常被抛出,调用栈(call stack),即栈中用来储存函数调用信息的部分,会被按次序搜索,直到找到对应类型的处理程序(exception handler)。而这里的搜索顺序就是f1->f2->f3。f1没有对应类型的catch块,因此跳到了f2,但f2也没有对应类型的catch块,因此跳到f3才能处理掉这个异常。
以上这个寻找异常相应类型处理器的过程就叫做栈展开。同时在这一过程中,当从f1返回到f2时,f1里局部变量的资源会被清空,即调用了对象的析构函数。同样,在从f2返回到f3时,f2里局部变量也会被调用析构函数并清空资源。
现在可以回到正题了,C++并不阻止在类的析构函数中抛出异常,但这是一个非常不好的做法!因为栈展开的前提是已经有一个未处理的异常,并且栈展开会自动调用函数本地对象的析构函数,如果这时对象的析构函数时又抛出一个异常,现在就同时有两个异常出现,但C++最多只能同时处理一个异常,因此程序这时会自动调用std::terminate()函数,导致我们所谓的闪退或者崩溃。
此外,如下的栗子也会导致程序同时出现多个异常:
class Widget{
public:
...
~Widget(){...} //假设此析构函数可能会抛出异常
};
void doSomething(){
std::vector<Widget> v;
} //在这一行调用了v的析构函数,资源被释放
当v被调用析构函数,它包含的所有Widget对象也都会被调用析构函数。又因为v是一个容器,如果在释放第一个元素时触发了异常,它也只能继续释放别的元素,否则会导致其它元素的资源泄露。如果在释放第二个元素的时候又触发了异常,那么程序同样会导致崩溃。
不仅仅是std::vector,所有STL容器的类甚至包括数组也都会像这样因为析构函数抛出异常而崩溃程序,所以在C++中,不要让析构函数抛出异常!
但是如果析构函数所使用的代码可能无法避免抛出异常呢?我们再来看一个栗子:
class DBConnection{ //某用来建立数据库连接的类
public:
...
static DBConnection create(); //建立一个连接
void close(); //关闭一个连接,假设可以抛出异常
};
class DBConn{ //创建一个资源管理类来提供更好的用户接口
public:
....
~DBConn{ db.close(); ] //终止时自动调用关闭连接的方法
private:
DBConnection db;
};
...{
DBConn dbc(DBConnection::create()); //创建一个DBConn类的对象
... //使用这个对象
} //对象dbc被释放资源
//但它的析构函数调用了可能会抛出异常的close()方法
我们通过DBConn的析构函数来释放资源并关闭连接,但析构函数所调用的close()方法可能会抛出异常,那么有什么方法来解决呢?
- 消化掉这个异常
DBConn::~DBConn(){
try{
db.close();
}catch(...){
//记录访问历史
}
}
栈展开的过程终止于异常被对应类型的catch块接到,因此在这种情况下,只要catch包括了所有可能的异常,析构函数就能消化掉这个异常,防止异常从析构函数里跑出来,和别的异常产生冲突。
但这样做法的缺点是可能会给程序的稳定运行带来隐患,因为当某些比较严重或者不能处理的异常发生时,我们继续让程序运行,就可能导致程序的未知行为。当然如果能保证所有异常都能被正确处理,程序能继续稳定运行,就可以使用这个方法。
- 主动关闭程序
DBConn::~DBConn(){
try{
db.close();
}catch(...){
//记录访问历史
std::abort();
}
}
通过std::abort()函数来主动关闭程序,而不是任由程序在某个随机时刻突然崩溃,这样能减少潜在的用户风险。对于某些比较严重的异常,就可以使用这个方法。并且我们可以结合使用上面的方法,把能处理的异常消化掉。
但这些做法治标不治本,只能当做plan B,我们再来看一个更好的方法:
- 把可能抛出异常的代码移出析构函数
我们设计DBConn类的更安全的接口,让其他函数来承担这个风险,而且这样也可以事先在析构函数这样的紧要关头前对异常做出处理。
class DBConn{
public:
...
~DBConn();
void close(); //当要关闭连接时,手动调用此函数
private:
...
closed = true; //显示连接是否被手动关闭
};
void DBConn::close(){ //当需要关闭连接,手动调用此函数
db.close();
closed = true;
}
DBConn::~DBcon(){
if(!closed) //析构函数虽然还是要留有备用,但不用每次都承担风险了
try{
db.close();
}catch(...){
//记录访问历史
//消化异常或者主动关闭
}
}
通过以上的做法,当关闭连接时,我们先手动调用close()方法,这样就算抛出了异常,我们也可以事先处理,然后再调用析构函数。当然析构函数还是要检查是否被手动关闭并留有备用方案。如果没有被手动关闭,析构函数还是需要在消化掉异常和终止程序中做出选择。
总结:
- 不要让异常从析构函数里跑出来。如果析构函数的某些代码可能会抛出异常,要保证它们能在跑出析构函数之前被catch块接到,然后选择消化异常还是终止程序。
- 我们可以把可能抛出异常的代码从析构函数中移到别的函数里,这样就可以事先对异常做出反应。