1. 核心原则
永远不要在析构函数中抛出异常,或者让异常传播到析构函数之外。 如果析构函数中调用的代码可能抛出异常,你必须在析构函数内部捕获(Catch)并处理它。
2. 为什么要这样做?(The Danger)
这个规则背后有两个致命的原因:
原因 A:双重异常导致的程序终结 (Premature Termination) 这是最主要的原因。在 C++ 中,当一个异常被抛出,程序开始进行栈展开 (Stack Unwinding) 以寻找 catch 块时,栈上的局部对象会被销毁,即调用它们的析构函数。
-
如果在这个过程中,析构函数本身又抛出了第二个异常…
-
C++ 运行时系统就会陷入两难:现在的控制流是该处理第一个异常,还是处理第二个?
-
结果:C++ 会直接调用
std::terminate(),强制结束程序(Crash)。
原因 B:资源泄漏与未定义行为 如果析构函数抛出异常,导致析构函数内的后续代码没有执行,可能会导致持有的其他资源(如句柄、锁)没有被释放。
3. 典型场景:数据库连接
假设你有一个数据库连接类,由于网络问题,关闭连接可能会失败并抛出异常。
❌ 危险的做法:
class DBConnection {
public:
static DBConnection create();
// 这个函数可能会抛出异常
void close();
};
class DBConnManager {
public:
~DBConnManager() {
db.close(); // 危险!如果 close 抛出异常,它会逃离析构函数
}
private:
DBConnection db;
};
// 场景:
// 1. 某个函数中抛出了一个异常。
// 2. 栈展开开始,销毁 DBConnManager 对象。
// 3. 调用 ~DBConnManager() -> db.close() 抛出第二个异常。
// 4. 程序直接 Crash。
4. 如何解决?(The Solutions)
Scott Meyers 提出了三种策略,前两种是被动防御,第三种是最佳实践。
策略 1:吞下异常 (Swallow it) 用 try-catch 包裹调用,记录日志,然后什么也不做。
-
优点: 保证程序不崩溃。
-
缺点: 掩盖了错误,程序可能在后续运行中处于不确定状态。
DBConnManager::~DBConnManager() {
try {
db.close();
} catch (...) {
// 记录日志:关闭连接失败
// 这里的关键是不让异常跑出去
}
}
策略 2:强制结束程序 (Terminate) 如果在析构期间发生错误是无法恢复的灾难,不如直接自杀。
DBConnManager::~DBConnManager() {
try {
db.close();
} catch (...) {
// 记录日志
std::abort(); // 主动调用 abort,防止未定义行为的传播
}
}
策略 3:双保险模式 (Best Practice) 把“处理异常的机会”交给用户。 提供一个普通的 close() 函数供用户调用。析构函数只作为“最后一道防线”。
class DBConnManager {
public:
void close() {
db.close();
closed = true;
}
~DBConnManager() {
if (!closed) { // 如果用户忘记关闭,析构函数来兜底
try {
db.close();
} catch (...) {
// 此时只能选择 策略1(记录并忽略) 或 策略2(abort)
// 因为用户已经放弃了处理机会
}
}
}
private:
DBConnection db;
bool closed = false;
};
这种做法的好处:
-
用户有权: 用户可以显式调用
close()并处理可能发生的异常。 -
底线安全: 如果用户忘了,析构函数会尝试关闭(虽然此时必须吞下异常),这是一种“尽力而为”的机制。
现代 C++ (C++11 及以后) 的补充
在 C++11 中,这个规则变得更加严格:
-
所有的析构函数默认都是
noexcept的。 -
这意味着,即使你不写
try-catch,如果你的析构函数抛出了异常,编译器生成的代码也会直接调用std::terminate()。 -
如果你真的想在析构函数里抛出异常(极度不推荐),你必须显式声明
noexcept(false)。
2. 为什么 C++11 要这么做?
这并不是为了给程序员找麻烦,而是基于两个核心考量:
-
强制执行最佳实践(Item 8): 既然《Effective C++》Item 8 早就告诉大家“别让异常逃离析构函数”,标准委员会决定在语言层面强制执行这一规则。这减少了未定义行为的风险。
-
优化与移动语义(Move Semantics): C++11 引入了移动语义(
std::move)。标准库容器(如std::vector)在扩容时,需要将旧元素移动到新内存。-
如果移动操作(依赖析构函数)可能抛出异常,
std::vector就无法保证“强异常安全保证”(Strong Exception Guarantee)。 -
因此,
std::vector等容器通常倾向于使用noexcept的操作。如果编译器知道析构函数不会抛出异常,它可以生成更高效、更安全的代码。
-
3. 为什么 noexcept 不是救世主?
noexcept 就像是一道红线。
-
Item 8 的建议是: “请不要踩红线,如果你脚滑了,请在踩线前自己收回来(在内部 try-catch)。”
-
C++11 的
noexcept机制是: “这道红线通了高压电。只要你敢踩(抛出异常),我就直接电死你(std::terminate)。”
所以,只声明 noexcept(或者利用默认的 noexcept)而不修改代码逻辑,并没有解决问题,只是把“隐患”变成了“立即崩溃”。
4. 只有 Item 8 才是活路
让我们对比一下三种情况,你就能明白为什么 Item 8 依然是金科玉律。
情况 A:不做 Item 8 处理(让异常逃逸) + C++11 默认行为
这是最糟的情况。
class BadDestructor {
public:
// C++11 默认隐含 noexcept
~BadDestructor() {
// 假设这里发生了错误,抛出了异常
throw std::runtime_error("Error!");
}
};
int main() {
BadDestructor bd;
// 程序运行到这里,bd 销毁 -> 抛出异常 -> 触犯 noexcept -> 直接 Crash!
// 你甚至没机会在 main 函数外层捕获它。
return 0;
}
结果: 程序直接暴毙。
情况 B:显式声明 noexcept(false) (试图回到老版本)
这是试图绕过 C++11 的保护机制。
class RiskyDestructor {
public:
// 告诉编译器:允许我抛出异常
~RiskyDestructor() noexcept(false) {
throw std::runtime_error("Risk!");
}
};
结果:
-
如果此时程序没有其他异常正在处理,异常可以抛出。但这会导致资源泄漏(Item 8 提到的)。
-
如果此时程序正在处理另一个异常(栈展开期间),程序依然会直接暴毙(双重异常)。 结论: 依然非常危险,不可取。
情况 C:遵循 Item 8(在内部捕获)
这是唯一的正确做法。
class GoodDestructor {
public:
// 依然是默认的 noexcept,但这没关系,因为我们不让异常跑出去
~GoodDestructor() {
try {
doSomethingDangerous();
} catch (...) {
// Item 8 核心:在析构函数内部把异常吞掉
// 记录日志...
}
// 异常在这里终结,没有触碰 noexcept 红线
}
};
结果: 程序安全存活,继续运行。
3. 总结
-
Item 8 并没有过时,它依然是教你如何写代码的唯一准则。
-
noexcept 只是加大了违反 Item 8 的惩罚力度(从“可能出错”变成了“立即死亡”)。
-
正解: 依然要在析构函数内部使用
try-catch包裹所有可能抛出异常的代码,