Item8–别让异常逃离析构函数

1. 别让异常逃离析构函数

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 要这么做?

这并不是为了给程序员找麻烦,而是基于两个核心考量:

  1. 强制执行最佳实践(Item 8): 既然《Effective C++》Item 8 早就告诉大家“别让异常逃离析构函数”,标准委员会决定在语言层面强制执行这一规则。这减少了未定义行为的风险。

  2. 优化与移动语义(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!");
    }
};

结果:

  1. 如果此时程序没有其他异常正在处理,异常可以抛出。但这会导致资源泄漏(Item 8 提到的)。

  2. 如果此时程序正在处理另一个异常(栈展开期间),程序依然会直接暴毙(双重异常)。 结论: 依然非常危险,不可取。

情况 C:遵循 Item 8(在内部捕获)

这是唯一的正确做法。

class GoodDestructor {
public:
    // 依然是默认的 noexcept,但这没关系,因为我们不让异常跑出去
    ~GoodDestructor() {
        try {
            doSomethingDangerous();
        } catch (...) {
            // Item 8 核心:在析构函数内部把异常吞掉
            // 记录日志...
        } 
        // 异常在这里终结,没有触碰 noexcept 红线
    }
};

结果: 程序安全存活,继续运行。


3. 总结

  • Item 8 并没有过时,它依然是教你如何写代码的唯一准则。

  • noexcept 只是加大了违反 Item 8 的惩罚力度(从“可能出错”变成了“立即死亡”)。

  • 正解: 依然要在析构函数内部使用 try-catch 包裹所有可能抛出异常的代码,不管 C++ 标准怎么变,析构函数不能吐出异常这一物理定律没有变

发表评论