Item 8:阻止异常逃离 destructor

对齐 Effective C++ 第三版 Item 8:解释 stack unwinding 中析构再抛异常为何 terminate,设计显式 close/commit 与 noexcept destructor fallback,并验证失败策略。

学习目标

  • 能解释正常作用域退出与 stack unwinding 中 destructor 抛异常的不同风险,并复现双异常触发 terminate
  • 能设计显式 close/commit、幂等状态与 noexcept destructor fallback,使调用方处理可恢复失败
  • 能比较记录吞掉、rollback 与 terminate 策略,并以故障注入验证资源、事务和容器析构路径
Destructor failure / no-throw strategy正常控制流报告失败,析构边界只做最终策略prevent exceptions fromleaving destructorsno-throw boundarycatch all / strategystack unwindingprimary + secondaryterminate riskclose functionobservable failureretry / commit stateswallow exceptionrecord / rollback或终止程序idempotent close成功才 Closeddestructor fallback不得再抛process-critical invariant决定是否终止test process独立进程退出码析构不负责报告业务失败;它只负责在生命周期边界维持 no-throw 和不变量
把可观察错误移到 close/commit,把析构保留为不抛的最后防线,并用独立进程验证 terminate 策略。

析构异常策略实验

先预测:清理失败该报告、吞掉还是终止?

先预测异常传播路径和状态提交,再切换场景查看 no-throw 证据。

观察

stack unwinding 已在传播 primary exception;destructor 再抛 secondary exception 会让运行时无法选择,通常直接 terminate。

决策

在 destructor 建立 no-throw boundary,catch all 并执行不会再抛的记录、回滚或终止策略;可恢复失败交给显式 close。

当前场景 · prevent exceptions from leaving destructors / stack unwinding

在 destructor 建立 no-throw boundary,catch all 并执行不会再抛的记录、回滚或终止策略;可恢复失败交给显式 close。

从“第二个异常没有去处”开始

对象析构既发生在正常离开作用域,也发生在异常传播的

如果一个 primary exception 正在传播,某个 destructor 又抛出另一个异常,运行时无法同时传播两者,通常调用 std::terminate

使外层 catch 无法选择保留哪一个。 Prevent exceptions from leaving destructors(阻止异常逃离析构函数)覆盖整个析构链:析构函数体、成员析构和基类析构产生的异常都必须在 no-throw boundary 内按策略处理。

一个元素析构可终止整个容器销毁

原书以数据库连接为例;数组或 vector 中多个对象析构时问题更明显:

class DBConnection {
public:
    void close(); // 可能抛异常
};
 
class DBConn {
public:
    ~DBConn()
    {
        db_.close(); // 异常可逃离 destructor
    }
private:
    DBConnection db_;
};

vector<DBConn> 销毁时第一个元素抛异常,剩余元素的清理和程序控制流都可能受破坏。

不是可在上层恢复的普通错误。

现代 destructor 默认倾向 noexcept

用户声明 destructor 通常隐式具有 noexcept 条件,取决于成员/基类析构。显式写 noexcept 可强调接口,但不能自动处理内部失败。

class DBConn {
public:
    ~DBConn() noexcept
    {
        try {
            db_.close();
        } catch (...) {
            record_cleanup_failure(std::current_exception());
        }
    }
private:
    DBConnection db_;
};

关键是 record_cleanup_failure 自身也不能抛;日志分配失败、锁失败都要考虑。

吞掉还是终止取决于不变量

适合失败不会破坏进程核心不变量,且调用方已没有恢复动作的 best-effort cleanup。

适合继续运行会产生数据损坏或安全风险。主动终止比异常偶然逸出更明确,但仍应让正常调用方有机会先处理。

~CriticalSession() noexcept
{
    try {
        rollback_if_active();
    } catch (...) {
        emergency_log();
        std::terminate();
    }
}

决定是否终止,而不是“析构永远吞掉”。

把可处理失败交给普通函数

class DBConn {
public:
    explicit DBConn(DBConnection connection)
        : db_{std::move(connection)} {}
 
    void close()
    {
        if (closed_)
            return;
        db_.close();      // 失败时调用方可 catch/retry
        closed_ = true;  // 只在成功后提交状态
    }
 
    ~DBConn() noexcept
    {
        if (!closed_)
            close_noexcept();
    }
private:
    void close_noexcept() noexcept;
    DBConnection db_;
    bool closed_{};
};

显式 close 让用户处理异常;destructor 是最后兜底,不能替代正常错误处理。

状态提交顺序决定重试语义

某些底层 API 失败后句柄状态未知,不能简单重试。wrapper 必须根据 API 契约定义 Active、Closed、Failed/Unknown 状态。

enum class CloseState { Active, Closed, Failed };
 
void Connection::close()
{
    if (state_ != CloseState::Active)
        return;
    try {
        native_close(handle_);
        state_ = CloseState::Closed;
    } catch (...) {
        state_ = CloseState::Failed;
        throw;
    }
}

destructor fallback 对此只能按协议记录或终止,不能盲目重复操作造成 double close。

commit 不应藏在 destructor

