拿到的资源最后总要还回去。比如加了锁就要解锁:
std::mutex mutex;
void update()
{
mutex.lock();
if (!ready()) {
return; // 忘记解锁
}
mutate();
mutex.unlock();
}
走到函数末尾没问题,但中间那个 return 直接跳过了 unlock()。mutate() 如果抛异常,也是一样的结果。
手动释放资源,就得保证每条退出路径都做了清理。函数越长,越容易漏。
把清理交给析构函数
RAII 是 Resource Acquisition Is Initialization 的缩写,名字不太好懂,做法倒是很简单:让一个对象在构造时拿到资源,在析构时释放资源。构造失败就抛异常,不留下一个半初始化、看起来还能用的对象。
这样做有用,是因为 C++ 保证局部对象离开作用域时一定会析构。正常走到结尾、中途 return、异常引起的栈展开,这几种情况都会调析构函数。资源什么时候释放,完全由对象什么时候死决定,跟垃圾回收器什么时候跑无关,调用方也不用记着每条路径该清理什么。
这里的“资源”指一切需要成对获取和释放的东西,内存只是其中一种。文件句柄、锁、socket、数据库连接、各种操作系统对象,都可以这么管。
标准库里的 std::lock_guard 就是管锁的:
std::mutex mutex;
void update()
{
std::lock_guard<std::mutex> lock(mutex);
if (!ready()) {
return;
}
mutate();
}
lock 构造时加锁,离开作用域时析构解锁,函数里不用再写 unlock()。
当然,锁的粒度不对、几把锁互相等着死锁,这些问题它都管不了。RAII 只保证一件事:拿到的锁一定会被释放。
自己封装一个文件句柄
C 的 FILE* 要手动 fclose:
std::FILE* handle = std::fopen("result.txt", "w");
if (!handle) {
throw std::runtime_error("failed to open file");
}
std::fputs("hello\n", handle);
std::fclose(handle);
fopen 和 fclose 之间只要有一处提前返回或者抛异常,句柄就漏了。把它包成一个类:
#include <cstdio>
#include <stdexcept>
#include <string_view>
#include <utility>
class File
{
public:
explicit File(const char* path)
: handle_(std::fopen(path, "w"))
{
if (!handle_) {
throw std::runtime_error("failed to open file");
}
}
~File() noexcept
{
close();
}
File(const File&) = delete;
File& operator=(const File&) = delete;
File(File&& other) noexcept
: handle_(std::exchange(other.handle_, nullptr))
{
}
File& operator=(File&& other) noexcept
{
if (this != &other) {
close();
handle_ = std::exchange(other.handle_, nullptr);
}
return *this;
}
void write(std::string_view text)
{
if (!handle_) {
throw std::logic_error("file is not open");
}
if (std::fwrite(text.data(), 1, text.size(), handle_) != text.size()) {
throw std::runtime_error("failed to write file");
}
}
private:
void close() noexcept
{
if (handle_) {
std::fclose(handle_);
handle_ = nullptr;
}
}
std::FILE* handle_ = nullptr;
};
用起来就不用管关闭了:
void save()
{
File file("result.txt");
file.write("hello\n");
}
save() 返回时 file 析构,文件随之关闭。以后在 write() 后面再加什么可能抛异常的代码,也不用回头改清理逻辑。
这个类有几处是刻意这么写的。
复制被删掉了。File 独占一个句柄,如果允许复制,两个对象会拿着同一个句柄,析构时各关一次。
移动是允许的。移动时把句柄交给新对象,旧对象置空。被移走的 File 还能正常析构、还能被重新赋值,只是不能再写。
析构函数是 noexcept 的。fclose 其实可能失败,但析构函数没有好办法把错误报出去,特别是在栈展开过程中,这时再抛异常程序会直接 terminate。如果关闭失败真的需要处理,比较常见的做法是额外提供一个显式的 close() 或 flush(),让调用方在析构之前自己调用,拿到错误。
实际写文件用 std::ofstream 就行,这里自己写一遍,是为了看清楚一个底层 RAII 类型需要考虑哪些事。
构造到一半失败了
局部对象出作用域会析构,这个好理解。容易忽略的是另一种情况:外层对象构造失败时,已经构造好的成员也会被清理。
#include <cstddef>
#include <vector>
class BufferedFile
{
public:
BufferedFile(const char* path, std::size_t buffer_size)
: file_(path)
, buffer_(buffer_size)
{
}
private:
File file_;
std::vector<char> buffer_;
};
成员的初始化顺序看的是声明顺序,跟初始化列表怎么写无关。这里先构造 file_,再构造 buffer_。
假设文件打开成功了,但 buffer_ 分配内存时抛了 std::bad_alloc:
| 对象 | 构造状态 | 是否析构 |
|---|---|---|
file_ | 已完成 | 是 |
buffer_ | 失败 | 否 |
BufferedFile | 未完成 | 否 |
BufferedFile 从来没构造完,它的析构函数不会运行。但 file_ 已经是一个完整的对象了,栈展开时会析构它,文件被关闭。buffer_ 自己没构造完,不会析构,它内部分配到一半的东西由 std::vector 自己处理。已经构造完的基类子对象也按同样的规则处理。
所以 BufferedFile 不需要写析构函数,也不用 try/catch 之后手动关文件。每个成员管好自己的资源,组合起来的类型就自动有了构造阶段的异常安全。
成员全部构造成功后,析构顺序和声明顺序相反。如果成员之间有依赖,被依赖的要先声明,这样它先构造、后析构。
要注意,这里只保证句柄不泄漏,没保证系统状态回到原样。用 "w" 打开文件时文件可能已经被创建或者截断了,后面构造失败,RAII 会把文件关掉,但不会帮你删掉新文件,也不会恢复被截断的内容。要做到“要么全成功,要么像什么都没发生”,得配合临时文件加原子重命名之类的提交方式。
Rule of Zero 和 Rule of Five
File 直接持有原始句柄,所以必须自己写析构。一旦写了析构、复制、移动里的任何一个,就应该把这五个特殊成员函数放在一起考虑:析构函数、复制构造、复制赋值、移动构造、移动赋值。这就是 Rule of Five。原因是它们之间会相互影响,比如用户声明了复制操作或者析构函数,编译器就不会再隐式声明移动操作,对这个类型的“移动”会悄悄退化成复制。每一个都要明确是 = default、= delete,还是自己实现。
更上层的类型最好一个都不写,这就是 Rule of Zero。它说的是不手写这五个管所有权的函数,普通构造函数照写不误:
class Document
{
public:
explicit Document(std::string name)
: name_(std::move(name))
{
if (name_.empty()) {
throw std::invalid_argument("document name is empty");
}
}
private:
std::string name_;
std::vector<std::string> paragraphs_;
};
Document 有一个自己写的构造函数,用来校验参数,但它符合 Rule of Zero。编译器照样会隐式声明那五个函数,行为都是逐个成员去析构、复制或移动,而 std::string 和 std::vector 已经把自己的资源管好了,逐成员的默认行为就是对的。
有一个副作用:只要声明了任何构造函数,编译器就不会再生成默认构造函数。如果还想支持 Document document;,得自己加一个,或者写 Document() = default;。
隐式声明的函数也不一定能用。比如成员里有 std::unique_ptr,复制构造和复制赋值仍然会被隐式声明,但因为 unique_ptr 不能复制,它们会被定义为 deleted。
逐成员复制和移动有一个前提:这样做不会破坏类型的不变量。如果对象里存了指向自己成员的指针,或者在别处注册过自己,逐成员复制出来的东西就是错的,这时候还是得自己定义复制和移动的语义。
移动为什么通常可以 noexcept
复制完成后,源对象和目标对象都要保持原来的值。对持有资源的类型来说,这一般意味着再申请一份资源:复制 std::vector 要分配新内存再逐个复制元素,复制一个文件句柄要么复制系统句柄,要么重新打开文件。这些操作都可能失败,所以复制构造函数通常可能抛异常。
移动允许改动源对象。File 的移动构造不用再开一个文件,把指针交过去、把源对象置空就完了:
File(File&& other) noexcept
: handle_(std::exchange(other.handle_, nullptr))
{
}
这里没有申请资源,也没有调用任何可能失败的外部操作,标 noexcept 是名副其实的。
反过来,移动也可能抛。如果移动的实现需要分配内存,或者某个成员的移动构造可能抛异常,外层的移动就不能保证不抛。编译器生成的移动构造是否 noexcept,取决于所有基类和成员的移动构造是不是都 noexcept。
noexcept 是一个承诺。标了之后异常还是跑出来了,程序直接 std::terminate()。std::move 也只是一个类型转换,把表达式转成右值,好让重载决议选中移动操作,它自己不移动任何东西。
noexcept 移动的实际好处在容器上。std::vector 扩容时要把旧元素搬到新内存里,如果元素的移动构造可能抛异常,而复制构造又可用,它会选择复制,这样中途失败时旧数据还完好。移动构造标了 noexcept,vector 才会放心去移动。
所以常见的分工是:底层的资源类型删掉复制、实现 noexcept 移动;上层业务类型组合这些资源类型,自己回到 Rule of Zero。
恢复状态也可以用 RAII
析构函数里能做的不只是 close()、unlock()、delete,也可以用来回滚:
class Transaction
{
public:
explicit Transaction(Database& database)
: database_(database)
{
database_.begin();
}
~Transaction() noexcept
{
if (!committed_) {
database_.rollback_noexcept();
}
}
void commit()
{
database_.commit();
committed_ = true;
}
private:
Database& database_;
bool committed_ = false;
};
调用方没有显式 commit(),析构时就自动回滚。临时改了配置之后改回来、结束一段性能 trace、撤销注册,都可以这么写。
这里假设 rollback_noexcept() 自己处理了失败的情况,因为析构函数不能把异常往外抛。回滚失败之后是记日志、重试、终止进程,还是交给上层去恢复,得看具体业务。
RAII 和栈、智能指针的关系
RAII 常常和局部变量一起出现,容易让人以为它就是“把资源放在栈上”。看 File 这个例子:局部变量 file 在栈上,它里面存了一个 FILE*,而文件本身是操作系统管理的,跟栈没有关系。RAII 对象也可以是别的对象的成员,或者放在容器里。要紧的是有一个明确的对象负责在析构时释放资源。
智能指针是 RAII 用在动态分配对象上的例子,但 RAII 的范围比智能指针大。std::unique_ptr 管堆上的对象,std::lock_guard 管锁,std::ofstream 管文件,是同一个思路。智能指针在此之上还表达了所有权:unique_ptr 是独占,shared_ptr 是共享,weak_ptr 是不延长生命周期的观察。这部分单独写。
它管不到的地方
RAII 解决的是“什么时候释放”,剩下的问题还得自己处理:
- 资源类型的析构函数不能让异常逃出去。
- 死锁、悬空引用、业务逻辑错误,RAII 都帮不上忙。
- 如果一个对象允许处于“没有持有资源”的状态(比如被移走之后),要想清楚这时哪些操作还能调用。
- 清理一定会发生,但操作失败后对象状态是否不变,也就是强异常保证,要靠具体实现来保证。
- 跨 translation unit 的全局 RAII 对象之间如果有依赖,初始化和析构的顺序得另外处理。