16. 智能指针与异常安全:异常发生时如何保证资源不泄漏?

异常安全,说白了就是你的代码在抛出异常时,资源不会「人间蒸发」。

我见过太多项目,平时跑得稳稳当当,一遇到异常就崩得稀里哗啦。原因很简单——资源没处理好。今天我们就来聊聊,智能指针怎么帮我们解决这个老大难问题。

异常安全的三个级别

在深入智能指针之前,我们先明确一下异常安全的三个级别。我个人习惯把它们分成这样:

级别 含义 实际表现
基本保证 异常发生后,资源不泄漏,对象处于有效但不确定的状态 程序还能继续跑,但数据可能变了
强保证 操作要么完全成功,要么回滚到操作前的状态 像数据库事务一样,失败就当没发生过
不抛异常 保证函数永远不会抛出异常 析构函数、swap 操作等

嗯,这里要注意:智能指针主要帮我们实现「基本保证」——确保异常发生时,堆内存能被正确释放。

裸指针的噩梦

先看一个反面教材。我在代码审查时经常看到这种写法:

void processData() {
    Widget* p = new Widget();
    // ... 一些操作,可能抛出异常
    delete p;  // 如果上面抛异常,这行永远不会执行
}

为什么会这样?因为 C++ 的异常处理机制不会自动帮你调用 delete。一旦 newdelete 之间出现异常,内存就泄漏了。

你想想看,如果这个函数被调用一万次,每次泄漏一点点内存……嗯,后果不用我多说。

智能指针的救赎

std::unique_ptr 改写一下:

void processData() {
    auto p = std::make_unique<Widget>();
    // ... 一些操作,可能抛出异常
    // 离开作用域时,p 的析构函数自动 delete
}

就这么简单。智能指针的析构函数是自动调用的,而析构函数默认是 noexcept。所以无论函数正常返回还是异常退出,unique_ptr 都会释放它管理的资源。

核心原则:资源获取即初始化(RAII)。把资源绑定到栈对象的生命周期上,异常安全自然就有了。

shared_ptr 的异常安全陷阱

我曾经踩过一个坑,分享出来给大家避避雷:

// 危险写法
void process(std::shared_ptr<Widget> p1, std::shared_ptr<Widget> p2);

process(std::shared_ptr<Widget>(new Widget()), 
        std::shared_ptr<Widget>(new Widget()));

这段代码有什么问题?C++ 标准允许编译器按任意顺序求值。可能的情况是:

  1. new Widget() 第一次调用
  2. new Widget() 第二次调用
  3. 第一次构造 shared_ptr
  4. 第二次构造 shared_ptr

如果第二步抛异常,第一步分配的内存就泄漏了——因为还没绑定到 shared_ptr 上。

解决方案:永远使用 std::make_shared 而不是直接 new
// 安全写法
process(std::make_shared<Widget>(), 
        std::make_shared<Widget>());

make_shared 把内存分配和对象构造打包成一个原子操作,异常发生时不会出现「半成品」资源。

自定义删除器与异常

有时候我们需要管理非内存资源,比如文件句柄、数据库连接。自定义删除器就派上用场了:

auto fileDeleter = [](FILE* fp) {
    if (fp) fclose(fp);
};

void writeData() {
    std::unique_ptr<FILE, decltype(fileDeleter)> 
        fp(fopen("data.txt", "w"), fileDeleter);
    
    // 写入操作,可能抛出异常
    if (fprintf(fp.get(), "hello") < 0) {
        throw std::runtime_error("write failed");
    }
    // 离开作用域时自动 fclose
}

你看,不管中间怎么抛异常,文件句柄都会被正确关闭。这就是 RAII 的威力。

异常安全的核心知识体系

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

智能指针与异常安全核心逻辑 异常安全 三个级别:基本保证 / 强保证 / 不抛异常 核心机制:RAII(资源获取即初始化) unique_ptr shared_ptr + make_shared 自定义删除器 异常发生时,资源自动释放,绝不泄漏

实战中的避坑指南

我总结了几条实战经验,希望能帮你少走弯路:

  • 优先使用 std::make_uniquestd::make_shared——不仅异常安全,还少打几个字。
  • 不要在构造函数中抛异常——如果构造函数抛异常,对象的析构函数不会被调用。但智能指针管理的资源已经分配了怎么办?用 make_shared 就能避免这个问题。
  • 注意循环引用——shared_ptr 循环引用会导致内存泄漏。我建议能用 unique_ptr 就别用 shared_ptr
  • 自定义删除器要 noexcept——如果删除器抛异常,而它又在析构函数中被调用,程序会直接 terminate。
小技巧:如果你不确定某个操作是否异常安全,可以问自己一个问题:「如果这里抛异常,资源还能被释放吗?」答案是否定的地方,就是需要加智能指针的地方。

总结

异常安全不是锦上添花,而是底线要求。智能指针让我们用很小的代价,就能获得可靠的资源管理能力。

说白了,就是一句话:用 RAII 管理所有资源,用智能指针管理所有堆内存。做到这一点,异常安全的基本保证就有了。

我在项目中见过太多因为裸指针泄漏导致的内存问题,修复起来极其痛苦。养成用智能指针的习惯,能让你省下大量调试时间。


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