一、异常安全编程:三个保证与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的
// 否则就不是
这种写法在标准库中很常见。它保证了「如果底层操作是安全的,那么上层操作也是安全的」。
知识体系总览
下面这张图概括了本章的核心内容:
总结一下:异常安全不是「加个try-catch就完事」那么简单。它需要你在设计阶段就考虑好:资源谁管理?状态怎么回滚?哪些操作绝对不能失败?
RAII是地基,三个保证是框架,noexcept是加固材料。把这些用好了,你的代码才能在异常面前「稳如泰山」。