析构函数不靠你记得调用:RAII 与智能指针管住了什么

RAII 不是某个库,而是一条语言层的约定:构造函数取得资源,析构函数归还资源,对象离开作用域时编译器保证析构执行,异常栈展开也不例外。智能指针只是这条约定在堆内存上的标准实现:unique_ptr 用移动语义表达独占,shared_ptr 靠控制块里的强计数表达共享,weak_ptr 只观察不持有。最常见的误解是以为用了智能指针就不会泄漏、也不会悬垂,实际上循环引用照样漏,对象释放后留存的裸指针照样悬垂;构造函数抛异常时,对象自身的析构更不会执行。读完你能说清控制块何时释放、make_shared 省掉了什么、weak_ptr 为什么要配 lock(),以及析构函数为什么默认不能抛。

是什么

RAII(Resource Acquisition Is Initialization)不是某个库或语法糖,而是一条把资源生命周期绑在对象生命周期上的约定:构造函数负责取得资源并让对象进入可用状态,析构函数负责归还,对象一旦离开作用域,编译器就保证析构被调用。直觉上它像酒店的自动结算——入住时拿房卡,退房时系统自动扣款,不管你是正常走人还是被火警赶出来。它解决的不是“怎么申请”,而是“谁来还、什么时候还”:把这两件事从人的记忆里挪到编译器能检查的对象生命周期上。典型场景包括堆内存、文件描述符、互斥锁、数据库连接、socket 与临时目录。智能指针只是这条约定在堆内存上的标准实现:unique_ptr 表达独占,shared_ptr 表达共享,weak_ptr 只观察不持有。理解 RAII 的关键不是记住三个指针的名字,而是理解“谁在什么时刻保证释放”。

核心原理

析构调用是编译器插进去的,不是运行时垃圾回收。 对自动存储期对象,编译器在每一条离开作用域的路上都埋了析构调用:正常走到右花括号、return、break、goto,以及异常抛出后的栈展开。异常展开时,已经完整构造的对象按构造的逆序销毁;对单个对象,先执行析构函数体,再按声明逆序销毁成员,最后销毁基类。这个顺序是语言规则,不依赖程序员写 finally。也正因如此,RAII 的清理是确定性的:函数返回时资源已经归还,而不是等 GC 某次扫描。但要注意,RAII 依赖对象真的被销毁:如果把 unique_ptr 本身 new 在堆上、外层又没人 delete,这个包装对象也不会析构,资源照样漏。

构造失败是 RAII 最容易漏掉的一半。 如果构造函数抛出异常,这个对象自身的析构函数不会执行,只有已经完整构造的基类和成员会被销毁。于是“先 new 一个句柄,再在构造函数体里做可能抛异常的初始化”就有泄漏窗口。正确做法是让每一个资源都由一个成员对象持有,把获取动作放进成员的构造函数里;这样外层构造失败时,成员的析构会兜底。反过来,析构函数默认是 noexcept 的,栈展开过程中再抛异常会直接调用 std::terminate,所以清理代码要么不抛,要么在内部吞掉并记录。

unique_ptr 把“独占”写进了类型。 C++14 起标准库提供了 make_unique;它不可拷贝、只可移动,赋值或按值传参就意味着所有权转移,编译期就能挡住“两个指针都以为自己该 delete”的错误。默认删除器是空类,主流实现借助空基类优化把额外状态压掉,所以常见平台上它与裸指针同宽;换成函数指针或有状态的删除器,对象就会变胖。对数组要用 unique_ptr<T[]>,否则 delete 与 delete[] 配错是未定义行为。

shared_ptr 是“对象指针 + 控制块”。 控制块里放强计数、弱计数、删除器和分配器;拷贝 shared_ptr 增加强计数,销毁减少,强计数归零时调用删除器销毁对象,弱计数也归零时才释放控制块。计数增减是原子操作,但这只保护计数本身。make_shared 通常把对象和控制块放进一次分配,少一次 malloc、局部性也更好;代价是只要还有 weak_ptr 活着,整块内存(包括对象曾经占的位置)都要等到弱计数归零才能还给分配器。weak_ptr 不拥有对象,lock() 会原子地检查强计数是否仍大于零并尝试提升,提升成功才拿到可安全解引用的 shared_ptr;它也是标准库里打破循环引用最直接的工具。

#include <iostream>
#include <memory>

struct Res {
    Res()  { std::cout << "acquire\n"; }
    ~Res() { std::cout << "release\n"; }
};

