1. 析构函数里抛异常,为什么会让整个进程直接“自杀”
我第一次被析构函数里的异常坑到,是在一个数据采集服务上。那个服务跑了两三天后突然进程消失,日志里没有留下任何业务层面的报错,只有一行类似“terminate called after throwing an instance of ...”,以及一个残缺的 core 文件。排查到最后,问题出在一个数据库连接对象上:它的析构函数在关闭连接时会尝试重连,重连失败就抛异常,而这个异常直接逃出了析构函数,触发了 std::terminate,整个进程当场终止。
这就是《Effective C++》条款8想讲的核心问题。条款原文很短,意思也很明确:析构函数绝对不要吐出异常。如果真的在析构函数里发生了可能失败的清理操作,比如释放资源、关闭连接、写日志、flush 缓冲区,你要么自己吞掉异常,要么设计成让用户有机会显式调用一个完成操作的函数,把错误处理的责任从“自动析构”转移到“显式调用”。
这个条款在 2005 年出版的时候就很重要,到了 C++11 之后,它的重要性不降反升。原因很简单:C++11 开始,析构函数默认是 noexcept(true)。也就是说,就算你的析构函数里写了 throw,编译器也会默认它不会抛异常,一旦运行到真的抛出异常时,程序直接调用 std::terminate,连“尝试一下栈展开”的机会都不给你。后面我会专门讲这个变化,先把这个结论刻在脑子里:析构函数里一但往外抛异常,进程死亡概率非常高,而且死得极其难看。
1.1 从“析构”到“崩溃”只需要一个 throw
先看一个最直接的场景。你写了一个类,里面持有某个数据库连接,连接需要显式关闭:
cpp复制class DBConnection {
public:
static DBConnection create();
void close(); // 可能抛异常
};
class DBConn {
public:
~DBConn() {
db.close(); // 如果 close 抛异常,会怎样?
}
private:
DBConnection db;
};
DBConn 的析构函数里调用了 db.close(),而 close() 是有可能抛异常的。如果这个异常没有被捕获,它就逃离了析构函数。看起来好像“也没多大事,最多报个错”?完全不是。
关键点在于:当异常离开析构函数时,C++ 运行时会认为这个异常没有被正确处理。如果析构函数是在栈展开的过程中被调用的(就是说:某个作用域里已经有了一个异常在传播,异常处理机制正在销毁沿途的栈对象,销毁到你的 DBConn 时,析构函数又抛了一个新异常),那么系统同时面对两个异常,根本无法决定该用哪个去匹配 handler,于是直接调用 std::terminate,进程终结。
即使不是在栈展开过程中——比如正常情况下作用域结束,对象析构——抛出异常一般也会导致 std::terminate。标准规定,析构函数默认就是 noexcept(true) 的,异常一旦跨越析构函数的边界,那就是违反了 noexcept 约定,直接触发 terminate。结论极其干脆:析构函数里的异常,就是一颗定时炸弹,而且引爆路径极短。
你可以自己做个实验,这段代码基本上会把它放在的大部分环境跑崩溃:
cpp复制#include <iostream>
#include <stdexcept>
struct Foo {
~Foo() {
throw std::runtime_error("oops");
}
};
int main() {
try {
Foo f;
throw std::runtime_error("something else");
} catch (const std::exception& e) {
std::cout << "caught: " << e.what() << std::endl;
}
return 0;
}
我把这段代码发给不同经验水平的人看过,很多新手会以为外层 catch 能拦住这个异常,毕竟 try 块里确实有两个异常源。但结果是:这个程序大概率直接调用 terminate,连 caught: 都打印不出来。因为当 throw std::runtime_error("something else") 触发栈展开时,Foo 的析构函数又抛了 oops,两个异常同时存在,运行时只能选择 terminate。
如果是普通的函数,你可以在一个 try-catch 里调用它,然后“优雅地处理”错误。但析构函数没有这个待遇,尤其当它出现在栈展开路径上的时候,C++ 运行时宁可杀掉进程也不愿意猜。
1.2 二重异常爆炸:栈展开期间的追加异常
上面已经提到“两个异常同时存在”这个场景,我再把它拆细一点,因为这是最容易让人轻视的地方。
所谓栈展开,指的是异常从抛出点开始,沿着调用栈向上寻找 handler 的过程。在这个过程中,所有已经构造完成的局部对象都会被析构,释放栈帧资源。这是一个递归的过程:从抛异常的帧开始,逐层向外走,每层都销毁该层定义的对象。
现在的问题就来了:如果第一个异常已经“走到一半”,正在析构某层局部对象,而局部对象的析构函数突然抛出第二个异常,C++ 标准认为这是一个不可恢复的局面,因为两个异常同时存在时,如果选择让第二个异常继续传播,那第一个异常就丢了;如果让第一个异常继续传播,第二个异常又没法处理。无论哪一种,都会导致部分资源的清理逻辑被打断,程序状态不可信。所以标准干脆规定:此时直接调用 std::terminate。
这不是理论上的罕见情况。凡是写过网络服务的人都会在实际代码里遇到:请求处理函数里抛了业务异常,函数栈上的某个连接管理对象在析构时去关闭 socket,关闭失败又抛了一个网络层异常。瞬间进程消失,日志里面全是 what(): ...。最常见的后果就是线上服务突然没了,运维一脸懵,你查半天也查不到业务逻辑的错误,最后才发现是析构函数里的“小小 throw”闯的祸。
这条线的逻辑要彻底理清楚:析构函数本身不害怕发生错误,它害怕的是把错误用异常的形式传播出去。 这就引出了条款8给出的两个经典解决方案。
2. 条款8的核心解法:吞掉还是转移?两种方案各有适用边界
Scott Meyers 在条款8里给了两条路:要么在析构函数里捕获异常并吞掉它,要么提供一个普通成
