一、异常安全编程:三个保证与RAII实践

异常安全,说白了就是你的代码在抛出异常后,还能不能「好好活着」。

我刚开始写C++时,总觉得异常处理就是try-catch一包了事。直到有一次线上服务因为异常导致资源泄漏,内存暴涨,最后OOM挂掉……嗯,从那以后我才真正重视起异常安全来。

1.1 异常安全性的三个保证

C++社区把异常安全分成了三个等级。你想想看,就像给代码买保险,不同等级保的东西不一样。

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

核心原则:能提供强保证就提供强保证,至少也要保证基本保证。不抛出保证是底线,尤其是析构函数。

基本保证(Basic Guarantee)

基本保证是最低要求。异常发生后,程序不会泄漏资源,对象还能用,但具体值是什么你猜不到。

class Buffer {
    int* data_;
    size_t size_;
public:
    Buffer(size_t n) : data_(new int[n]), size_(n) {}
    ~Buffer() { delete[] data_; }
    
    void resize(size_t new_size) {
        // 如果这里抛出异常,data_指向旧内存,但size_已经变了
        // 这就违反了基本保证——状态不一致
        delete[] data_;
        size_ = new_size;
        data_ = new int[new_size];  // 可能抛出bad_alloc
    }
};

我在项目中遇到过类似的问题。一个同事写的resize函数,先释放旧内存再分配新内存。结果分配失败,data_成了悬空指针。这就是典型的「基本保证都没做到」。

我的习惯:写任何修改状态的函数前,先问自己一句:「如果这里抛异常了,对象还能用吗?」

强保证(Strong Guarantee)

强保证就是「要么不做,要做就做完」。异常发生时,一切回滚到操作前的样子。

实现强保证的经典手法是「拷贝-交换」(copy-and-swap)。

class Buffer {
    int* data_;
    size_t size_;
    
    void swap(Buffer& other) noexcept {
        std::swap(data_, other.data_);
        std::swap(size_, other.size_);
    }
    
public:
    void resize(size_t new_size) {
        Buffer temp(new_size);  // 先构造临时对象
        // 如果上面抛出异常,this完全不受影响
        swap(temp);  // 无异常交换
    }  // temp析构,释放旧内存
};

你看,关键点在于:所有可能抛异常的操作,都在修改this之前完成。最后一步swap是noexcept的,保证不会出问题。

注意:强保证不是免费的。拷贝临时对象可能带来性能开销。我一般只在「操作粒度较大」或「事务性要求高」的场景下使用。

不抛出保证(No-throw Guarantee)

不抛出保证是最严格的。函数承诺:我绝对不会抛异常。

哪些函数必须是不抛出的?

  • 析构函数——C++标准规定,析构函数抛出异常是未定义行为
  • swap函数——很多异常安全技术依赖它
  • 移动构造函数/移动赋值——标准库容器在重新分配时,如果移动操作不抛出,会优先使用移动而非拷贝
class Logger {
    FILE* file_;
public:
    ~Logger() noexcept {
        if (file_) {
            fclose(file_);  // fclose本身不会抛C++异常
        }
    }
    
    void swap(Logger& other) noexcept {
        std::swap(file_, other.file_);
    }
};

我曾经见过一个析构函数里调用了第三方库的接口,那个接口在特定条件下会抛异常。结果程序崩溃得莫名其妙,查了两天才定位到问题。嗯,从那以后我养成了习惯:析构函数里只做不会失败的事

1.2 RAII与异常安全

RAII(资源获取即初始化)是C++异常安全的基石。说白了就是:资源绑定到对象的生命周期上

为什么RAII对异常安全这么重要?因为C++保证:栈展开(stack unwinding)时,所有局部对象的析构函数都会被调用。这意味着,只要资源被RAII对象管理着,异常发生时它就能自动释放。

// 不安全的写法
void process() {
    int* ptr = new int[100];
    // ... 中间代码可能抛异常
    delete[] ptr;  // 如果上面抛异常,这行永远不会执行
}

// RAII的安全写法
void process() {
    std::vector<int> vec(100);
    // ... 中间代码可能抛异常
    // vec的析构函数会自动释放内存
}

黄金法则:任何需要手动释放的资源,都应该用RAII包装起来。文件句柄、互斥锁、数据库连接……统统交给RAII。

我个人习惯用RAII管理锁资源:

