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++ 中,利用作用域自动执行操作(通常与资源管理相关)的能力直接源自类型系统,使得对象 —— 而非用户代码 —— 成为符合范式惯例的资源管理者。
第 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 虽公认为优秀的编程实践,但并非万能“银弹” —— 过度依赖析构函数同样存在风险。我们将探讨析构函数可能引发的两类典型问题及其规避方案。