RAII
RAII|资源获取即初始化
C++,资源管理,异常安全,析构顺序。
编译环境:本地 MinGW-w64 g++ 13.1.0(x86_64-w64-mingw32),-std=gnu++17 -O2。文中每个例子的输出都是在这个配置下跑出来的。
一个资源跟着一个对象走
RAII 的全称是 Resource Acquisition Is Initialization,即资源获取即初始化。资源在这里指用完必须还回去的东西:堆内存、文件句柄、互斥锁、套接字、数据库连接。手写的形式一般是:
std::FILE* fp = std::fopen(path, "wb");
if (!fp) {
return false;
}
// 后面还有若干出错分支
std::fclose(fp);
漏一个 return 前的 fclose 就漏一次,加一个分支就多一份责任。RAII 把释放动作放进析构函数:资源在构造时拿到、在析构时还回去,释放点由作用域决定,不由调用者的记性决定。cppreference 上的定义是,把必须先获取才能使用的资源,其生命期绑定到对象的生命期上 [1] 。
一个最小例子,顺便看清释放顺序:
struct Handle {
const char* name;
explicit Handle(const char* n) : name(n) { std::printf("acquire %s\n", name); }
~Handle() { std::printf("release %s\n", name); }
};
struct Session {
Handle first;
Handle second;
Session() : first("first"), second("second") { std::printf("Session body\n"); }
~Session() { std::printf("release Session body\n"); }
};
main 里先构造一个外层对象,再进一层作用域构造 Session:
int main() {
Handle outer("outer");
{
Session s;
std::printf("--- leaving scope ---\n");
}
std::printf("--- end of main ---\n");
return 0;
}
输出是:
acquire outer
acquire first
acquire second
Session body
--- leaving scope ---
release Session body
release second
release first
--- end of main ---
release outer
成员按声明顺序构造,析构按构造的逆序进行;Session 自己的析构体先跑完,才轮到 second 和 first。cppreference 的 RAII 条目写的也是这个:资源在对象生命期结束时按获取的逆序释放 [2] 。
顺序不是讲究。两个资源之间有依赖的时候,先打开的文件先关、多把锁按相反的次序放,把次序交给成员声明顺序表达,比在注释里写一句「注意先关 A」可靠。
构造没成功的那半个对象
析构函数只在对象构造完成之后才被调用。构造函数中途抛异常时,对象本体不存在,它的析构函数不会跑;但已经完整构造出来的成员和基类子对象是存在的,要按初始化逆序清理。
struct Res {
const char* name;
explicit Res(const char* n) : name(n) { std::printf("acquire %s\n", name); }
~Res() { std::printf("release %s\n", name); }
};
struct Widget : Res {
Res m1;
Res m2;
Widget() : Res("base"), m1("member-1"), m2("member-2") {
std::printf("Widget body\n");
throw std::runtime_error("ctor failed");
}
~Widget() { std::printf("~Widget body (never runs for a failed ctor)\n"); }
};
在主函数里接住这个异常,输出是:
acquire base
acquire member-1
acquire member-2
Widget body
release member-2
release member-1
release base
caught: ctor failed
--- end of main ---
包含 ~Widget body 的那一行没有出现。base 在 m2、m1 之后才释放。cppreference 的 RAII 条目里写着,构造函数以异常退出时,所有已完整构造的成员和基类子对象按初始化逆序释放 [3] 。
这条规则直接影响构造函数怎么写:第二个资源要等前一个资源已经交给成员持有之后再去获取。先拿两个裸句柄、最后一起赋给成员,中途抛出时还没交出去的那个就漏了。
析构函数不能抛出来
C++11 之后析构函数默认是 noexcept。在析构里抛出,编译器直接给警告:
warning: 'throw' will always call 'terminate' [-Wterminate]
note: in C++11 destructors default to 'noexcept'
运行到那里就是 terminate:
--- leaving try block ---
terminate called after throwing an instance of 'std::runtime_error'
what(): thrown from a destructor
进程退出码是 \(3\),外面的 catch 没接到异常。崩溃消息的格式和退出码是这套运行时与这台机器上的结果,换个平台会变。
栈展开期间再抛更直接。把析构声明成 noexcept(false),在已经有一个异常往上走的时候抛出,标准同样要求 terminate [4] 。实测里能看到的输出还跟优化级别有关。
-O0 下先跑完析构里的清理,再 terminate,报告的是第二个异常:
cleanup
terminate called after throwing an instance of 'std::runtime_error'
what(): thrown while unwinding
-O2 下清理那一行没有了,报告的是第一个异常:
terminate called after throwing an instance of 'std::runtime_error'
what(): first exception
看 work 函数的汇编能解释这个差别。-O2 下它只剩下分配异常对象、构造 runtime_error 和 __cxa_throw 这几步,没有调用 ~Bad。Bad 的默认构造没有副作用,编译器把抛出挪到了对象存在之前,析构自然没被调用。所以拿 terminate 的报告反推析构跑没跑并不可靠。
需要知道当前是不是正在展开时,用 std::uncaught_exceptions():它返回当前线程中已经抛出或重抛、还没进入匹配 catch 的异常个数 [5] 。析构里比较这个计数和构造时记下的值,就能区分正常退出与异常退出。
提交还是回滚
写盘这类操作需要一个「中途出错就退回去」的清理器,正常走到末尾则是提交。
点击查看代码
#include <cstdio>
#include <exception>
#include <stdexcept>
// 作用域退出时执行的清理器:正常退出时不回滚,异常退出时回滚
class Rollback {
public:
// 是否需要回滚只由异常计数决定,不额外记录提交状态
Rollback() : m_onExit(std::uncaught_exceptions()) {}
~Rollback() {
// 析构被调用时若异常计数比构造时多,说明正在栈展开,必须回滚
if (std::uncaught_exceptions() > m_onExit) {
std::printf("rollback\n");
}
}
private:
int m_onExit;
};
struct Store {
void write(int v) { std::printf("write %d\n", v); }
};
void commitPath() {
Rollback guard;
Store s;
s.write(1);
std::printf("commit\n");
}
void abortPath() {
Rollback guard;
Store s;
s.write(2);
throw std::runtime_error("storage full");
}
int main() {
commitPath();
try {
abortPath();
} catch (const std::exception& e) {
std::printf("caught: %s\n", e.what());
}
std::printf("--- end of main ---\n");
return 0;
}
两条路径的输出:
write 1
commit
write 2
rollback
caught: storage full
--- end of main ---
第一条走到末尾正常返回,异常计数没变,析构什么都不做;第二条在 write 之后抛出,计数变大,析构回滚。把提交与清理压在一个对象里,靠计数判断方向,比在每条错误分支上各写一遍回滚少一些遗漏的机会。
把所有权写进类型
自动存储期对象离开作用域就析构,所以「只允许有一个所有者」的语义可以直接写进类型:拷贝删掉,移动把源对象置空。
struct FileGuard {
std::FILE* fp;
explicit FileGuard(const char* path) : fp(std::fopen(path, "w")) {}
~FileGuard() {
if (fp) {
std::fclose(fp);
}
}
FileGuard(const FileGuard&) = delete; // 句柄同一时刻只有一个所有者
FileGuard& operator=(const FileGuard&) = delete;
FileGuard(FileGuard&& o) noexcept : fp(o.fp) { o.fp = nullptr; } // 移动后原对象置空
};
移动之后原对象为空,两个对象的析构不会重复关闭:
open: ok
moved from: original is empty
close
--- end of main ---
标准库里有现成的:lock_guard 在构造时持有 mutex、析构时解锁、不可拷贝 [6] ;unique_ptr 在生命期结束或 reset 时用绑定的 deleter 释放对象 [7] 。要管的不是内存时,把删除器换掉就能复用 unique_ptr 的所有权语义:
struct FreeDeleter {
void operator()(void* p) const noexcept {
std::printf("custom deleter frees %p\n", p);
std::free(p);
}
};
std::unique_ptr<void, FreeDeleter> buf(std::malloc(64));
输出里的地址每次运行都不同:
owned pointer: 000002e11b661e10
custom deleter frees 000002e11b661e10
--- end of main ---
移动构造标上 noexcept
把 \(1000\) 个元素 emplace_back 进 vector,比较移动构造有没有 noexcept。
点击查看代码
struct MoveThrows {
int id;
static int copies;
static int moves;
explicit MoveThrows(int i) : id(i) {}
MoveThrows(const MoveThrows& o) : id(o.id) { ++copies; }
MoveThrows(MoveThrows&& o) : id(o.id) { ++moves; } // 没有 noexcept
};
struct MoveNoexcept {
int id;
static int copies;
static int moves;
explicit MoveNoexcept(int i) : id(i) {}
MoveNoexcept(const MoveNoexcept& o) : id(o.id) { ++copies; }
MoveNoexcept(MoveNoexcept&& o) noexcept : id(o.id) { ++moves; }
};
// 两类各插入 1000 次,这里只写其中一段
std::vector<MoveNoexcept> v;
for (int i = 0; i < 1000; ++i) {
v.emplace_back(i);
}
输出:
move ctor without noexcept: copies=1023 moves=0
move ctor with noexcept: copies=0 moves=1023
差别来自 vector 扩容时怎么把元素搬到新缓冲区。它用 move_if_noexcept 在移动和拷贝之间选:移动构造可能抛异常、类型同时可拷贝时选拷贝,因为拷贝中途失败还能退回旧缓冲区,原来的元素还在;移动途中失败就没法收拾。给移动构造标上 noexcept,移动才会被选中。
\(1023\) 是扩容搬迁的累计次数:
第一次插入分配容量 \(1\) 时没有元素要搬,之后容量按 \(2\) 的幂翻倍,每次翻倍把已有元素搬一遍。
代价在哪里
同一份逻辑写两遍,一遍用 RAII 封装、一遍手动 fopen 和 fclose,两个函数都加 noinline,在 -O2 下反汇编。objdump 出来的指令序列逐条一致:sub 调整栈、lea 取模式串、call fopen、保存返回值、test 判空、je 跳过关闭、call fclose、返回 \(1\)、收栈、ret。只有符号名不同。
正常路径上没有额外指令,析构被展开成了和手写释放一样的代码。
代价落在异常路径和展开信息上。同一个程序,默认开异常和加 -fno-exceptions 各编一份:可执行文件 \(81618\) 字节对 \(80686\) 字节,.text 段 0x81d8 对 0x8128,差 \(932\) 字节,约 \(1\) 个百分点。这点差异来自异常处理相关代码,与资源管理本身无关。
这份对比里两个可执行文件都没有 .eh_frame 段,展开信息走的是另一套结构,所以别用段名去判断有没有为异常付代价。
什么时候不合适
- 资源生命期和对象生命期对不上。连接池里的连接要反复借出归还,跨对象共享的资源由谁持有也得想清楚。包成 RAII 对象意味着不能在作用域中途提前交还,除非对象另外提供 reset 或 close。
- 释放的结果需要上报。析构不能抛,关闭和提交的返回值只能丢掉。要处理失败就得显式提供 close(),让析构去调用它并忽略结果。
- 所有权不明。从 C 接口拿回来的裸指针、回调里传出去的指针,先弄清谁负责释放,再决定包不包。包错方向比不包更糟。
- 跨编译单元的静态对象。析构顺序没有保证,别在一个静态对象的析构里依赖另一个静态对象还活着。
- 作用域比资源实际使用区间大。构造析构本身没有额外指令,但资源会一直占着,这种时候要手动提前释放。
完整实现
完整的文件句柄与作用域退出实现:
点击查看代码
#include <cstdio>
#include <utility>
/**
* @brief 独占文件句柄的极简 RAII 封装
* @note 不可拷贝,可移动;析构时自动关闭,析构函数不抛异常
*/
class FileHandle {
public:
FileHandle() noexcept = default;
/**
* @brief 打开文件并接管句柄
* @param path 文件路径
* @param mode 与 std::fopen 相同的模式串
*/
FileHandle(const char* path, const char* mode) noexcept : m_fp(std::fopen(path, mode)) {}
~FileHandle() { (void)close(); }
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
FileHandle(FileHandle&& other) noexcept : m_fp(other.m_fp) {
// 移动后原对象不再持有句柄,析构时不会重复关闭
other.m_fp = nullptr;
}
FileHandle& operator=(FileHandle&& other) noexcept {
if (this != &other) {
(void)close();
m_fp = other.m_fp;
other.m_fp = nullptr;
}
return *this;
}
/**
* @brief 显式关闭,需要知道关闭结果时使用
* @return 关闭成功或本来就没有句柄时返回 true
* @note 析构函数也调用它,只不过丢弃返回值
*/
bool close() noexcept {
if (!m_fp) {
return true;
}
std::FILE* fp = m_fp;
m_fp = nullptr;
return std::fclose(fp) == 0;
}
bool isOpen() const noexcept { return m_fp != nullptr; }
std::FILE* get() const noexcept { return m_fp; }
private:
std::FILE* m_fp = nullptr;
};
/**
* @brief 作用域退出时执行一次回调
* @tparam F 可调用的清理动作
*/
template <class F>
class ScopeExit {
public:
explicit ScopeExit(F f) noexcept : m_func(std::move(f)) {}
~ScopeExit() { m_func(); }
ScopeExit(const ScopeExit&) = delete;
ScopeExit& operator=(const ScopeExit&) = delete;
private:
F m_func;
};
程序的输出:
isOpen=1
moved, original isOpen=0
scope exit
--- end of main ---

浙公网安备 33010602011771号