4.3. 资源管理

假设有一个函数,该函数需要打开文件、读取内容并在完成后关闭文件。可以使用的是一种过程式平台(如同大多数操作系统API那样),提供了一系列函数来完成这些操作。请注意,本例中所有“操作系统”函数均为虚构,但模拟了现实中的对应功能。该API中我们关注的函数有:

// 打开名为 "name" 的文件,返回指向该文件的文件描述符指针
// 如果打开失败,返回 nullptr
FILE *open_file(const char *name);

// 从文件中读取数据到缓冲区 buf 中,并返回实际读取的字节数
// 前置条件:
//   - file 不为 nullptr 且是有效的文件指针
//   - buf 指向一个至少有 capacity 个字节的缓冲区
//   - capacity >= 0
int read_from(FILE *file, char *buf, int capacity);

// 关闭文件
// 前置条件:file 不为 nullptr 且是有效的文件指针
void close_file(FILE *file);

假设代码需要处理从文件中读取的数据,但该处理过程可能抛出异常。异常的具体原因在此并不重要:可能是数据损坏、内存分配失败、调用某些会抛异常的辅助函数等。关键在于该函数存在抛出异常的风险。

如果简单地编写这个函数:

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

  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);
}

这段代码在正常情况下可以工作,但当没有异常处理时,它并不能完全满足我们的需求。现在假设 process(v) 抛出异常...会发生什么?此时函数 f() 会直接退出,无法满足其后置条件。process(v) 的调用未能完成…而 close_file(file) 也永远不会调用。这就产生了泄漏 —— 不一定是内存泄漏,但 file 未关闭确实造成了资源泄漏,从 process() 抛出的异常若未被 f() 捕获,将导致 f() 终止,并使异常继续传播到 f() 的调用者(依此类推,直到被捕获或程序崩溃)。

解决这个问题有几种方法。一种是“手动”处理,在可能抛出异常的代码周围添加 try...catch 块:

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

  vector<char> v;
  char buf[N]; // N是一个正整数常数

  try {
    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);
  } catch(...) { // 捕获一切异常
    close_file(file);
    throw; // 将捕获的异常重新抛出
  }
}

这种方式有些“笨拙”,close_file(file) 出现了两次:一次在 try 块末尾(正常情况下的关闭),另一次在 catch 块末尾(避免资源泄漏)。

手动方法可行,但它是一种不可靠的解决方案:C++ 中任何未声明为 noexcept 或 noexcept(true) 的函数都可能抛出异常,则实际上任何表达式都可能抛出异常。

捕获异常

C++异常类型并不像某些语言那样必须继承自某个统一的基类。实际上,throw 3;这样的语句是完全合法的C++代码。此外,C++拥有极其强大的泛型编程机制,这使得泛型代码在语言中无处不在。因此,经常会遇到需要调用可能抛出异常的函数,却又无法预知具体会抛出何种异常的情况。需要注意的是,catch(...)能够捕获所有用于表示异常的C++对象:虽然无法知晓捕获的具体类型,但异常确实已拦截。

在这种情况下,通常需要先拦截异常(很可能是为了执行某些清理操作),然后保持异常原封不动地继续传播,以便使用端代码能够按需处理。执行清理是为了确保函数既具备异常安全性(无泄漏、无状态损坏等),又保持异常中立性(不向需要处理问题的代码隐藏异常本质)。若要重新抛出已捕获的异常(即使是在catch(...)块中捕获的),只需使用throw;语句 —— 这称为“重新抛出”。

4.3.1 是否处理异常?

这引出了另一个问题:在像f()这样的函数中,仅在消费数据并为其目的进行处理时,是否真的应该寻求处理异常?

思考一下:抛出异常的要求与处理异常的要求大不相同。

从函数中抛出异常是为了表明函数无法实现其后置条件(无法完成本应完成的任务):也许内存不足,要读取的文件不存在,执行所请求的整数除法会导致除以零,从而毁灭宇宙;也许调用的某个函数无法满足其后置条件,且未能预见或不愿处理……函数失败的原因有很多。许多情况下,函数可能会发现自己处于继续执行将导致严重问题的境地,而在某些情况下(构造函数和重载操作符浮现在脑海中),异常确实是向使用端代码发出问题信号的唯一合理方式。

处理异常本身则要罕见得多:要抛出异常,必须识别问题;但要处理异常,则需要理解上下文。在交互式控制台应用程序中针对异常采取的操作,与人们在舞池中移动时的音频应用程序中的操作不同,也与面对内核代码崩溃时所需的操作不同。

大多数函数需要在某种程度上是异常安全的(这有不同的层次),而不是需要处理问题。在示例中,困难源于在异常发生时手动关闭文件。避免这种手动资源管理的最简单方法是将其自动化,而无论函数是正常完成(到达函数的闭合大括号、遇到返回语句、看到异常“飞过”)时在函数末尾发生的情况,都可以通过析构函数更好地建模。这种做法已经深深植根于C++开发者的实践中,赋予了一个名称:RAII惯用法。