79 / 80 · C++11 · 约 12 分钟
异常安全:Guarantees、noexcept 与提交点
异常安全关心失败以后对象还剩什么,而不是代码里有没有 catch。用 RAII 保住资源,用先准备再提交实现强保证,并让 noexcept 只描述真正不会向外传播异常的操作;copy-and-swap 是有成本和前提的策略,不是万能答案。
先为失败路径写下契约
基本保证要求失败后无资源泄漏、对象不变量仍成立,但值可以改变。强保证要求失败不改变所承诺的可观察状态,像操作从未提交。不抛出保证表示不会通过异常报告失败,不等于所有业务请求一定成功;返回错误码仍可失败。没有保证时,调用者甚至不能可靠地继续使用对象。
这些保证属于具体操作,而不是整个类的统一标签。下面 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 清理的结构分析。
继续查证
- cppreference:Exceptions / Exception safety
- C++ 工作草案:[except.spec] Exception specifications
- C++ Core Guidelines:E.12 使用 noexcept 表达不抛出意图
标准草案链接会随工作草案更新;本文版本标记对应示例最低要求,不表示草案中的所有新规则都适用于旧标准。