int main() {
    {
        auto p = std::make_unique<Res>();
        std::cout << "use\n";
    }
    auto sp = std::make_shared<Res>();
    std::weak_ptr<Res> wp = sp;
    std::cout << sp.use_count() << "\n";
    if (auto q = wp.lock())
        std::cout << sp.use_count() << "\n";
    std::cout << sp.use_count() << "\n";
}

这段代码的输出顺序值得记住:unique_ptr 在右花括号处立刻 release;shared_ptr 的强计数在 lock() 提升期间短暂变成 2,提升出的 q 析构后又回到 1。计数是“当前有多少个强拥有者”,不是“对象被多少地方引用”的精确统计。

常见考点与误区

  • “RAII 就是智能指针”:不成立。lock_guard、fstream、jthread、自定义的 scope guard 都是 RAII;智能指针只是把同一套机制用在堆内存上。
  • “构造函数抛异常,析构函数会来兜底”:错。对象自身没构造完成,析构不会调用;只有成员和基类会被销毁。裸句柄在构造函数体里获取后再抛,就是典型泄漏点。
  • “shared_ptr 线程安全,所以它指向的对象也线程安全”:混淆了两层。不同 shared_ptr 实例(哪怕是同一对象的拷贝)可在多线程中并发拷贝、销毁;同一个 shared_ptr 实例上一边读一边写仍是数据竞争;对象本身的读写更不在控制块保护范围内。
  • “用了 make_shared 就一定更优”:多数情况是,但它不支持自定义删除器,且 weak_ptr 会把整块内存拖到弱计数归零。对象很大、弱引用长期存在时,要权衡峰值内存。
  • “shared_ptr 能自动处理循环引用”:不能。环上的强计数永远不归零,对象和控制块都不释放;必须把环的一侧改成 weak_ptr。反过来,weak_ptr 的 expired() 只能当提示,判断完再 lock() 仍可能拿到空指针,正确姿势是 lock() 后先判空再使用。
  • “有了 RAII 就不会有悬垂指针”:两件事。RAII 保证资源在作用域结束时被释放,不跟踪还有多少别名指向它;unique_ptr 被 move 走或 reset 之后,之前用 get() 留下的裸指针立刻悬垂。weak_ptr 也只能解决 shared_ptr 管理下的观察问题。
  • “有了 RAII 就有强异常安全”:RAII 只保证不泄漏,不保证回滚。操作改了两个成员、第二个赋值抛出时,第一个成员已经是新值;要强保证仍得靠先构造副本再 swap 之类的手段。
  • “析构函数里可以随手抛异常”:C++11 起析构默认 noexcept,栈展开中抛出会 terminate;即使没在展开,也应在析构内部捕获,不要让它逃出去。
  • “unique_ptr 一定和裸指针一样大”:只在删除器无状态时通常成立;函数指针、带捕获的 lambda 都会把删除器存进对象。

实践建议

  • 默认用 unique_ptr 表达独占,只有确实存在多个拥有者、且生命周期无法用作用域表达时才上 shared_ptr;能用引用或裸指针传参就别按值传 shared_ptr,后者每次拷贝都要动原子计数。
  • 接口签名里写明所有权:T* / T& 表示借用、不负责释放,返回 unique_ptr<T> 表示转移,只有真正共享生命周期才出现 shared_ptr;把语义放进类型,比放进注释可靠。
  • 优先 make_unique / make_shared;需要关闭 FILE*、socket、dlopen 句柄时,给 unique_ptr 配自定义删除器,把“释放方式”和“指针”绑在一个对象里,避免某条提前 return 漏掉 close。
  • 设计 owning 类时,把资源放进成员而不是构造函数体里的裸指针;构造失败时成员析构会自动回滚,这是最省事的异常安全写法。
  • 出现 shared_ptr 环(父子互指、观察者反向持有、缓存回指)时,先画强引用图,再把非拥有方向改成 weak_ptr;缓存场景在 lock() 成功后使用,不要只看 expired() 就动手,lock() 之后仍要判空。
  • 析构、关闭、解锁这类清理动作保持不抛;需要报告错误就写日志或交给显式的 close()。上线前用 ASan/UBSan 跑一遍异常路径,比只读代码更容易抓到泄漏与悬垂。

一句话总结:RAII 让释放跟着作用域走,智能指针让所有权跟着类型走;它们保证一定释放,不保证指针一直有效。

相关阅读

← 返回文章志