跳至内容

异常安全保证

学习异常安全的三个级别──基本保证、强保证和无抛出保证──以及 RAII 和 copy-and-swap 如何提供这些保证。

三种保证

异常安全不是关于阻止异常──而是关于定义当异常确实发生时你的代码保证什么。你编写的每个函数都隐式地提供以下级别之一:

1. 无抛出保证(最强): 函数永远不抛出。它总是成功。示例:析构函数、swap、简单的 getter。标记为 noexcept。

2. 强保证(提交或回滚): 如果函数抛出异常,程序状态与调用前完全一样。就好像该函数从未被调用过。可以想象一个数据库事务,要么完全提交,要么完全回滚。

3. 基本保证(最低可接受): 如果函数抛出异常,程序处于有效但未指定的状态。没有资源泄漏,所有不变量成立,但确切的状态可能与之前不同。

还有第四种不可接受的级别:无保证──异常可能使程序处于损坏状态并泄漏资源。生产代码决不能提供低于基本保证的级别。

RAII:异常安全的基石

RAII(资源获取即初始化)将资源的生命周期绑定到对象的生命周期。构造函数获取资源;析构函数释放资源。由于析构函数在栈展开期间运行,RAII 自动提供基本保证。

raii_db.cpp
#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 惯用法为赋值运算符提供强异常保证。思路是:先创建一个副本(这可能抛出异常),然后与副本交换(这永远不会抛出)。如果复制抛出异常,原始对象不受影响。

copy_and_swap.cpp
#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 在内部使用这个类型特征来决定其策略。

识别保证级别

在编写函数时,思考它提供的是哪种保证。以下是一个在单个类中展示所有三种级别的示例。

guarantee_levels.cpp
#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 惯用法中,为什么赋值运算符按值接收参数?