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_ptr、std::shared_ptr、std::lock_guard等工具,直接用就好。
31.3 异常中立原则:别让你的代码成为背锅侠
什么叫异常中立?简单说就是:如果你的函数调用了可能抛异常的函数,那么你也要准备好处理异常,或者让异常继续往上传播。不要吞掉异常,也不要让对象处于不一致状态。
我见过最糟糕的代码是这样的:
void doSomething() {
try {
// 调用可能抛异常的函数
riskyOperation();
} catch (...) {
// 什么都不做,吞掉异常
}
// 继续执行,但状态可能已经不对了
}
这种做法极其危险。异常被吞掉后,调用方完全不知道出了问题,后续操作基于错误的状态继续执行,最终导致更难排查的bug。
异常中立原则要求你做到三点:
- 不吞异常:除非你能完全恢复,否则让异常继续传播。
- 不破坏不变量:在可能抛异常的操作前,先保存好状态,或者用RAII保证回滚。
- 不泄漏资源:所有资源都通过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_back或pop_back抛异常,原对象的状态也不会被破坏。
小技巧:写异常安全的代码时,记住一个口诀——先拷贝,再修改,最后交换。这个模式几乎适用于所有需要保证强异常安全的场景。
31.4 异常安全的三层保证
C++社区把异常安全分为三个等级,我整理成了一张表:
| 保证等级 | 含义 | 典型做法 |
|---|---|---|
| 基本保证 | 异常发生后,不泄漏资源,对象处于有效但不确定的状态 | RAII管理资源,捕获异常后清理 |
| 强保证 | 异常发生后,对象状态回滚到调用前的状态 | copy-and-swap,事务性操作 |
| 不抛异常保证 | 函数保证绝不抛出异常 | noexcept声明,只使用不抛异常的操作 |
我个人建议:尽量提供强保证,至少保证基本保证。如果某个函数你确定不会抛异常,记得加上noexcept。这不仅是文档,还能让编译器做更好的优化。
31.5 知识体系图
下面这张图总结了本章的核心逻辑:
这张图把三个核心概念串起来了。单例关注构造安全,RAII关注资源安全,异常中立关注行为安全。三者结合,才能写出真正健壮的代码。
总结一句话:异常安全不是锦上添花,而是C++代码的底线。用RAII管好资源,用copy-and-swap保证强异常安全,用异常中立原则让异常自然传播。做到这三点,你的代码在异常面前就不会轻易崩溃。
公众号:蓝海资料掘金营,微信deep3321