异常安全保证
学习异常安全的三个级别──基本保证、强保证和无抛出保证──以及 RAII 和 copy-and-swap 如何提供这些保证。
三种保证
异常安全不是关于阻止异常──而是关于定义当异常确实发生时你的代码保证什么。你编写的每个函数都隐式地提供以下级别之一:
1. 无抛出保证(最强): 函数永远不抛出。它总是成功。示例:析构函数、swap、简单的 getter。标记为 noexcept。
2. 强保证(提交或回滚): 如果函数抛出异常,程序状态与调用前完全一样。就好像该函数从未被调用过。可以想象一个数据库事务,要么完全提交,要么完全回滚。
3. 基本保证(最低可接受): 如果函数抛出异常,程序处于有效但未指定的状态。没有资源泄漏,所有不变量成立,但确切的状态可能与之前不同。
还有第四种不可接受的级别:无保证──异常可能使程序处于损坏状态并泄漏资源。生产代码决不能提供低于基本保证的级别。
RAII:异常安全的基石
RAII(资源获取即初始化)将资源的生命周期绑定到对象的生命周期。构造函数获取资源;析构函数释放资源。由于析构函数在栈展开期间运行,RAII 自动提供基本保证。
#include <fstream>
#include <string>
#include <stdexcept>
#include <iostream>
class DatabaseConnection {
public:
explicit DatabaseConnection(const std::string& conn_str)
: conn_str_(conn_str), connected_(false) {
// Simulate connection
if (conn_str.empty()) {
throw std::invalid_argument("empty connection string");
}
connected_ = true;
std::cout << "Connected to: " << conn_str_ << '\n';
}
~DatabaseConnection() {
if (connected_) {
// Cleanup never throws
std::cout << "Disconnected from: " << conn_str_ << '\n';
connected_ = false;
}
}
void execute(const std::string& query) {
if (!connected_) throw std::runtime_error("not connected");
// Even if this function throws, the destructor
// will still close the connection during unwinding
std::cout << "Executing: " << query << '\n';
}
// Non-copyable, movable
DatabaseConnection(const DatabaseConnection&) = delete;
DatabaseConnection& operator=(const DatabaseConnection&) = delete;
DatabaseConnection(DatabaseConnection&& other) noexcept
: conn_str_(std::move(other.conn_str_)),
connected_(other.connected_) {
other.connected_ = false;
}
private:
std::string conn_str_;
bool connected_;
};
void process_data() {
DatabaseConnection db("postgres://localhost/mydb");
db.execute("SELECT * FROM users");
// If execute() throws, ~DatabaseConnection() still runs
// — the connection is always properly closed
}用 Copy-and-Swap 实现强保证
Copy-and-swap 惯用法为赋值运算符提供强异常保证。思路是:先创建一个副本(这可能抛出异常),然后与副本交换(这永远不会抛出)。如果复制抛出异常,原始对象不受影响。
#include <algorithm>
#include <utility>
#include <cstddef>
class Matrix {
public:
Matrix(std::size_t rows, std::size_t cols)
: rows_(rows), cols_(cols),
data_(new double[rows * cols]{}) {}
// Copy constructor — may throw (allocates memory)
Matrix(const Matrix& other)
: rows_(other.rows_), cols_(other.cols_),
data_(new double[other.rows_ * other.cols_]) {
std::copy(other.data_,
other.data_ + rows_ * cols_,
data_);
}
// Move constructor — never throws
Matrix(Matrix&& other) noexcept
: rows_(other.rows_), cols_(other.cols_),
data_(other.data_) {
other.data_ = nullptr;
other.rows_ = other.cols_ = 0;
}
~Matrix() { delete[] data_; }
// No-throw swap — the key ingredient
friend void swap(Matrix& a, Matrix& b) noexcept {
using std::swap;
swap(a.rows_, b.rows_);
swap(a.cols_, b.cols_);
swap(a.data_, b.data_);
}
// Copy-and-swap assignment: STRONG guarantee
// Takes parameter by value (invokes copy/move ctor)
Matrix& operator=(Matrix other) noexcept {
swap(*this, other);
return *this;
// 'other' is destroyed here, freeing old data
}
private:
std::size_t rows_, cols_;
double* data_;
};noexcept 和移动操作
STL 在很大程度上依赖移动构造函数的 noexcept。考虑 std::vector::push_back:当 vector 必须重新分配时,它需要将元素从旧缓冲区转移到新缓冲区。
- 如果元素的移动构造函数是 noexcept,vector 移动元素(快速)。
- 如果移动构造函数可能抛出异常,vector 则复制元素(慢,但安全──如果复制抛出异常,原始缓冲区仍然完好)。
这就是为什么将移动构造函数标记为 noexcept 不仅仅是良好实践──对于存储在容器中的类型,它可能带来 10 倍甚至 100 倍的性能差异。
你可以在编译时使用 std::is_nothrow_move_constructible_v 进行检查。STL 在内部使用这个类型特征来决定其策略。
识别保证级别
在编写函数时,思考它提供的是哪种保证。以下是一个在单个类中展示所有三种级别的示例。
#include <vector>
#include <string>
#include <algorithm>
#include <stdexcept>
class UserRegistry {
public:
// NO-THROW: simple getter, can never fail
std::size_t size() const noexcept {
return users_.size();
}
// STRONG GUARANTEE: either the user is added or nothing changes
void add_user(const std::string& name) {
if (name.empty()) {
throw std::invalid_argument("name cannot be empty");
}
// If push_back throws (e.g., bad_alloc during reallocation),
// vector guarantees the original data is intact.
users_.push_back(name);
}
// BASIC GUARANTEE: if an exception occurs partway through,
// some users may have been processed and some may not.
// But the object is still in a valid state (no leaks, no corruption).
void normalize_all() {
for (auto& user : users_) {
// std::transform could theoretically throw
// (e.g., if user string allocation fails during tolower)
std::transform(user.begin(), user.end(), user.begin(),
[](unsigned char c) { return std::tolower(c); });
}
}
private:
std::vector<std::string> users_;
};遵循以下准则以一致地编写异常安全的代码。
- 到处使用 RAII:永远不要持有裸资源──将它们包装在智能指针或 RAII 包装器中
- 将移动构造函数和 swap 标记为
noexcept:这启用 STL 优化并提供无抛出保证 - 在修改状态之前完成所有可能抛出异常的工作:先分配、计算和验证──然后用无抛出操作如
swap提交 - 优先使用 copy-and-swap 惯用法来编写赋值运算符,以最小的努力获得强保证
- 析构函数决不能抛出异常:从 C++11 起,它们隐式地是
noexcept
这些模式会破坏异常安全保证并导致微妙的错误。
- 在完成所有可能抛出异常的操作之前修改状态:如果第 2 步(共 3 步)抛出异常,第 1 步已经改变了对象
- 忘记
new可能抛出std::bad_alloc:每次动态分配都是潜在的抛出点 - 非
noexcept移动构造函数:STL 会默默地回退到复制,严重影响性能 - 过于宽泛地捕获异常:
catch(...)不重新抛出会隐藏真正的问题
- 三种保证是:无抛出(总是成功)、强保证(提交或回滚)和基本保证(有效但未指定的状态)
- RAII 是基石──析构函数在展开期间运行,确保资源总是被释放
- Copy-and-swap 提供强保证:先完成所有可能抛出的工作,然后
swap(noexcept) noexcept移动构造函数至关重要──没有它们,std::vector在重新分配时会回退到复制- 每个函数都应至少提供基本保证;在可能的情况下提供无抛出保证
测验 — 检验你的知识
(20 经验值)1. 强异常保证承诺了什么?
2. 当移动构造函数不是 `noexcept` 时,为什么 `std::vector` 会复制元素而不是移动?
3. 在 copy-and-swap 惯用法中,为什么赋值运算符按值接收参数?