别让异常逃离析构函数
C++ 并不禁止析构函数吐出异常,但它也不鼓励这样做。
考虑下面代码:
class Widget {
public:
...
~Widget( ) {
...} // 假设这里吐出一个异常
};
void soSomething()
{
std::vector<Widget> vec;
...
} // vec 在这里被自动销毁
当 vector vec 被销毁,它有责任销毁其内含的所有 Widgets。假设 vec 内含10个Widgets,而在析构第一个元素期间,有一个异常被抛出。其他九个 Widgets 还是应该被销毁(否则它们保存的任何资源都会发生泄漏),因此 vec 应该调用它们各个析构函数。但假设在那些调用期间,第二个 Widget 析构函数又抛出异常。现在有两个同时作用的异常,这对 C++ 而言太多了。在两个异常同时存在的情况下,程序若不是结束就是导致不明确行为。本例会导致不明确行为。
这很容易理解,但如果你的析构函数必须执行一个动作,而该动作可能会在失败是抛出异常,该咋办呢?举个例子,假设你使用一个 class 来负责数据库连接:
class DBConnection {
public:
...
static DBConnection create(); //返回 DBConnection 对象
void close(); // 关闭联机;失败则抛出异常
};
- 为了确保用户不忘记在 DBConnection 对象身上调用 close() ,我们可以这样做,创建一个用来管理 DBConnection 资源的 class,并在其析构函数中调用 close。
代码如下:
class DBConn {
DBConnection db;
public:
...
~DBConn(){
db.close();
}
};
这样便允许用户写出这样的代码:
{
// 开启一个区块(block)。
DBConn dbc(DBConnection::create()); // 建立 DBConnection 对象并交给 DBConn 对象以便管理。
// 通过 DBConn 的接口使用 DBConnection 对象。
// 在区块结束点,DBConn 对象被销毁,因而自动为 DBConnection 对象调用 close
...
}
只要调用 close 成功,一切都美好,但如果该调用导致异常,DBConn 析构函数会传播该异常,也就是允许它离开这个析构函数。那样就会造成问题,因为那是抛出了难以驾驭的麻烦。
下面给出两种办法解决这个问题
- 如果 close 抛出异常就结束程序。通常通过调用 abort 完成:
DBConn::~DBConn( ){
try {
db.close(); }
catch ( ... ){
// 制作运转记录,记下对 close 的调用失败
std::abort(); // 使用 abort 可以抢先制 “ 不明确 ” 行为于死地,也就是强迫结束程序
}
}
- 吞下因调用 close 而发生的异常:
DBConn::~DBConn( ){
try {
db.close(); }
catch ( ... ){
// 制作运转记录,记下对 close 的调用失败
}
}
一般而言,这样做是一个坏主意,因为它压制了 “ 某些动作失败 ” 的重要信息!然而有时候吞下异常也比负担 “ 草率结束程序 ” 或 “ 不明确行为带来的风险 ” 好。
显然这些方法都不具有吸引力。问题在于两者都无法对 “ 导致 close抛出异常 ” 的情况做出反应。
一种较佳策略是重新设计 DBConn 接口,使用户有机会对可能出现的问题做出反应:
class DBConn {
public:
void close (){
// 供用户使用的新函数
db.close();
closed = true;
}
~DBConn(){
if(!closed) {
try {
// 关闭连接(如果用户不那么做(调用close())的话)
db.close();
}
catch(...){
// 制作运转记录,记下对 close 的调用失败
...
}
}
}
private:
DBConnection db;
bool closed;
};
把调用 close 的责任从 DBConn 析构函数手上移到 DBConn 用户手上,让用户有机会第一手处理问题。
最后请记住:
- 析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下它们(不传播)或结束程序。
- 如果用户需要对某个操作函数运行期间抛出的异常作出反应,那么 class 应该提供一个普通函数(而非在析构函数中)执行该操作。