16. 智能指针与异常安全:异常发生时资源的正确释放、noexcept 与智能指针
异常安全,说白了就是当你的代码抛出异常时,资源不会泄漏,程序不会崩溃。
我见过太多项目,代码写得挺漂亮,但一遇到异常就原形毕露。new 出来的对象没人 delete,打开的文件没人 close,锁拿到了没人释放。嗯,这种问题在 C++ 里太常见了。
智能指针的出现,很大程度上解决了这个问题。但前提是——你得用对。
16.1 异常安全的基本保证
C++ 异常安全有三个级别,我习惯这么记:
- 基本保证:异常发生后,资源不泄漏,对象处于有效但不确定的状态
- 强保证:异常发生后,程序状态回滚到调用前的样子
- 不抛异常保证:函数保证不会抛出异常(noexcept)
你想想看,如果你的代码连基本保证都做不到,那异常来了就是灾难。
核心原则:资源获取即初始化(RAII)是异常安全的基石。智能指针是 RAII 在内存管理上的最佳实践。
16.2 裸指针的异常陷阱
先看一个反面教材。我在代码审查时经常看到这种写法:
void processData() {
Widget* w = new Widget();
// ... 做一些操作,可能抛出异常
delete w; // 如果上面抛异常,这行永远不会执行
}
为什么会这样?因为 C++ 不会自动帮你清理栈上的裸指针。一旦中间抛出异常,delete w 就被跳过了。
我曾经在一个金融交易系统中排查过内存泄漏,最后发现就是这种裸指针 + 异常的组合拳。修复起来倒简单,换成智能指针就行。
16.3 智能指针如何解决异常安全
智能指针的析构函数会在异常传播时自动调用。这是 RAII 的精髓:
void processData() {
auto w = std::make_unique<Widget>();
// ... 做一些操作,可能抛出异常
// 无论是否异常,w 的析构函数都会执行
}
就这么简单。unique_ptr 的析构函数在栈展开(stack unwinding)时会被调用,资源自然就释放了。
shared_ptr 也一样:
void processData() {
auto w = std::make_shared<Widget>();
// ... 可能抛异常
// shared_ptr 析构时自动释放
}
小技巧:我个人习惯在构造函数中尽量使用 make_unique 和 make_shared,而不是直接 new。这样不仅异常安全,还能避免一些微妙的未定义行为。
16.4 异常安全与自定义删除器
有时候你需要自定义删除器。比如文件句柄、socket 这些非内存资源:
auto fileDeleter = [](FILE* f) {
if (f) fclose(f);
};
void processFile(const char* filename) {
std::unique_ptr<FILE, decltype(fileDeleter)>
file(fopen(filename, "r"), fileDeleter);
if (!file) {
throw std::runtime_error("文件打开失败");
}
// 读取文件,可能抛异常
// 异常发生时,file 的析构函数会调用 fclose
}
嗯,这里要注意:自定义删除器必须是不抛异常的。如果删除器本身抛异常,而你又处于异常传播过程中,程序会直接 terminate。
警告:自定义删除器一定要标记为 noexcept,或者在内部捕获所有异常。否则两个异常同时发生时,你的程序就没了。
16.5 noexcept 与智能指针
noexcept 是 C++11 引入的关键字,告诉编译器这个函数不会抛异常。智能指针的很多操作都是 noexcept 的:
| 操作 | 是否 noexcept | 说明 |
|---|---|---|
| unique_ptr 析构 | 是 | 删除器必须 noexcept |
| shared_ptr 析构 | 是 | 引用计数递减,可能触发删除 |
| make_shared | 否 | 可能抛出 bad_alloc |
| reset() | 否 | 可能调用删除器 |
| swap() | 是 | 只是交换指针 |
为什么析构函数必须是 noexcept?因为 C++ 标准规定,异常传播过程中如果析构函数又抛异常,就会调用 std::terminate。所以智能指针的析构函数默认就是 noexcept。
我记得有一次,团队里有人给 shared_ptr 的自定义删除器加了日志输出,日志库在某种情况下会抛异常。结果线上服务偶尔莫名其妙地挂掉。查了两天才发现是这个问题。
16.6 异常安全的最佳实践
结合我多年的经验,总结几条实用的:
- 永远不要手动管理资源。能用智能指针就别用裸指针,能用 RAII 就别手动 new/delete。
- 构造函数中不要做可能抛异常的操作。如果必须做,考虑使用两阶段构造或者工厂函数。
- 移动操作尽量 noexcept。这样标准库容器在重新分配时会更高效,也更安全。
- 自定义删除器必须 noexcept。这是硬性要求,否则你的智能指针就不安全了。
- 使用 make_shared 和 make_unique。它们不仅性能更好,还能避免一些异常安全的问题。
避坑指南:我曾经在代码里写过这样的代码:f(std::shared_ptr<Widget>(new Widget), otherFunc())。如果 otherFunc 抛异常,new 出来的 Widget 可能泄漏。换成 f(std::make_shared<Widget>(), otherFunc()) 就安全了。
16.7 知识体系图
下面这张图总结了智能指针与异常安全的核心关系:
16.8 总结
智能指针和异常安全,说白了就是一件事:让资源管理自动化,让异常不会成为泄漏的借口。
我个人觉得,C++ 程序员最容易犯的错误就是低估异常的影响。你写的时候觉得「这里不会抛异常」,但实际运行中什么都有可能发生。与其赌运气,不如从一开始就用智能指针把资源管好。
记住:noexcept 是你的朋友,RAII 是你的盾牌,智能指针是你的武器。用好它们,异常来了也不怕。
公众号:蓝海资料掘金营,微信 deep3321