异常安全:基本保证、强保证、不抛异常保证

异常安全这个话题,说实话,很多C++开发者容易忽略。我早年写代码时也踩过坑——程序跑着跑着突然崩了,查了半天发现是异常导致资源没释放。嗯,今天我们就来把异常安全的三个保证级别彻底讲清楚。

为什么需要异常安全?

你想想看,一个函数抛出异常时,会发生什么?栈展开,局部对象被析构。但如果你的代码里用了裸指针、手动管理的内存,或者某些操作只做了一半,那异常一抛,资源就泄漏了。

我在项目中遇到过最典型的情况:一个数据库插入操作,先写日志,再写主表,再写索引表。结果索引表写入时抛异常了,日志和主表的数据已经写进去了。这就导致数据不一致。说白了,这就是异常安全没做好。

三个保证级别

C++标准把异常安全分成三个级别,从弱到强依次是:

级别 含义 典型场景
基本保证 异常发生后,资源不泄漏,对象处于有效但不确定的状态 大多数标准库容器操作
强保证 操作要么完全成功,要么回滚到操作前的状态 事务性操作、数据库写入
不抛异常保证 函数承诺绝不抛出异常 析构函数、swap操作、noexcept函数

基本保证:底线要求

基本保证是最低要求。它承诺:异常发生后,程序不会泄漏资源,所有对象都处于有效状态——但这个状态可能不是原来的状态。

举个例子:

class DataBuffer {
    int* data_;
    size_t size_;
public:
    void resize(size_t newSize) {
        int* newData = new int[newSize];
        // 如果这里抛出异常,newData泄漏了
        // 但data_和size_还是旧值,对象状态一致
        std::copy(data_, data_ + size_, newData);
        delete[] data_;
        data_ = newData;
        size_ = newSize;
    }
};

这段代码其实连基本保证都没做到。为什么?因为new int[newSize]如果抛出std::bad_allocnewData还没赋值给data_,但data_指向的旧内存还在。嗯,这里其实没泄漏,但std::copy如果抛出异常呢?旧内存已经被删了,data_成了野指针。这就连基本保证都没了。

正确的做法是:

void resize(size_t newSize) {
    int* newData = new int[newSize];
    try {
        std::copy(data_, data_ + size_, newData);
    } catch(...) {
        delete[] newData;
        throw;  // 重新抛出
    }
    delete[] data_;
    data_ = newData;
    size_ = newSize;
}

或者更优雅的方式——用智能指针:

void resize(size_t newSize) {
    auto newData = std::make_unique<int[]>(newSize);
    std::copy(data_, data_ + size_, newData.get());
    delete[] data_;
    data_ = newData.release();
    size_ = newSize;
}
我的习惯:只要涉及资源管理,优先用RAII。智能指针、unique_ptr、shared_ptr,能用的地方尽量用。这样基本保证自动就有了。

强保证:事务语义

强保证比基本保证更严格。它承诺:操作要么完全成功,要么就像什么都没发生过一样。这其实就是数据库里的事务语义。

实现强保证的核心技巧是:先做所有可能失败的操作,最后一步才修改状态。如果中间任何一步失败,就回滚。

看个例子:

class UserManager {
    std::vector<User> users_;
    std::map<std::string, size_t> nameIndex_;
public:
    void addUser(const User& user) {
        // 先做所有可能失败的操作
        auto newUsers = users_;
        newUsers.push_back(user);  // 可能抛异常
        
        auto newIndex = nameIndex_;
        newIndex[user.name] = newUsers.size() - 1;  // 可能抛异常
        
        // 最后一步,用不抛异常的swap提交
        users_.swap(newUsers);
        nameIndex_.swap(newIndex);
    }
};

这里的关键是:swap操作必须是不抛异常的。标准库的std::vector::swapstd::map::swap都保证不抛异常。所以只要最后一步用swap,就能实现强保证。

我曾经踩过的坑:有一次我写了一个自定义容器,它的swap操作会分配内存。结果用它实现强保证时,swap本身抛异常了,整个状态全乱套了。后来我强制要求所有用于强保证的swap必须是不抛异常的。

不抛异常保证:最严格的承诺

不抛异常保证是最严格的级别。函数承诺:无论发生什么,我绝不抛出异常。这个保证通常用在析构函数、swap操作、以及一些noexcept标记的函数中。

C++11之后,我们可以用noexcept关键字显式声明:

class MyClass {
public:
    ~MyClass() noexcept {
        // 析构函数默认就是noexcept
        // 但如果这里抛异常,程序会直接terminate
    }
    
    void swap(MyClass& other) noexcept {
        // swap操作通常应该是不抛异常的
        std::swap(data_, other.data_);
    }
};

为什么析构函数必须是不抛异常的?因为栈展开过程中,如果析构函数抛出异常,而外层还有一个异常在传播,那程序就只能调用std::terminate了。说白了,这就是灾难。

我见过一个真实案例:某团队在析构函数里写了日志,日志库内部抛异常了。结果程序崩溃,日志没写出来,还导致数据损坏。后来他们把所有析构函数都加上了try-catch,确保绝不往外抛。

如何选择?

这三个级别不是越强越好。强保证实现起来成本高,有时候甚至做不到。我个人建议这样选:

  • 析构函数、swap、移动操作:必须是不抛异常保证。这是底线。
  • 资源管理类:至少基本保证。用RAII自动满足。
  • 事务性操作:尽量强保证。比如数据库写入、配置更新。
  • 性能敏感代码:基本保证就够了。强保证的拷贝开销可能太大。

核心原则:异常安全不是事后补救,而是设计时就考虑好的。用RAII管理资源,用swap实现提交,用noexcept标记关键操作。这样你的代码天然就是异常安全的。

知识体系图

下面这张图总结了异常安全的三个级别及其关系:

异常安全三个级别 基本保证 资源不泄漏 对象状态有效但不确定 RAII自动满足 最低要求 强保证 事务语义 全部成功或全部回滚 copy-and-swap 实现成本较高 不抛异常保证 绝不抛出异常 noexcept声明 析构函数默认 最严格承诺 选择建议 • 析构函数、swap、移动操作 → 不抛异常保证(noexcept) • 资源管理类 → 至少基本保证(RAII) • 事务性操作 → 尽量强保证(copy-and-swap) • 性能敏感代码 → 基本保证即可

你看,这三个级别其实是一个递进关系。基本保证是底线,不抛异常保证是最高要求。实际开发中,我建议你从基本保证做起,关键路径上争取强保证,核心操作确保不抛异常。这样你的代码就能从容应对各种异常场景了。

专注资料整理