文章

C++异常安全

异常安全是指在发生异常时,程序能够保持容器或数据结构的有效性,避免资源泄漏,并保证容器状态的一致性。常见的异常安全级别有基本保证、强保证和不变性保证。

C++异常安全

C++异常安全

异常安全(Exception Safety) 是指在程序中发生异常时,代码能够保证某些特定的不变性,确保程序的行为不会变得不可预测或导致资源泄漏。异常安全性使得在出现异常时,程序能够以预期的方式恢复或终止,不会产生副作用。

异常安全的核心目标

  • 保持程序的状态一致性:在异常发生时,程序应保持处于有效状态,避免未定义行为。
  • 避免资源泄漏:确保分配的资源(如内存、文件句柄、锁等)能够正确释放。
  • 保证不产生副作用:即使发生异常,程序不应对外部环境产生不良影响。

异常安全的级别

C++ 标准库定义了几种常见的异常安全级别,开发者在设计函数时要考虑不同的安全性要求:

  1. 无异常保证(No-throw guarantee)

    • 即使抛出异常,函数也不保证任何安全性。发生异常时,可能导致资源泄漏或不一致的状态。
    1
    2
    3
    
    void unsafeFunction() {
        // 可能抛出异常,但没有保证
    }
    
  2. 基本保证(Basic Guarantee)

    • 在发生异常时,函数保证不会造成程序状态的不一致,即保证不泄漏资源,但不能保证数据的具体值是有效的。
    • 如果异常发生,资源会被释放,程序不会处于错误状态,但可能会产生部分修改的数据。
    1
    2
    3
    4
    
    void basicGuarantee() {
        std::vector<int> v;
        v.push_back(1);  // 如果此时发生异常,vector v 会被正确析构
    }
    
  3. 强保证(Strong Guarantee)

    • 强保证意味着在发生异常时,程序的状态不会改变。即,调用此函数之前的所有状态都能保持不变(即使发生异常,调用函数的效果会像没有发生过一样)。
    • 这要求在实现过程中使用一些技巧,如回滚操作、复制机制等。
    1
    2
    3
    4
    5
    
    void strongGuarantee() {
        std::vector<int> v = {1, 2, 3};
        std::vector<int> backup = v;  // 保存一个备份
        v.push_back(4);  // 如果发生异常,v 会恢复到备份状态
    }
    
  4. 不抛出异常保证(No-throw Guarantee)

    • 这种保证意味着函数不会抛出任何异常。通常在非常关键的操作中要求此保证。
    • 比如,swap() 在 C++ 中被设计为无异常抛出,因为它保证不会引发异常。
    1
    2
    3
    4
    5
    
    void noThrowGuarantee() noexcept {
        int a = 1;
        int b = 2;
        std::swap(a, b);  // 该操作不会抛出异常
    }
    

异常安全的实现技巧

RAII

RAII(资源获取即初始化):通过对象的构造函数和析构函数管理资源,可以确保即使发生异常,资源也能被正确释放。

1
2
3
4
5
6
7
8
9
10
11
12
class FileGuard {
public:
    FileGuard(const std::string& filename) {
        file = fopen(filename.c_str(), "w");
        if (!file) throw std::runtime_error("File open failed");
    }
    ~FileGuard() {
        if (file) fclose(file);
    }
private:
    FILE* file;
};

复制-交换惯用法

复制-交换惯用法(Copy-and-Swap Idiom):通过使用拷贝构造和拷贝交换(copy-and-swap)实现强保证,保证异常安全。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class MyClass {
public:
    // 拷贝构造函数
    MyClass(const MyClass& other) {
        // 假设我们进行深拷贝(这里只是一个简单的例子)
        data = new int(*other.data);
    }

    // 赋值运算符(实现拷贝交换)
    MyClass& operator=(MyClass other) noexcept {
        swap(*this, other);  // 使用 swap 交换内容
        return *this;
    }

    // 交换函数
    friend void swap(MyClass& first, MyClass& second) {
        using std::swap;
        swap(first.data, second.data);  // 交换资源
    }

    // 析构函数
    ~MyClass() {
        delete data;  // 释放资源
    }

private:
    int* data;
};
  • 复制-交换惯用法的核心思想是通过传值方式接收参数,并且在函数内部通过swap操作确保交换对象的状态。这使得赋值操作即使在发生异常时也能保证强保证。
为什么能保证强异常安全?

传值接收对象

