Skip to content
Go back

RAII:为什么资源应该绑定对象生命周期

手动释放资源,就得保证每条退出路径都做了清理。函数越长,越容易漏。

contents 8 sections
  1. 1把清理交给析构函数
  2. 2自己封装一个文件句柄
  3. 3构造到一半失败了
  4. 4Rule of Zero 和 Rule of Five
  5. 5移动为什么通常可以 noexcept
  6. 6恢复状态也可以用 RAII
  7. 7RAII 和栈、智能指针的关系
  8. 8它管不到的地方

拿到的资源最后总要还回去。比如加了锁就要解锁:

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 解决的是“什么时候释放”,剩下的问题还得自己处理:



← Previous Post
Reactor 与 Proactor:从 I/O 就绪到 I/O 完成
Next Post →
从一条编译命令开始理解 C++ 程序