C++ 异常与异常安全

最后更新: 2024-05-28 17:55:25 +0800 +0800
原文以英文撰写。您正在阅读AI翻译版本。

异常将失败操作的报告与将其传播到能够响应的边界分离。 异常把控制权转移给匹配的处理器;控制向外传播时,C++ 会按构造的逆序销毁所有退出作用域中已完全构造的自动对象。 资源获取即初始化(RAII)因此使正常清理路径和异常清理路径相同。

cpp
void load_configuration(std::filesystem::path path) {
    std::ifstream input{path};             // Closed during unwinding.
    if (!input) {
        throw std::runtime_error{"cannot open configuration"};
    }
    // Parsing may throw; input still closes.
}

try {
    load_configuration("app.conf");
} catch (const std::exception& error) {
    report(error.what());
}

按值抛出异常对象,并通过 const 引用捕获多态异常类型。 按值捕获基类异常会切割派生对象。catch (...) 只能位于能够恢复不变量、转换错误或有意终止的边界; 不得在状态已损坏时静默继续。

展开和 RAII #

抛出异常的构造函数不会产生完成的对象,但其已完全构造的基类和成员子对象仍会运行析构函数。 析构函数不能在另一个异常活动期间让异常逸出,因为这会调用 std::terminate。 因此析构函数、智能指针删除器和回滚保护器都应不抛出异常。 清理可能失败时,应暴露一个在销毁前报告失败的显式操作。

cpp
class Transaction {
public:
    explicit Transaction(Database& db) : db_{db} { db_.begin(); }

    void commit() {
        db_.commit();
        committed_ = true;
    }

    ~Transaction() noexcept {
        if (!committed_) {
            db_.rollback_noexcept();
        }
    }

private:
    Database& db_;
    bool committed_ = false;
};

析构函数只尝试回滚;commit() 仍是可能报告错误的操作。 每个公共操作都必须定义失败后保留的状态。

异常安全保证 #

操作可以声明以下保证之一:

  1. 不抛出保证: 不传播异常。
  2. 强保证: 如果失败,可观察状态不变。
  3. 基本保证: 不变量成立且资源不泄漏,但状态可能改变。

不能仅因为类型使用 RAII,就推断其保证超过基本保证。 RAII 防止泄漏,但不会恢复被覆盖的值或外部副作用。 对于可能抛出的修改操作,应记录其保证。

提交、回滚和不变量 #

强保证遵循准备-提交结构:在替换状态中执行可能抛出的工作,然后执行不抛出的提交。 提交通常是 swap、指针替换或数据库事务提交。

cpp
void Account::replace_limits(Limits requested) {
    requested.validate();                  // May throw; `limits_` is unchanged.
    limits_.swap(requested);               // Commit must not throw.
}

当替换状态成本过高或外部操作无法撤销时,应采用基本保证。 定义调用方在每次失败后都能依赖的不变量,例如“对象仍可使用且余额非负”。 外部系统没有事务或补偿操作时,不要声称不可逆副作用已经回滚。

noexcept 是终止契约 #

noexcept 会在某些场景改变函数类型,并承诺逸出函数的异常会导致 std::terminate。 它不会抑制异常,也不会按定义让函数体更快。 当终止是预期响应且每个被调用操作都符合该契约时,才标记函数为 noexcept。

除非另行声明,析构函数隐式不抛出。只有当调用方能在非展开路径捕获析构异常时, 显式将析构函数设为 noexcept(false) 才合适;如果另一个异常已经在传播,这仍不安全。

noexcept 也会影响标准库策略。例如 std::vector 重新分配时, 如果元素移动构造函数不抛出,它可以移动元素并保持强保证。 如果移动可能抛出而复制可用,实现可以改为复制。 如果移动可能抛出且复制不可用,vector 可能无法为该次重新分配提供强保证。 这是容器特定的后果,不是虚假标记操作为 noexcept 的理由。

设计异常边界 #

保持普通代码对异常中立:使用拥有对象获取资源,在本地维护不变量, 并让失败到达了解恢复策略的层。需要目标错误模型时,在进程、请求、线程或外部函数边界转换异常。 绝不允许 C++ 异常跨越不允许异常的接口。

异常不是验证的替代品。在修改前验证前置条件;当失败属于正常的局部控制流时, 使用 C++23 std::expected 等返回值;当调用方需要非局部传播且清理可靠时使用异常。

评论

在 GitHub 上阅读或评论(发表评论需要登录)