事务、文件 flush、网络发送等不应只在 destructor 执行,因为调用方无法可靠得知失败。

class Transaction {
public:
    void commit();
    ~Transaction() noexcept
    {
        if (!committed_)
            rollback_noexcept();
    }
private:
    bool committed_{};
};

RAII 负责“未提交则回滚”,调用方负责“提交是否成功”。这比 destructor 自动 commit 更符合异常安全。

scope guard 也必须 no-throw

auto rollback = ScopeExit{[&]() noexcept {
    restore_previous_state_noexcept();
}};
 
apply_changes();
rollback.release();

lambda 标记 noexcept 并不证明内部调用真的不抛,需要静态约束或内部 catch。通用 ScopeExit 可要求 std::is_nothrow_invocable_v<F&>

第三方 destructor 会抛怎么办

不能修改第三方类时,在 wrapper 的显式 close 中调用并传播,在 destructor 中 catch all。若第三方 destructor 本身 noexcept(false),把它作为成员会影响外层析构异常规范;可通过动态所有权和受控销毁隔离,但最终仍需处理。

void ThirdPartyOwner::destroy_noexcept() noexcept
{
    try {
        resource_.reset();
    } catch (...) {
        report_without_throwing(std::current_exception());
    }
}

在析构中保存到外部线程安全诊断通道仍需考虑分配与生命周期,不能保存回即将销毁对象。

选择失败策略

文件描述符 close、临时目录删除常属此类;事务 commit 不是。

是把 destructor 风险移回正常控制流的关键。

故障注入覆盖两种退出路径

先预测:正常作用域退出时 destructor 抛异常能否被外层 catch?若作用域内先抛 primary exception,结果又如何?分别运行,记录 terminate handler。

std::set_terminate([] {
    terminateObserved.store(true);
    std::_Exit(99);
});

terminate 测试应放独立进程,避免结束测试 runner。

  • 正常显式 close 失败可被调用方 catch,状态符合协议。
  • close 成功后 destructor 不重复调用底层 close。
  • 忘记 close 时 destructor fallback 执行一次且不抛。
  • primary exception unwinding 期间 cleanup 失败不产生 secondary exception。
  • rollback 失败按策略记录或 terminate,测试进程退出码明确。
  • 容器中一个元素 cleanup 失败不会阻止其余元素按策略清理。

小结

  • stack unwinding 中 destructor 再抛 secondary exception 会触发 terminate
  • destructor 应建立 no-throw cleanup boundary,不能让清理异常逃离
  • 可恢复关闭失败通过 explicit close 暴露,destructor 只做幂等 fallback
  • commit 属于可观察业务操作,应显式执行;destructor 更适合 no-throw rollback
  • 吞掉、记录或 terminate 取决于继续运行是否破坏核心不变量
  • 正常退出和异常 unwinding 必须分别做故障注入测试

资料与写作方式声明

本章以Effective C++, Third Edition, Item 8权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

名词解释

本章出现的专业名词,用大白话再讲一遍。

stack unwinding

异常传播时逆序销毁自动对象并退出调用栈的过程。

secondary exception

已有异常传播时清理代码再次抛出的异常。

escaping destructor exception

越过对象析构边界继续传播的异常。

std::terminate

异常机制无法继续时立即终止程序的运行库函数。

noexcept contract

承诺异常不越过函数边界的接口约束。

no-throw cleanup boundary

析构内部捕获并处理所有清理失败的边界。

swallow-and-log

记录析构故障后吞掉异常继续运行的策略。

terminate-on-failure

清理失败破坏不变量时主动终止的策略。

process-critical invariant

一旦无法恢复就不能安全继续的进程约束。

explicit close

调用方主动执行并可观察失败的关闭接口。

idempotent close

多次调用与一次调用具有同一最终效果的关闭。

close state commit

成功后才提交 Closed,失败保留可识别状态的协议。

indeterminate cleanup state

失败后无法确认资源是否已部分释放的状态。

explicit commit

调用方必须观察结果的持久业务提交。

destructor rollback

析构中不抛异常地尝试恢复未提交状态。

scope guard

离开作用域时执行清理动作的 RAII 对象。

guard release

取消 scope guard 清理,表示主操作已提交。

exception firewall wrapper

封装第三方抛异常资源并统一处理的边界对象。

exception_ptr

可保存并稍后处理的类型擦除异常对象。

best-effort cleanup

无法报告成功、尽力执行并记录失败的清理。

observable failure API

让调用方必须观察可失败操作结果的接口。

cleanup failure injection

在指定清理阶段主动制造失败的测试方法。

练习

  1. 问题 1:prevent exceptions from leaving destructors 与 stack unwinding。 底层 close 可能抛且失败后状态未知,设计 wrapper 的显式接口、状态和析构策略。
  1. 问题 2:close function 与 swallow exception。 为数据库事务解释为什么 destructor 不应自动 commit,并设计异常路径。
  1. 问题 3:终止程序。 设计独立进程测试证明 unwinding 中析构抛异常触发 terminate,并验证修复。

讨论

评论区加载中…