31. 设计模式与异常安全:异常安全的单例,RAII与资源管理,异常中立原则

异常安全这个话题,说实话很多C++开发者容易忽略。我早年带团队做项目时,就吃过这方面的亏。一个看似完美的单例模式,因为构造函数抛异常,导致整个系统启动时崩溃。从那以后,我对异常安全的重视程度就上了一个台阶。

今天咱们聊聊三个核心问题:异常安全的单例怎么写RAII到底怎么管资源、以及什么叫异常中立。这三个点,说白了就是让你写的代码在异常面前不崩、不漏、不背锅。

31.1 异常安全的单例:别让构造失败毁了全局

单例模式本身不复杂,但加上异常安全,坑就多了。我见过不少代码这么写:

class Singleton {
public:
    static Singleton* instance() {
        if (!instance_) {
            instance_ = new Singleton();
        }
        return instance_;
    }
private:
    static Singleton* instance_;
};

这段代码有什么问题?两个:线程不安全,而且不异常安全。如果new Singleton()抛出异常,instance_不会被赋值,但内存已经分配了——泄漏了。

我个人习惯用局部静态变量的方式,C++11之后这个做法是线程安全的:

class Singleton {
public:
    static Singleton& instance() {
        static Singleton inst;
        return inst;
    }
private:
    Singleton() = default;
    ~Singleton() = default;
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;
};

这里有个细节:如果Singleton的构造函数抛出异常,static变量不会被初始化,下次调用时会重新尝试构造。不会留下半死不活的状态。这就是异常安全中的基本保证——不泄漏资源,不破坏不变量。

注意:局部静态变量的析构顺序是反初始化的。如果单例依赖其他全局对象,析构时可能出问题。我建议单例尽量不依赖外部资源,或者用std::shared_ptr管理生命周期。

31.2 RAII与资源管理:把资源交给对象

RAII(Resource Acquisition Is Initialization)是C++里最优雅的设计之一。说白了就是:资源在构造函数里获取,在析构函数里释放。这样无论代码怎么跳转,哪怕抛出异常,析构函数也会被调用,资源就不会泄漏。

举个例子,你想想看,如果手动管理文件句柄:

void processFile(const char* path) {
    FILE* f = fopen(path, "r");
    if (!f) throw std::runtime_error("open failed");
    // ... 处理逻辑 ...
    if (someCondition) {
        fclose(f);
        return;
    }
    // ... 更多逻辑 ...
    fclose(f);
}

这段代码里,每个返回点都要记得fclose。一旦漏掉,或者中间抛出异常,文件句柄就泄漏了。我曾经在一个网络库中看到类似的代码,排查了整整两天才找到泄漏点。

用RAII改写:

class FileGuard {
public:
    FileGuard(const char* path, const char* mode) 
        : fp_(fopen(path, mode)) {
        if (!fp_) throw std::runtime_error("open failed");
    }
    ~FileGuard() { if (fp_) fclose(fp_); }
    FILE* get() const { return fp_; }
private:
    FILE* fp_;
    FileGuard(const FileGuard&) = delete;
    FileGuard& operator=(const FileGuard&) = delete;
};

void processFile(const char* path) {
    FileGuard fg(path, "r");
    // 处理逻辑,随便抛异常,析构函数自动关闭文件
}

你看,代码简洁多了,而且安全。这就是RAII的魅力——资源管理自动化,异常安全自然来

核心原则:任何需要手动释放的资源(内存、文件、锁、socket等),都应该封装在RAII对象中。C++标准库已经提供了std::unique_ptrstd::shared_ptrstd::lock_guard等工具,直接用就好。

31.3 异常中立原则:别让你的代码成为背锅侠

什么叫异常中立?简单说就是:如果你的函数调用了可能抛异常的函数,那么你也要准备好处理异常,或者让异常继续往上传播。不要吞掉异常,也不要让对象处于不一致状态。

我见过最糟糕的代码是这样的:

void doSomething() {
    try {
        // 调用可能抛异常的函数
        riskyOperation();
    } catch (...) {
        // 什么都不做,吞掉异常
    }
    // 继续执行,但状态可能已经不对了
}

这种做法极其危险。异常被吞掉后,调用方完全不知道出了问题,后续操作基于错误的状态继续执行,最终导致更难排查的bug。

异常中立原则要求你做到三点:

  1. 不吞异常:除非你能完全恢复,否则让异常继续传播。
  2. 不破坏不变量:在可能抛异常的操作前,先保存好状态,或者用RAII保证回滚。
  3. 不泄漏资源:所有资源都通过RAII管理,异常发生时自动释放。

举个例子,一个简单的栈操作:

template<typename T>
class SafeStack {
public:
    void push(const T& value) {
        // 先拷贝,再修改状态
        auto temp = data_;
        temp.push_back(value);
        // 如果push_back抛异常,data_不会被修改
        data_.swap(temp);
    }
    
    void pop() {
        if (data_.empty()) throw std::underflow_error("empty stack");
        // 先保存副本,再修改
        auto temp = data_;
        temp.pop_back();
        data_.swap(temp);
    }
    
private:
    std::vector<T> data_;
};

这里用了copy-and-swap技巧。先在一个临时对象上操作,如果操作成功,再用swap替换原对象。swap本身不会抛异常(这是C++标准对swap的要求)。这样,即使push_backpop_back抛异常,原对象的状态也不会被破坏。

小技巧:写异常安全的代码时,记住一个口诀——先拷贝,再修改,最后交换。这个模式几乎适用于所有需要保证强异常安全的场景。

31.4 异常安全的三层保证

C++社区把异常安全分为三个等级,我整理成了一张表:

保证等级 含义 典型做法
基本保证 异常发生后,不泄漏资源,对象处于有效但不确定的状态 RAII管理资源,捕获异常后清理
强保证 异常发生后,对象状态回滚到调用前的状态 copy-and-swap,事务性操作
不抛异常保证 函数保证绝不抛出异常 noexcept声明,只使用不抛异常的操作

我个人建议:尽量提供强保证,至少保证基本保证。如果某个函数你确定不会抛异常,记得加上noexcept。这不仅是文档,还能让编译器做更好的优化。

31.5 知识体系图

下面这张图总结了本章的核心逻辑:

异常安全设计模式 异常安全的单例 • 局部静态变量(C++11线程安全) • 构造失败自动重试 • 避免全局依赖 RAII与资源管理 • 资源在构造时获取 • 资源在析构时释放 • 异常发生时自动清理 异常中立原则 • 不吞异常 • 不破坏不变量 • 不泄漏资源 异常安全的三层保证 基本保证 强保证 不抛异常保证 不泄漏,状态不确定 状态回滚到调用前 绝不抛出异常

这张图把三个核心概念串起来了。单例关注构造安全,RAII关注资源安全,异常中立关注行为安全。三者结合,才能写出真正健壮的代码。

总结一句话:异常安全不是锦上添花,而是C++代码的底线。用RAII管好资源,用copy-and-swap保证强异常安全,用异常中立原则让异常自然传播。做到这三点,你的代码在异常面前就不会轻易崩溃。


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