1
MyClass& operator=(MyClass other) noexcept {
  • 这里 operator= 通过传值接收 MyClass other 参数。这意味着当 operator= 被调用时,会先创建 other 的副本。即使在构造副本的过程中抛出异常,原始对象(*this)的状态也不会受到影响。
  • 由于传值的过程中会调用 MyClass 的拷贝构造函数(或移动构造函数),即使在构造过程中发生异常,也只会影响临时副本 other,不会影响已经存在的 *this

swap 操作

1
swap(*this, other);
  • swap 操作是原子性的,即使发生异常,它也保证:
    • 交换操作之前,*this 仍然保持其原有的状态。
    • 如果发生异常,原始的 *this 对象和副本 other 的状态都可以通过 swap 恢复到之前的一致性状态。
  • 如果 swap 期间发生异常,*thisother 都处于未修改的状态。也就是说,不会有部分赋值发生,整个赋值操作被“回滚”到原先状态。

noexcept 保证

  • operator= 声明为 noexcept,意味着此函数在执行过程中不会抛出任何异常。
  • noexcept 确保了 swap 操作也不会抛出异常(前提是 swap 本身也是 noexcept 的)。

STL 容器的异常安全性

1. std::vector

  • 插入(push_backinsertstd::vector 在内存不足时会进行 重新分配,这时会发生 内存重新分配操作,并将原有元素移动到新的位置。这个过程可能会抛出异常(比如 内存分配失败元素的拷贝/移动构造抛出异常)。因此,push_backinsert 的异常安全性是 基本保证,即可能修改容器,但不会导致资源泄漏。

    注意:如果在容器中使用的是移动语义(如使用 std::move),则可以提高效率,但可能会破坏原容器中的某些元素。

  • 删除(erasestd::vectorerase 操作通常是 基本保证,它会删除元素并移动后续元素。在移动或销毁元素时,可能会抛出异常,但不会导致内存泄漏。

  • 重分配(resizereserve:类似 push_back,如果发生 重新分配,容器会将现有元素移动到新位置,这个过程可能会抛出异常。

2. std::list

  • 插入(push_backinsertstd::list 是一个双向链表,插入操作通常不会导致内存重新分配,因此具有 强保证。如果插入的元素的拷贝构造或移动构造抛出异常,那么只有当前插入的元素会失败,其他部分保持不变。
  • 删除(erase:同样,删除操作的异常安全性通常是 强保证,因为它只会影响当前元素的删除,其他元素不会受到影响。
  • 节点分配(resizestd::list 不会像 std::vector 一样需要重新分配大量内存,因此删除和插入的操作较为简单。它们通常能够提供 强保证

3. std::mapstd::set

  • 插入(insert:在 std::mapstd::set 中插入元素时,可能会发生内存分配或重新平衡树结构。这些操作通常能够提供 强保证,即如果插入失败,容器的状态将保持不变。
  • 删除(eraseerase 操作通常不会抛出异常,且 强保证,因为它只会影响单个元素的删除,容器的其他部分保持一致。
  • 查找(findfind 操作不会抛出异常,因此提供 不变性保证

4. std::deque

  • std::deque 是一个双端队列,虽然它的实现与 std::vector 类似,但它不像 std::vector 那样每次都重新分配全部内存,而是将数据分块存储。其异常安全性类似于 std::vector,但要考虑到块级别的重新分配。
    • 插入:如果 std::deque 需要重新分配内存或扩展块,可能会导致异常。操作提供的是 基本保证
    • 删除:通常提供 强保证

5. std::unordered_mapstd::unordered_set

  • 插入(insert:插入时可能会重新哈希,发生内存分配。插入的异常安全性是 基本保证,因为插入时可能会抛出异常,但不会造成内存泄漏。
  • 删除:删除操作通常不会抛出异常,因此能够提供 强保证
  • 查找find 操作不会抛出异常,因此是 不变性保证

小结

  • std::vector:对于大部分操作提供 基本保证,但对于 push_back 等操作,内存重新分配时可能会抛出异常。
  • std::list:对于插入和删除操作提供 强保证,因为它不需要大规模的内存重新分配。
  • std::mapstd::set:对于插入和删除操作通常提供 强保证,特别是在平衡树操作时。
  • std::deque:类似于 std::vector,提供 基本保证,但比 vector 更加复杂。
  • std::unordered_mapstd::unordered_set:插入操作通常提供 基本保证,删除和查找提供 强保证不变性保证

总结

异常安全性保证了在程序抛出异常时,资源能够被正确管理,程序不会处于不一致或不可靠的状态。在实现异常安全的代码时,常常使用 RAII、回滚机制、复制-交换惯用法等技巧来实现不同的异常安全级别。

通过合理的设计和异常安全保证,程序能够在异常情况下保持稳定运行,避免数据损坏和资源泄漏。

本文由作者按照 CC BY 4.0 进行授权