本节摘要:RAII(资源获取即初始化)把开户写进构造函数、销账写进析构函数,利用"C++ 保证局部对象析构必达"的语言铁律,让销账不依赖任何人工记忆——正常返回、提前返回、异常抛出,账都轧得平。它是智能指针、锁守卫、容器等一切现代 C++ 设施的共同底座。
上一章结尾留下了死结:销账责任散落在控制流里,分叉越多脱落风险越大。本节负责解开它。位置上,本节承接 1.4 的坏账清单,是全册制度的总闸;往下走,2.2 到 2.4 的三种智能指针都是本节思想在内存账本上的具体化,第 5 章的锁守卫则是它在并发账本上的应用。
先把 1.4 里那单泄漏的最小复现摆出来,看看"手工记账"输在哪:
#include <cstdio> #include <stdexcept> void handle_file_manual() { std::FILE* f = std::fopen("audit.log", "w"); // 开户 if (!f) return; process(); // 若这里抛异常,下面的 fclose 被跳过 std::fclose(f); // 销账写在末尾:任何早退路径都是隐患 std::printf("正常路径销账完成\n"); }
这段代码在"晴天的路径"上没问题,问题全在雨天:process 抛异常时,fclose 那行永远执行不到,文件句柄泄漏。修补的思路通常是 try/catch 里再补一次 fclose——每个开户点配一套 try,控制流复杂度指数上涨,这正是第 1 章结论"责任散落"的具体表现。
RAII 的修法换了个思路:让销账动作自己长上腿。写一个"账户对象",构造时开户、析构时销账:
#include <cstdio> #include <stdexcept> class FileGuard { // 一个最小的 RAII 账户 public: explicit FileGuard(const char* path, const char* mode) : f_(std::fopen(path, mode)) { if (!f_) throw std::runtime_error("开户失败"); std::printf("FileGuard 开户\n"); } ~FileGuard() { if (f_) { std::fclose(f_); std::printf("FileGuard 销账(无论从哪条路径退出都会执行)\n"); } } FileGuard(const FileGuard&) = delete; // 禁止复制:一份资源不能两个户主 FileGuard& operator=(const FileGuard&) = delete; std::FILE* get() const { return f_; } // 借用访问,不转移所有权 private: std::FILE* f_; }; void risky() { throw std::runtime_error("业务异常"); } void handle_file_raii() { FileGuard guard("audit.log", "w"); risky(); // 抛异常也没关系——栈展开会调用 guard 的析构 std::printf("这行不会执行\n"); } int main() { try { handle_file_raii(); } catch (const std::exception& e) { std::printf("捕获异常:%s\n", e.what()); } return 0; }
输出确定:
FileGuard 开户 FileGuard 销账(无论从哪条路径退出都会执行) 捕获异常:业务异常
异常真的抛了,销账真的执行了。凭据是语言铁律:异常抛出时栈展开(stack unwinding)逐层销毁已构造完成的局部对象,析构函数逐一执行。销账从"流程末尾的一行代码"变成了"对象死亡时的必然事件"——这就是制度与纪律的差别。

上面 FileGuard 只有十几行,但每一行都有讲究,提炼成守则:
守则一:开户写在构造函数,构造失败就抛异常。 对象存在的每一刻都对应"资源已就位"的状态,不存在"半开户"的中间态。这样使用者永远不需要调用 is_open 之类的检查——对象活着就是好的。
守则二:销账写在析构函数,析构函数绝不抛异常。 栈展开期间再抛异常会让程序直接终止(std::terminate),销账制度就会反噬自己。析构里只做不抛的清理动作,std::fclose、delete、unlock 都是安全清单上的成员。
守则三:默认禁止复制,想转移就显式移动。 一份资源两个户主,迟早双重销账。删除拷贝构造与拷贝赋值(= delete)把"别复制我"写进类型;需要转移所有权时再提供移动操作——2.2 的 unique_ptr 就是把这套守则做到极致的标准答案。
守则四:暴露借用接口,不暴露所有权。 get() 返回的裸指针只供"临时使用",绝不保存——保存了就等于私刻了一枚账户印章,又回到别名悬垂的老问题。这条守则在第 3 章讲引用与指针时会进一步形式化。
背景:1.3 用挂钩子审计出泄漏,但钩子只是观测手段;团队想把"对象数量受控"从纪律变成保证。
操作:把 RequestContext 改造成 RAII 类:构造里完成开户登记(存量加一、登记时间戳),析构里完成销账(存量减一、写流水)。所有代码路径禁止手工调用任何 begin/end 接口,对象直接以局部变量存在。
结果:压测两小时存量归零,且这次不是"这次没人写错",而是"想写错都难"——销账代码根本不存在于业务流程里,脱落无从谈起。异常路径专门做了一轮注入测试:处理函数随机抛异常,存量曲线依旧归零。
解读:对比两次修复的成本收益:上一章的修复改了一行代码,但制度没变,下一次泄漏只是时间问题;本节的修复搬动了类的结构,此后这类坏账被类型系统整体拦截。RAII 的价值不在防住某一次错误,在于让这类错误失去表达方式。
变式:制度可以直接搬去管非内存资源——锁(第 5 章的 lock_guard)、socket、数据库连接、事务。凡"成对出现的动作"(open/close、lock/unlock、begin/commit),都值得一个 RAII 封装。标准库已经把常用的都写好了,自己动手写一遍的价值在于理解制度本身,生产代码优先用现成的。
💡 关键直觉:判断一段代码是否"异常安全",别去逐条路径推演,只看一句——资源有没有被局部对象持有。持有,则销账必达;没持有(裸句柄在业务代码里流转),就有雨天路径,迟早出账。
下一节看标准库把这套制度在内存账本上的标准答案:unique_ptr——独占账户、零开销、移动即过户,它将替你管理第 1 章所有手工 new 开出的户头。