0%

[Effective C++ 笔记]08. 不要在析构函数中抛出异常

简介

  • 析构函数绝对不要抛出异常。如果一个被析构函数调用的函数可能抛出异常,则在析构函数捕捉它并主动终止程序或不作处理;
  • 如果使用者需要对该异常进行处理,那么类中应该提供一个普通函数执行该操作。

引子

一般来说,C++ 不禁止析构函数抛出异常,但这个行为并不鼓励(事实上在 C++11 标准之后,析构函数被默认声明为 nonexcept 以防止用户在析构函数进行抛出异常操作)。考虑以下代码(这里我把书中示例的代码简单修改了一下更加方便说明例子,完整代码见 Ex1):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Widge {
public:
Widge(int a): a_(a) {}

~Widge() {
std::cout << "Destructor called" << std::endl;

if (a_ == 1) {
throw 1;
} else if (a_ == 2) {
throw 1.0;
} else {
std::cout << "Safely Destructed" << std::endl;
}
}

private:
int a_;
};

int main() {

try{

Widge w1(3);

{
Widge w2(1);
}
}
catch(...) {
std::cout << "Error catch." << std::endl;
}

return 0;
}

编译运行代码,程序输出:

1
2
3
4
Destructor called
Destructor called
Safely Destructed
Error catch.

可以发现,由于 w2 是局部变量,所以在脱离作用域时会自动调用析构函数进行销毁。而在 w2 抛出异常时,C++ 会对该作用域中所有已构造对象依次调用析构函数以防止内存泄露,这里我们从输出可以看到 w1 的析构函数被调用了。最后 w2 的异常在 catch 被接住,程序正常退出。这样看来,貌似在 Widge 中的析构函数并没有引起什么不良后果。

但假如我们令 w1 的析构函数也抛出异常,如下所示(完整代码见 Ex1):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Widge {
// ... 同上例
};

int main() {

try{

{
Widge w1(2); // 此时 w1 的析构函数也会抛出异常

{
Widge w2(1);
}
}
}
catch(...) {
std::cout << "Error catch." << std::endl;
}

return 0;
}

这意味着,在 w2 析构函数调用过程抛出异常而导致 w1 的析构函数被调用而抛出另一个异常,这个时候 C++ 需要处理两个异常,编译运行结果如下所示:

1
2
3
4
Destructor called
Destructor called
terminate called after throwing an instance of 'double'
Aborted (core dumped)

可以发现,C++ 处理不了第二个异常导致程序崩溃了。这意味着如果我们在某个对象的析构函数抛出异常,则在该对象的作用域有任何其他操作抛出异常而导致该对象的析构函数被调用时,系统将无法处理而直接崩溃!因此我们需要一种其他做法来避免在析构函数中抛出异常。

优化做法

有时候我们不得不在析构函数中执行某个动作而导致抛出异常,考虑如下例子:我们用一个类 DBConnection 管理数据库连接,为了防止用户忘记调用 close() 方法关闭连接,我们用另一个类 DBConn 来管理 DBConnection 对象,在其析构函数中调用 close 这样就可以使用户不用显式地调用 close() 了,如下所示(完整代码见 Ex2):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class DBConnection {
public:
static DBConnection create();

// 关闭联机,失败则抛出异常
void close();
};

// DBConnection 的管理类
class DBConn {
public:
DBConn(DBConnection db_): db(db_) {}

~DBConn() {
db.close();
}
private:
DBConnection db;
};

int main() {

// ...
{
DBConn dbc(DBConnection::create()); // 建立 DBConnection 对象并交给 DBConn 以便管理。
} // dbc 在区块结束点时被销毁,并自动为 DBConnection 对象调用 close 方法
//...
}

上例中,如果 db.close() 调用成功则没有问题,一旦失败则会发生我们之前说的情况:在析构函数中抛出了异常。我们有以下两种方法避免这个问题:

在析构函数中接住异常并主动终止

1
2
3
4
5
6
7
8
9
~DBConn::DBConn() {
try {
db.close();
}
catch(...) {
// 记录关闭失败记录并主动终止程序
std::abort();
}
}

第一种方法是与其让程序因为析构函数抛出异常而导致的不确定行为,我们可以主动记录异常过程并终止程序。这种做法通常适合用于此处的异常比较关键并且后续程序无法执行的情况(完整代码见 Ex3)。

在析构函数中接住异常不做处理

1
2
3
4
5
6
7
8
~DBConn::DBConn() {
try {
db.close();
}
catch(...) {
// 记录关闭失败记录
}
}

如果此处的异常并不会对程序进行运行和结果导致很大影响,我们也可以选择将异常接住并记录异常抛出过程,然后不做其他记录让程序继续进行。当然这必须建立在异常不会对程序后续结果产生不良影响的情况下(完整代码见 Ex4)。

重新设计接口让使用者有机会对异常做出反应

以上两种方法对使用者而言都无法做出反应(因为这是 DBConn 析构函数的实现,而通常情况下用户不会主动调用析构函数),为了让使用者有机会对异常做出反应,我们可以重新设计借口,例如为 DBConn 也添加一个 close() 函数来使用户主动处理由于 DBConnectionclose() 方法产生的异常。同时 DBConn 通过跟踪其管理的 DBConnection 对象是否已经关闭。如果已经关闭则无须操作,否则则重复上述操作。如下所示(完整代码见 Ex5):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class DBConn {
public:
//...

// 共使用者主动调用
void close() {

db.close();
closed = true;
}

~DBConn() {
if (!closed) { // 如果使用者之前没关闭
try {
std::cout << "closing db" << std::endl;
db.close();
}
catch(...) {
std::cout << "close fail!" << std::endl;
}
}
}
private:
DBConnection db;
bool closed;
};

则相当于提供了“双保险”,即给用户一个接口使其可以主动关闭连接并处理可能发生的异常,而当用户认为没有必要主动关闭时则在销毁时自动关闭。虽然看起来接口比原先复杂,但这是必要的。因为异常不能从析构函数中抛出,而当我们有某个操作抛出异常而该异常必须处理的情况下,我们只能提供另一个接口使用户有机会处理它。

结论

  • 析构函数绝对不要抛出异常。如果一个被析构函数调用的函数可能抛出异常,则在析构函数捕捉它并主动终止程序或不作处理;
  • 如果使用者需要对该异常进行处理,那么类中应该提供一个普通函数执行该操作。

完整可运行代码地址:Effective-Cpp-Reading-Note