9.禁止异常离开析构函数

C++并不禁止析构函数抛出异常,但需要明确:析构函数抛出异常存在风险

问题描述

考虑如下代码:

1
2
3
4
5
6
7
8
9
class Widget{
public:
...
~Widget() {...}//可能抛出异常
}
void doSth(){
vector<Widget> v;
...//v在此处被自动销毁
}

当vector被销毁时,所有的Widget对象都会被销毁。于是我们一一调用析构函数.假设在析构第一个元素时,有一个异常被抛出,但剩下的还是应该被销毁(否则就会资源泄漏),因此会接着销毁第二个,但如果第二个Widget被析构时又抛出了异常,于是现在有了两个同时作用的异常,C++不允许处理多个异常同时存在的情况在两个异常同时存在的情况下,c++不是立刻结束运行就是导致不明确行为。在本例中就是会导致不明确行为。
我们需要记住的是:只要析构抛出异常,程序就可能过早结束或者行为不明确


问题实例及解决方案

问题实例

假设我们建立了一个数据库连接类,其行为如下:

1
2
3
4
5
6
class DBConnection{
public:
...
static DBConnection create();//返回对象 static成员函数不属于对象而是属于类
void close();//关闭连接,关闭失败则抛出异常
}

为了确保DBConnection对象必然会关闭,很自然地,我们会建立一个类来管理DBConnection对象:
1
2
3
4
5
6
7
8
class DBConn{
public:
~DBConn(){
db.close();//析构函数中可能抛出异常
}
private:
DBConnection db;
}

如果析构时抛出异常,DBConn会传播该异常(允许其离开析构函数),那么结果相当麻烦。

解决方案

修改析构函数

抛出异常就终结程序(调用abort)

1
2
3
4
5
6
7
DBConn::~DBConn(){
try{ db.close():}
catch(...){
...//记录失败至日志
std::abort();
}
}

虽然这直接导致了程序突然结束,但至少它阻止了异常的传播。

直接吞下异常,不对它进行处理

1
2
3
4
5
6
DBConn::~DBConn(){
try{ db.close():}
catch(...){
...//记录失败至日志
}
}

这基本上会导致雪崩,但有时我们不能立刻终结程序,毕竟能跑总比不能用要好。

重新设计DBConn类接口(较优策略)

我们可以重新设计DBConn,使用户可以对发生异常做出反应。或者说追踪connection是否被关闭,如果未被关闭则由析构函数关闭。但如果析构函数调用close失败,我们还是又将退回“强迫结束程序”或者“吞下异常”的老路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class DBConn{
public:
...
void close(){//供客户使用的关闭函数
db.close();//调用时不会在析构函数中抛出异常
closed = true;
}
~DBConn(){
if(!closed){//如果用户没有手动关闭连接
try{db.close();}
catch(...){
//使用上述两种处理方式之一
}
}
}
private:
DBConnection db;
bool closed;
}

这种写法把调用close的责任从析构函数转移到了客户手中。可能有人会认为这令接口变得不方便使用,但实际上如果某个操作可能在失败时抛出异常,又存在某种必要必须处理该异常,那么这个异常必须来自析构函数之外的某个函数。因为析构函数抛出异常必然会导致过早结束程序或者发生不明确行为的风险。
如果用户没用close来关闭连接,那至少析构函数中也适当的调用了它,这时候再发生错误用户也没资格抱怨,因为毕竟我们提供了接口,而他们选择了放弃使用。


总结

  1. 析构函数绝对不能吐出异常,如果一个被析构函数调用的函数可能会抛出异常,那我们的析构函数应该捕捉任何异常,然后吞下它们(不传播)或者结束程序。
  2. 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么class应该提供一个普通函数(而非在析构函数中)执行该操作。