4.4. RAII惯用法

C++开发者倾向于使用析构函数来自动释放资源,这确实可以称为语言中的惯用编程技术,以至于大家为它起了一个名字 —— RAII,即资源获取即初始化(也有人提出责任获取即初始化,同样适用且含义相近)。其核心思想是,对象通常在构造时(或之后)获取资源,但(更重要的是!)释放对象持有的资源通常应在对象生命周期结束时完成。因此,RAII 更关乎析构函数而非构造函数,但我们不太擅长命名和缩写。

回顾本章资源管理一节中的文件读取与处理示例,可以构建一个 RAII 资源处理器,确保无论函数如何结束,文件都能正确关闭:

class FileCloser {
  FILE * file;
public:
  FileCloser(FILE *file) : file{ file } {
  }
  ~FileCloser() {
    close_file(file);
  }
};

void f(const char *name) {
  FILE *file = open_file(name);
  if(!file) return; // 打开文件失败时

  FileCloser fc{ file }; // <-- fc 来管理文件

  vector<char> v;
  char buf[N]; // N是一个正整数常数
  for(int n = read_from(file, buf, N); n != 0; n = read_from(file, buf, N))
    v.insert(end(v), buf + 0, buf + n);

  process(v); // 具体的处理功能

} // 隐式调用 close_file(file)

FileCloser 的具体实现和职责范围取决于对它的定位:仅仅是管理文件的关闭,还是完整代表文件及其所有功能?本例中我选择了前者,但两种方式都合理 —— 关键在于希望实现的语义。重点是,通过使用 FileCloser 对象,将责任从使用端代码转移,转而委托一个自动化对象来关闭文件,从而简化代码并降低意外遗漏的风险。

这个 FileCloser 对象非常特定于当前任务。可以通过多种方式进行泛化(设计一个通用对象),在销毁时执行用户提供的一组操作:

template <class F> class scoped_finalizer { // 简化版本
  F f;
public:
  scoped_finalizer(F f) : f{ f } {
  }

  ~scoped_finalizer() {
    f();
  }
};

void f(const char *name) {
  FILE *file = open_file(name);
  if(!file) return; // 打开文件失败时

  auto sf = scoped_finalizer{ [&file] {
    close_file(file);
  } }; // <-- sf 来管理文件

  vector<char> v;
  char buf[N]; // N是一个正整数常数
  for(int n = read_from(file, buf, N); n != 0; n = read_from(file, buf, N))
    v.insert(end(v), buf + 0, buf + n);

  process(v); // 具体的处理功能
} // 通过sf的析构函数隐式close_file(file)

RAII 范式几乎遍布 C++ 的每个角落,可以说它是这门语言最具渗透力的范式,也是其最鲜明、最具代表性的编程实践之一。如今许多语言都提供了类似特性:C# 有 using 块,Java 有 try-with 块,Go 有 defer 关键字等。但在 C++ 中,利用作用域自动执行操作(通常与资源管理相关)的能力直接源自类型系统,使得对象 —— 而非用户代码 —— 成为符合范式惯例的资源管理者。

4.4.1 RAII和C++的特殊成员函数

第 1 章曾介绍过6个特殊成员函数(默认构造函数、析构函数、复制构造函数、复制赋值操作符、移动构造函数和移动赋值操作符)。在类中实现这些函数时,通常该类需要管理某些资源。若类不显式管理资源,通常可交由编译器生成这些函数,其默认行为往往能产生更简洁高效的代码。

RAII 范式本质上关乎资源管理 —— 将对象的析构时刻与释放先前获取资源的操作相绑定。众多 RAII 对象(包括前文示例中的 FileCloser 和 scoped_finalizer 类)可视为对其托管的资源负有责任,则复制这些对象可能引发错误(资源该由原对象还是副本对象来释放?)。因此,除非有充分理由需要显式实现复制操作,否则应考虑禁用 RAII 类型的复制功能:

template <class F> class scoped_finalizer {
  F f;
public:
  scoped_finalizer(const scoped_finalizer&) = delete;
  scoped_finalizer& operator=(const scoped_finalizer&) = delete;

  scoped_finalizer(F f) : f{ f } {
  }

  ~scoped_finalizer() {
    f();
  }
}

与其他编程范式一样,RAII 虽公认为优秀的编程实践,但并非万能“银弹” —— 过度依赖析构函数同样存在风险。我们将探讨析构函数可能引发的两类典型问题及其规避方案。