C++ / a working model

79 / 80   ·   C++11   ·   约 12 分钟

异常安全:Guarantees、noexcept 与提交点

先记住这句话

异常安全关心失败以后对象还剩什么,而不是代码里有没有 catch。用 RAII 保住资源,用先准备再提交实现强保证,并让 noexcept 只描述真正不会向外传播异常的操作;copy-and-swap 是有成本和前提的策略,不是万能答案。

本篇内容
  1. 先为失败路径写下契约
  2. RAII 清理资源,不会自动回滚值
  3. noexcept 是边界承诺,不是异常屏蔽器
  4. copy-and-swap 的适用边界
  5. 运行示例
  6. 动手练习

先为失败路径写下契约

基本保证要求失败后无资源泄漏、对象不变量仍成立,但值可以改变。强保证要求失败不改变所承诺的可观察状态,像操作从未提交。不抛出保证表示不会通过异常报告失败,不等于所有业务请求一定成功;返回错误码仍可失败。没有保证时,调用者甚至不能可靠地继续使用对象。

这些保证属于具体操作,而不是整个类的统一标签。下面 Scores 的不变量是所有分数都非负。replace 在临时 vector 中完成校验,成功后才交换;任何校验异常或准备阶段分配失败都不会改动旧值。其强保证仅针对对象状态,不声称能撤销外部日志、网络请求或已经完成的文件写入。

RAII 清理资源,不会自动回滚值

异常传播到匹配处理器时,栈展开会逆序销毁沿途已构造完成的自动对象。vector、智能指针和锁守卫把清理责任绑定到生命周期,避免每个出口手写释放。若非委托构造函数抛出,完整对象的析构函数不会执行,但已完成构造的成员和基类会清理。若委托构造的目标已成功完成,而委托构造函数体随后抛出,则会调用该对象的析构函数。

然而先改成员再抛异常,即使没有泄漏,也不自动满足强保证。可靠做法是先准备候选状态,再用不抛出的操作提交。下例使用默认分配器的 vector 交换;自定义分配器场景要检查传播属性及分配器相等的前提,不能假设任意容器交换都安全。

noexcept 是边界承诺,不是异常屏蔽器

异常一旦试图越过 noexcept 函数边界,就调用 std::terminate,而不是返回默认值。函数内部可以抛出并自行捕获,只要异常不逃逸。析构和清理操作应避免向外抛出;栈展开期间析构函数再传播异常也会终止程序。处理异常通常按 const 引用捕获,重新传播用 throw; 保留原异常。

可信的 noexcept 移动构造让容器在重新分配时更容易维持强保证;若移动可能抛出且能复制,容器常选择复制。具体保证仍须读对应操作及元素类型的条件。不要给执行分配的 replace 随手加 noexcept,这会把本来可恢复的失败变成进程终止。

copy-and-swap 的适用边界

复制赋值可先构造 Scores temporary(other);,再用不抛出的 swap 提交。复制失败时原值不变,自赋值也自然安全;下例保留这个过程供观察。交换之后临时对象持有旧资源,在退出函数时释放,提交点清晰。

代价是临时副本可能额外分配,并丢失复用原容量的机会;多个已各自管理资源的成员通常先考虑 Rule of Zero。这里自定义赋值还会抑制隐式移动成员的生成,所以不能顺便宣称它具备高效移动语义。保存为 exceptions.cpp,用 g++ -std=c++11 -Wall -Wextra -pedantic exceptions.cpp -o exceptions 编译,再运行 ./exceptions

容易答错的地方

  • 捕获所有异常后静默继续,不等于恢复了不变量;应在能恢复、补充上下文或建立错误边界的位置处理异常。
  • 强保证依赖最后的提交操作不会失败;如果复制成功后 swap 仍可能抛出,就不能仅凭 copy-and-swap 这个名字宣称事务性。

运行一个例子

最低标准 C++11 · 完整程序 · 下载 .cpp

#include <cassert>
#include <initializer_list>
#include <iostream>
#include <stdexcept>
#include <utility>
#include <vector>

class Scores {
    std::vector<int> values_;
public:
    explicit Scores(std::initializer_list<int> values) {
        replace(std::vector<int>(values));
    }
    Scores(const Scores&) = default;

    void swap(Scores& other) noexcept {
        values_.swap(other.values_);
    }
    Scores& operator=(const Scores& other) {
        Scores temporary(other);
        swap(temporary);
        return *this;
    }
    void replace(std::vector<int> candidate) {
        for (int value : candidate) {
            if (value < 0) {
                throw std::invalid_argument("negative score");
            }
        }
        values_.swap(candidate);
    }
    const std::vector<int>& values() const noexcept {
        return values_;
    }
};

int main() {
    Scores scores{10, 20};
    const auto before = scores.values();
    bool rejected = false;
    try {
        scores.replace({30, -1});
    } catch (const std::invalid_argument&) {
        rejected = true;
    }
    assert(rejected);
    assert(scores.values() == before);

    Scores replacement{40, 50};
    scores = replacement;
    assert(scores.values() == replacement.values());
    const Scores& same = scores;
    scores = same;
    assert((scores.values() == std::vector<int>{40, 50}));
    scores.replace({60});
    assert((scores.values() == std::vector<int>{60}));
    static_assert(noexcept(scores.swap(replacement)),
                  "commit must not throw");
    std::cout << scores.values().front() << '\n';
}

在本地编译

g++ -std=c++11 -Wall -Wextra -Wpedantic -pthread tooling-exceptions.cpp -o example && ./example

预期结果

60

CHECK YOUR UNDERSTANDING

合上答案,试着解释。

如果 replace 先执行 values_ = std::move(candidate),再检查 values_ 是否有负数,会保留哪项保证?写出恢复原契约的顺序,并说明现有断言验证了什么。

查看参考答案

负数已经进入成员后才抛出,既改变原值,又破坏“所有分数非负”的不变量,因此对这个类连基本保证也没有。正确顺序是检查局部 candidate 的所有元素,全部通过才执行 values_.swap(candidate)。例中的 rejected 确认失败实际发生,values() == before 确认失败后原值保留;成功替换与自赋值也有断言。它没有模拟内存分配失败;该路径的保证来自副本准备先于提交、临时资源由 RAII 清理的结构分析。

继续查证

标准草案链接会随工作草案更新;本文版本标记对应示例最低要求,不表示草案中的所有新规则都适用于旧标准。

回到目录