class LockGuard {
    std::mutex& mtx_;
public:
    explicit LockGuard(std::mutex& m) : mtx_(m) {
        mtx_.lock();
    }
    ~LockGuard() {
        mtx_.unlock();
    }
    // 禁止拷贝
    LockGuard(const LockGuard&) = delete;
    LockGuard& operator=(const LockGuard&) = delete;
};

void thread_safe_func() {
    LockGuard guard(global_mutex);
    // 即使这里抛异常,mutex也会被解锁
    // 不会造成死锁
}

1.3 异常安全的容器与算法

标准库容器在设计时就考虑了异常安全。但作为使用者,我们得知道它们的「安全边界」在哪里。

容器的异常安全保证

容器操作 异常安全等级 说明
push_back (单元素) 强保证 如果元素拷贝/移动构造失败,容器不变
insert (单元素) 强保证 同上,但关联容器可能不同
emplace 强保证 就地构造,但构造失败时容器不变
vector::reserve 强保证 如果重新分配失败,原容器不变
list::splice 不抛出 只调整指针,不涉及元素拷贝

这里有个坑:当元素类型是「移动构造可能抛出」时,vector的强保证会降级为基本保证。为什么?因为vector在扩容时,如果移动操作抛异常,旧元素已经被移走了,回滚不了。

避坑指南:我曾经在项目中用了一个自定义类型,它的移动构造函数是可能抛异常的。结果vector在扩容时抛了异常,部分数据丢失了。排查了好久才发现问题。从那以后,我给自己定了个规矩:移动构造函数和移动赋值运算符,必须标记为noexcept

算法的异常安全

标准算法通常提供基本保证。但有些算法有特殊要求:

  • std::sort:要求元素类型提供noexcept的swap,否则异常安全无法保证
  • std::for_each:如果函数对象抛异常,算法会停止,但已处理的部分不会回滚
  • std::accumulate:如果二元操作抛异常,结果不确定
// 安全的做法:确保谓词不抛出
std::vector<int> v = {3, 1, 4, 1, 5};
try {
    std::sort(v.begin(), v.end(), [](int a, int b) noexcept {
        return a < b;  // 比较操作不会抛异常
    });
} catch (...) {
    // 如果sort内部抛异常(比如迭代器失效),v可能处于部分排序状态
    // 但至少不会泄漏资源
}

1.4 noexcept规范

noexcept是C++11引入的关键字,用来声明函数不会抛出异常。它不只是文档,还能影响编译器优化和标准库的行为。

void func1() noexcept;      // 承诺不抛异常
void func2() noexcept(true); // 等价于上面
void func3() noexcept(false);// 可能抛异常(默认行为)

为什么noexcept这么重要?

  • 性能优化:编译器知道函数不会抛异常,可以生成更高效的代码(不需要维护异常处理栈帧)
  • 移动语义:标准库会检查移动操作是否为noexcept,如果是,优先使用移动而非拷贝
  • 容器操作:vector的强保证依赖于元素类型的移动操作是noexcept的
class MyData {
    std::vector<int> data_;
public:
    // 移动构造函数必须标记为noexcept
    // 否则vector在扩容时会选择拷贝而非移动
    MyData(MyData&& other) noexcept 
        : data_(std::move(other.data_)) {}
    
    MyData& operator=(MyData&& other) noexcept {
        data_ = std::move(other.data_);
        return *this;
    }
};

我的建议:能加noexcept的地方尽量加。但不要为了加noexcept而吞掉异常。如果函数确实可能抛异常,就老老实实写noexcept(false)或者不写。

noexcept还有一个妙用:条件noexcept。你可以根据模板参数决定是否noexcept。

template <typename T>
void swap(T& a, T& b) noexcept(noexcept(a.swap(b))) {
    a.swap(b);
}

// 如果T::swap是noexcept的,那么::swap也是noexcept的
// 否则就不是

这种写法在标准库中很常见。它保证了「如果底层操作是安全的,那么上层操作也是安全的」。

知识体系总览

下面这张图概括了本章的核心内容:

异常安全编程知识体系 基本保证 强保证 不抛出保证 资源不泄漏 拷贝-交换 noexcept声明 RAII 核心 容器异常安全 算法异常安全 noexcept规范

总结一下:异常安全不是「加个try-catch就完事」那么简单。它需要你在设计阶段就考虑好:资源谁管理?状态怎么回滚?哪些操作绝对不能失败?

RAII是地基,三个保证是框架,noexcept是加固材料。把这些用好了,你的代码才能在异常面前「稳如泰山」。


公众号:蓝海资料掘金营,微信deep3321