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 异常安全的最佳实践

结合我多年的经验,总结几条实用的:

  1. 永远不要手动管理资源。能用智能指针就别用裸指针,能用 RAII 就别手动 new/delete。
  2. 构造函数中不要做可能抛异常的操作。如果必须做,考虑使用两阶段构造或者工厂函数。
  3. 移动操作尽量 noexcept。这样标准库容器在重新分配时会更高效,也更安全。
  4. 自定义删除器必须 noexcept。这是硬性要求,否则你的智能指针就不安全了。
  5. 使用 make_shared 和 make_unique。它们不仅性能更好,还能避免一些异常安全的问题。

避坑指南:我曾经在代码里写过这样的代码:f(std::shared_ptr<Widget>(new Widget), otherFunc())。如果 otherFunc 抛异常,new 出来的 Widget 可能泄漏。换成 f(std::make_shared<Widget>(), otherFunc()) 就安全了。

16.7 知识体系图

下面这张图总结了智能指针与异常安全的核心关系:

智能指针与异常安全核心关系 异常安全 基本保证 强保证 不抛异常保证 RAII(资源获取即初始化) 智能指针 noexcept 声明 unique_ptr shared_ptr weak_ptr 自定义删除器 关键要求:删除器必须 noexcept,析构函数必须 noexcept

16.8 总结

智能指针和异常安全,说白了就是一件事:让资源管理自动化,让异常不会成为泄漏的借口。

我个人觉得,C++ 程序员最容易犯的错误就是低估异常的影响。你写的时候觉得「这里不会抛异常」,但实际运行中什么都有可能发生。与其赌运气,不如从一开始就用智能指针把资源管好。

记住:noexcept 是你的朋友,RAII 是你的盾牌,智能指针是你的武器。用好它们,异常来了也不怕。


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