5.4. RAII惯用法

上一节中已经看到,临时性地管理资源的方法会变得不可靠、容易出错,最终导致失败。需要确保资源的获取总是与资源的释放相匹配,并且这两个操作分别发生在使用该资源的代码段之前和之后。在C++中,这种由一对操作将一段代码序列包围起来的模式,称为“执行环绕”设计模式。

Tip

欲了解更多信息,请参阅 Kevlin Henney 撰写的《C++ 模式 —— 执行环绕序列》一文,网址为:http://www.two-sdg.demon.co.uk/curbralan/papers/europlop/ExecutingAroundSequences.pdf

当这一模式具体应用于资源管理时,更为人所熟知的名称是“资源获取即初始化”(简称 RAII)。

5.4.1 RAII 简述

RAII 背后的基本思想非常简单 —— 只有一种函数能保证自动调用,就是在栈上创建的对象的析构函数,或者是作为另一个对象数据成员的对象的析构函数(后一种情况下,只有当包含该成员的类自身销毁时,此保证才成立)。如果将资源的释放操作绑定到此类对象的析构函数上,那么资源的释放就不可能遗忘或跳过。由此可以推断,如果资源的释放由析构函数来处理,资源的获取就应该在对象初始化期间由构造函数来完成。因此,也就有了本章标题中“全面了解 RAII”的完整含义。

来看看在最简单的内存分配场景(通过 operator new)中,这一机制是如何工作的。首先,需要一个类,能够通过指向新分配对象的指针进行初始化,并且其析构函数能够删除该对象:

// Example 05
template <typename T> class raii {
public:
  explicit raii(T* p) : p_(p) {}
  ~raii() { delete p_; }
private:
  T* p_;
};

现在,就能确保删除操作永远不会被遗漏,并且可以使用 object_counter 进行测试,以验证其按预期工作:

// Example 05
TEST(RAII, AcquireRelease) {
  object_counter::all_count = object_counter::count = 0;

  {
    raii<object_counter> p(new object_counter);
    EXPECT_EQ(1, object_counter::count);
    EXPECT_EQ(1, object_counter::all_count);
  } // 作用域结束,销毁p,其管理的对象自动删除

  EXPECT_EQ(0, object_counter::count);
  EXPECT_EQ(1, object_counter::all_count);
}

C++17 中类模板的类型可以从构造函数中自动推导出来,可以直接写成如下形式:

raii p(new object_counter);

无论出于何种原因,只要拥有资源的(RAII)对象销毁,就会触发 RAII 的资源释放机制,即使在抛出异常的情况下,清理工作也会自动完成:

// Example 05
struct my_exception {};

TEST(Memory, NoLeak) {
  object_counter::all_count = object_counter::count = 0;

  try {
    raii p(new object_counter);
    throw my_exception();
  } catch ( my_exception& e ) {

  }

  EXPECT_EQ(0, object_counter::count);
  EXPECT_EQ(1, object_counter::all_count);
}

当然,我们可能不仅仅想创建和删除新对象,还希望使用该对象,最好能够访问 RAII 对象内部存储的指针。可以使用标准的指针语法来提供这种访问,这使得 RAII 对象也表现为一种指针:

// Example 06
template <typename T> class scoped_ptr {
public:
  explicit scoped_ptr(T* p) : p_(p) {}
  ~scoped_ptr() { delete p_; }

  T* operator->() { return p_; }
  const T* operator->() const { return p_; }

  T& operator*() { return *p_; }
  const T& operator*() const { return *p_; }
private:
  T* p_;
};

这个指针可以在作用域结束时,自动删除其所指向的对象:

// Example 06
TEST(Scoped_ptr, AcquireRelease) {
  object_counter::all_count = object_counter::count = 0;

  {
    scoped_ptr p(new object_counter);
    EXPECT_EQ(1, object_counter::count);
    EXPECT_EQ(1, object_counter::all_count);
  }

  EXPECT_EQ(0, object_counter::count);
  EXPECT_EQ(1, object_counter::all_count);
}

当程序退出包含 scoped_ptr 对象的作用域时,会调用其析构函数。无论以何种方式退出该作用域 —— 函数提前返回、循环中的 break 或 continue 语句,或是抛出异常 —— 都能以完全相同的方式正确处理,且不会造成资源泄漏,也可以通过测试来验证:

// Example 06
TEST(Scoped_ptr, EarlyReturnNoLeak) {
  object_counter::all_count = object_counter::count = 0;

  do {
    scoped_ptr p(new object_counter);
    break;
  } while (false);

  EXPECT_EQ(0, object_counter::count);
  EXPECT_EQ(1, object_counter::all_count);
}

TEST(Scoped_ptr, ThrowNoLeak) {
  object_counter::all_count = object_counter::count = 0;

  try {
    scoped_ptr p(new object_counter);
    throw 1;
  } catch ( ... ) {

  }

  EXPECT_EQ(0, object_counter::count);
  EXPECT_EQ(1, object_counter::all_count);
}

所有测试均通过,确认没有发生内存泄漏:

[----------] 6 tests from Scoped_ptr
[ RUN      ] Scoped_ptr.AcquireRelease
[       OK ] Scoped_ptr.AcquireRelease (0 ms)
[ RUN      ] Scoped_ptr.EarlyReturnNoLeak
[       OK ] Scoped_ptr.EarlyReturnNoLeak (0 ms)
[ RUN      ] Scoped_ptr.ThrowNoLeak
[       OK ] Scoped_ptr.ThrowNoLeak (0 ms)
[ RUN      ] Scoped_ptr.DataMember
[       OK ] Scoped_ptr.DataMember (0 ms)
[ RUN      ] Scoped_ptr.Reset
[       OK ] Scoped_ptr.Reset (0 ms)
[ RUN      ] Scoped_ptr.Reseat
[       OK ] Scoped_ptr.Reseat (0 ms)
[----------] 6 tests from Scoped_ptr (0 ms total)

同样,也可以将作用域指针作为另一个类的数据成员使用 —— 例如一个拥有附属存储空间、并在析构时必须释放该空间的类:

class A {
public:
  A(object_counter* p) : p_(p) {}

private:
  scoped_ptr<object_counter> p_;
};

通过这种方式,就不必在类 A 的析构函数中手动删除对象。事实上,如果类 A 的每个数据成员都能以类似方式自行管理资源,那么类 A 甚至可能根本不需要显式定义析构函数。

熟悉 C++11 的人会认出,我们实现的 scoped_ptr 实际上是 std::unique_ptr 的一个非常基础的版本,后者可用于相同的目的,但标准库中的 unique_ptr 实现要复杂得多。但需要明确的是:在实际代码中你应该使用 std::unique_ptr;我们在此实现自己的 scoped_ptr 的唯一目的,是为了理解 RAII 指针的工作原理。

最后需要考虑的一个问题是性能,C++ 始终力求在可能的情况下实现零开销抽象。本例中,将原始指针封装到了一个智能指针对象中。然而,编译器并不需要为此生成机器指令;这种封装只是强制编译器生成原本在正确程序中就应该生成的代码。可以通过一个简单的基准测试来确认,scoped_ptr(或 std::unique_ptr)的构造/析构以及解引用操作所花费的时间,与原始指针的对应操作完全相同。例如,以下微基准测试(使用 Google Benchmark 库)比较了三种指针类型在解引用操作上的性能:

// Example 07
void BM_rawptr_dereference(benchmark::State& state) {
  int* p = new int;

  for (auto _ : state) {
    REPEAT(benchmark::DoNotOptimize(*p);)
  }

  delete p;
  state.SetItemsProcessed(32*state.iterations());
}

void BM_scoped_ptr_dereference(benchmark::State& state) {
  scoped_ptr<int> p(new int);

  for (auto _ : state) {
    REPEAT(benchmark::DoNotOptimize(*p);)
  }

  state.SetItemsProcessed(32*state.iterations());
}

void BM_unique_ptr_dereference(benchmark::State& state) {
  std::unique_ptr<int> p(new int);

  for (auto _ : state) {
    REPEAT(benchmark::DoNotOptimize(*p);)
  }

  state.SetItemsProcessed(32*state.iterations());
}

BENCHMARK(BM_rawptr_dereference);
BENCHMARK(BM_scoped_ptr_ dereference);
BENCHMARK(BM_unique_ptr_dereference);
BENCHMARK_MAIN();

该基准测试表明,智能指针确实没有性能开销:

----------------------------------------------------------------------
Benchmark Time CPU Iterations UserCounters...
BM_rawptr_dereference 3.42 ns 3.42 ns 817698667 items_per_second=9.35646G/s
BM_scoped_ptr_dereference 3.37 ns 3.37 ns 826869427 items_per_second=9.48656G/s
BM_unique_ptr_dereference 3.42 ns 3.42 ns 827030287 items_per_second=9.36446G/s

我们已经较为详细地介绍了 RAII 在内存管理方面的应用,但 C++ 程序还需要管理和跟踪其他类型的资源,因此必须扩展对 RAII 的理解。

5.4.2 RAII 用于其他资源

RAII 这个名称指的是“资源”而不仅仅是“内存”,同样的方法也适用于其他类型的资源。对于每种资源类型,都需要一个特定的对象,尽管泛型编程和 Lambda 表达式可以帮助我们减少代码量。资源在构造函数中获取,在析构函数中释放。

需要注意的是,RAII 有两种略有不同的实现方式。第一种方式是已经见过的:资源的实际获取发生在初始化阶段,但在 RAII 对象的构造函数之外。RAII 对象的构造函数仅捕获资源获取后得到的句柄(例如,指针)。刚才看到的 scoped_ptr 对象就是这种 —— 内存分配和对象构造都是在 scoped_ptr 对象的构造函数之外完成的,但仍属于其初始化过程的一部分。

第二种方式是 RAII 对象的构造函数本身直接负责获取资源。让我们通过一个管理互斥锁的 RAII 对象示例来了解这种工作方式:

// Example 08
class mutex_guard {
public:
  explicit mutex_guard(std::mutex& m) : m_(m) {
    m_.lock();
  }
  ~mutex_guard() { m_.unlock(); }
private:
  std::mutex& m_;
};

mutex_guard 类的构造函数自身就获取了资源 —— 即获得由互斥锁保护的共享数据的独占访问权,而析构函数则负责释放该资源。同样,这种模式完全消除了锁泄漏的可能性(即在未释放锁的情况下退出作用域),例如当抛出异常时也能确保锁正确释放:

// Example 08
std::mutex m;
TEST(MutexGuard, ThrowNoLeak) {
  try {
    mutex_guard lg(m);
    EXPECT_FALSE(m.try_lock()); // 预期 m 已锁定
    throw 1;
  } catch ( ... ) {

  }

  EXPECT_TRUE(m.try_lock()); // 预期 m 已解锁
  m.unlock(); // try_lock() 成功锁定了 m,现在需要手动解锁
}

在此测试中,通过调用 std::mutex::try_lock() 来检查互斥锁是否已锁定 —— 如果互斥锁已锁定,就不能调用 lock(),否则会导致死锁。通过调用 try_lock(),可以在没有死锁风险的情况下检查互斥锁的状态(如果 try_lock() 调用成功,需要手动解锁互斥锁,若仅用它来进行状态检测,并不希望再次锁定互斥锁)。

同样,标准库也提供了一个用于互斥锁管理的 RAII 对象:std::lock_guard。使用方式类似,但可应用于具有 lock() 和 unlock() 成员函数的互斥锁类型:

try {
  std::lock_guard lg(m); // C++17 类型推导构造
  EXPECT_FALSE(m.try_lock()); // 预期 m 已锁定
  throw 1;
} catch ( ... ) {

}
EXPECT_TRUE(m.try_lock()); // 预期 m 已解锁

C++17 中,还有一个用于同时锁定多个互斥锁的类似 RAII 对象:std::scoped_lock。除了提供 RAII 式的资源释放外,在同时锁定多个互斥锁时还提供了避免死锁的算法。

当然,C++ 程序可能需要管理的资源类型还有很多,常常需要自己编写 RAII 对象。有时标准库会提供帮助,例如 C++20 中新增的 std::jthread(线程也是一种资源,“释放”它通常意味着要等待线程结束,这正是 std::jthread 在其析构函数中所做的)。

鉴于 RAII 技术可管理的资源种类繁多,有时需求会超出“在作用域结束时自动释放资源”的范畴。

5.4.3 提前释放

函数或循环体的作用域并不总是与资源持有期的理想时长完全匹配。如果不想在作用域一开始就获取资源,这很容易实现 —— RAII 对象可以在作用域内的任意位置创建,而不仅仅是在开头。资源只有在 RAII 对象构造时才会获取:

void process(...) {
  ... 执行不需要独占访问的工作 ...
  mutex_guard lg(m); // 现在加锁
  ... 处理共享数据,现在受到互斥量的保护 ...
} // 锁在此处释放

然而,资源的释放仍然发生在函数体作用域的末尾。如果只想在函数内部锁定一小段代码呢?最简单的解决方法是创建一个额外的作用域:

void process(...) {
  ... 执行不需要独占访问的工作 ...
  {
    mutex_guard lg(m); // 现在加锁
    ... 处理共享数据,现在受到互斥量的保护 ...
  } // 锁在此处释放
  ... 更多不需要独占访问的工作 ...
}

如果之前从未见过这种写法,这可能会令人感到意外,但 C++ 中语句序列都可以用花括号 { ... } 括起来。这样做会创建一个新的作用域,并拥有自己的局部变量。与循环或条件语句后面的花括号不同,这种作用域的唯一目的就是控制这些局部变量的生命周期。一个使用 RAII 的程序通常会包含许多这样的作用域,用于限定变量的生命周期,使其短于整个函数或循环体。这种做法还能提高代码的可读性,可清楚地表明某些变量在某个点之后将不再使用,读者无需再扫描代码的其余部分去查找对这些变量的可能引用。此外,如果本意是让某个变量在作用域结束后失效且不再使用,用户也就不会再次引用。

如果资源可能需要提前释放,但仅在满足特定条件的情况下呢?一种可行的方法是再次将资源的使用限制在一个作用域内,并在不再需要资源时退出该作用域。如果能使用 break 语句跳出该作用域,那将会非常方便。一个常见的实现方式是编写一个 do-once 循环:

// Example 08
void process(...) {
  ... 执行不需要独占访问的工作 ...
  do { // 实际上不是一个循环
    mutex_guard lg(m); // 现在加锁
    ... 处理共享数据,现在受到互斥量的保护 ...
    if (work_done) break; //  退出该作用域
      ... 继续处理共享数据 ...
  } while (false); // 锁在此处释放
  ... 更多不需要独占访问的工作 ...
}

然而,这种方法并不总是奏效(可能只想释放资源,而不释放在同一作用域中定义的其他局部变量),并且随着控制流变得复杂,代码的可读性也会下降。请勿使用 operator new 动态分配 RAII 对象来实现此目的的冲动!这完全违背了 RAII 的初衷,因为现在调用 operator delete。可以通过增强资源管理对象,除了析构函数的自动释放外,再添加由使用端触发的显式释放功能。只需确保同一资源不会被释放两次即可。请看以下使用 scoped_ptr 的示例:

// Example 06
template <typename T> class scoped_ptr {
public:
  explicit scoped_ptr(T* p) : p_(p) {}
  ~scoped_ptr() { delete p_; }
  ...
  void reset() {
    delete p_;
    p_ = nullptr; // 提前释放资源
  }
  T* p_;
};

调用 reset() 后,会删除scoped_ptr 对象所管理的对象,同时 scoped_ptr 对象内部的指针成员会重置为 nullptr。我们无需在析构函数中添加条件检查, C++ 标准允许对空指针调用 delete —— 这种操作是安全的,不会产生效果。资源只会释放一次,要么通过显式的 reset() 调用,要么在包含 scoped_ptr 对象的作用域结束时隐式释放。

除了学习 RAII 指针的工作原理外,通常不需要自己编写 scoped_ptr:std::unique_ptr 同样也支持 reset() 操作。

对于 mutex_guard 类,我们无法仅通过锁的状态来判断是否已提前释放,因此需要一个数据成员来跟踪这一状态:

// Example 08
class mutex_guard {
public:
  explicit mutex_guard(std::mutex& m) :
    m_(m), must_unlock_(true) { m_.lock(); }
  ~mutex_guard() { if (must_unlock_) m_.unlock(); }
  void reset() { m_.unlock(); must_unlock_ = false; }

private:
  std::mutex& m_;
  bool must_unlock_;
};

现在,可以通过以下测试,来验证互斥锁确实在正确的时间点仅释放了一次:

TEST(MutexGuard, Reset) {
  {
    mutex_guard lg(m);
    EXPECT_FALSE(m.try_lock());
    lg.reset();
    EXPECT_TRUE(m.try_lock()); m.unlock();
  }
  EXPECT_TRUE(m.try_lock()); m.unlock();
}

标准库中的 std::unique_ptr 支持 reset() 方法,而 std::lock_guard 则不支持。因此,如果需要提前释放互斥锁,就必须使用另一种标准 RAII 对象:std::unique_lock:

// Example 08
TEST(LockGuard, Reset) {
  {
    std::unique_lock lg(m);
    EXPECT_FALSE(m.try_lock());
    lg.unlock();
    EXPECT_TRUE(m.try_lock()); m.unlock();
  }
  EXPECT_TRUE(m.try_lock()); m.unlock();
}

对于其他类型的资源,可能需要自己编写 RAII 对象。这通常是一个相当简单的类,但在开始编写之前,请先完整阅读本章内容,有几个陷阱需要注意。

std::unique_ptr 的 reset() 方法实际上,不仅仅是在对象需要提前释放时调用删除操作。还可以用于将指针重置为指向一个新对象,同时自动删除旧对象。其工作方式大致如下(标准库中的实际实现更为复杂一些,unique_ptr 还具备其他附加功能):

template <typename T> class scoped_ptr {
public:
  explicit scoped_ptr(T* p) : p_(p) {}
  ~scoped_ptr() { delete p_; }
  ...
  void reset(T* p = nullptr) {
    delete p_; p_ = p; // 重置指针
  }
private:
  T* p_;
};

如果一个作用域指针重置为指向自身(例如,调用 reset() 时传入的参数与当前存储在 p_ 中的值相同),上述代码将会出错。可以检查这种情况并选择不进行操作,并且标准并未要求 std::unique_ptr 必须进行此类自赋值检查。

5.4.4 RAII 对象的谨慎实现

显然,资源管理对象必须能够正确管理它们所负责的资源,绝不能出现管理失误。但到目前为止,我们所编写的简单 RAII 对象存在几个明显的缺陷。

第一个问题出现在有人试图复制这些对象时。本章中的每个 RAII 对象都负责管理其资源的唯一实例,然而,目前没有机制能阻止复制这些对象:

scoped_ptr<object_counter> p(new object_counter);
scoped_ptr<object_counter> p1(p);

这段代码会调用默认的复制构造函数,该函数只是简单地复制对象内部的数据位;我们的例子中,指针复制到了 object_counter。现在,有了两个 RAII 对象,都管理着同一个资源。最终,两个析构函数都会调用,并且都会尝试删除同一个对象。第二次删除属于未定义行为(如果足够幸运,程序会在那时崩溃)。

RAII 对象的赋值同样存在问题:

scoped_ptr<object_counter> p(new object_counter);
scoped_ptr<object_counter> p1(new object_counter);
p = p1;

默认的赋值操作符也会复制对象的数据位。同样,得到了两个 RAII 对象,都将删除同一个被管理的对象。更麻烦的是,没有 RAII 对象来管理第二个 object_counter,p1 内部的旧指针丢失了,并且没有其他对该对象的引用,因此无法删除它。

mutex_guard 的情况也好不到哪里去 —— 尝试复制会导致两个互斥锁保护对象试图解锁同一个互斥锁。第二次解锁将作用于一个未锁定的互斥锁(至少调用线程并未锁定)。根据标准,这属于未定义行为。不过,不可能对mutex_guard 对象进行赋值,因为含有引用类型数据成员的对象不会生成默认的赋值操作符。

有读者可能已经注意到,问题是由默认的复制构造函数和默认的赋值操作符引起的。这是否意味着应该自己实现它们?它们应该做什么?每个构造的对象应该只调用一次析构函数;一个互斥锁在锁定后只能解锁一次。这表明 RAII 对象根本不应该复制,我们应该禁止复制和赋值:

template <typename T> class scoped_ptr {
public:
  explicit scoped_ptr(T* p) : p_(p) {}
  ~scoped_ptr() { delete p_; }
  ...
private:
  T* p_;
  scoped_ptr(const scoped_ptr&) = delete;
  scoped_ptr& operator=(const scoped_ptr&) = delete;
};

有些 RAII 对象是可以复制。这些是引用计数的资源管理对象,会跟踪针对同一管理资源实例的 RAII 对象有多少个副本。当最后一个 RAII 对象删除时,必须释放该资源。

对于移动构造函数和移动赋值,存在另一套考量。移动对象并不会违反“只有一个 RAII 对象拥有特定资源”的假设,它只是改变了是哪一个 RAII 对象拥有该资源。许多情况下,例如互斥锁保护对象,移动 RAII 对象是没有意义的(标准库并未将 std::lock_guard 或 std::scoped_lock 设计为可移动的,但 std::unique_lock 是可移动的,可用于转移互斥锁的所有权)。在某些上下文中,移动唯一指针是可行且有意义的。

然而,对于作用域指针而言,移动操作是不希望发生的,因其允许对象的生命周期超出其创建时所在的作用域。需要注意的是,如果已经删除了复制构造函数和复制赋值操作符,通常不需要再显式删除移动构造函数和移动赋值操作符(尽管这样做也无害)。另一方面,std::unique_ptr 是一个可移动的对象,所以将其用作作用域保护的智能指针时,无法提供相同的保护,资源可能会移出作用域。但如果确实需要一个作用域指针,有一个非常简单的方法可以让 std::unique_ptr 完美地胜任这项工作 —— 只需要声明一个 const std::unique_ptr 对象即可:

std::unique_ptr<int> p;
{
  // 可以移出当前作用域
  std::unique_ptr<int> q(new int);

  q = std::move(p); // 移动操作在此发生

  // 真正的作用域指针,无法移动到地方
  const std::unique_ptr<int> r(new int);

  q = std::move(r); // 无法编译通过
}

目前,我们已经保护了 RAII 对象,防止资源被复制或丢失。但还有一种资源管理错误我们尚未考虑。资源的释放方式应与其获取方式相匹配,这看似显而易见。然而,我们的 scoped_ptr 对象却无法避免构造与删除之间的这种不匹配:

scoped_ptr<int> p(new int[10]);

这里的问题在于,使用了 operator new 的数组版本来分配多个对象,应该使用 operator delete 的数组版本来删除 —— 即必须在 scoped_ptr 的析构函数中调用 delete[] p_,而不是像现在这样调用 delete p_。

更一般地说,在初始化期间接受资源句柄的 RAII 对象(不像 mutex_guard 那样自行获取资源),必须以某种方式确保资源的释放方式与其获取方式相匹配。显然,这在一般情况下不可能实现。事实上,即使是对于 new[] 和 delete 标量这种简单情况,也无法自动处理(std::unique_ptr 在这方面并不比我们的 scoped_ptr 更好,尽管像 std::make_unique 这样的工具可以减少此类代码的出错几率)。

通常,RAII 类要么被设计为以某种特定方式释放资源,要么由调用者指定资源的释放方式。前者显然更简单,在许多情况下也完全足够。如果 RAII 类本身也获取资源(如我们的 mutex_guard),当然知道如何释放资源。即使对于 scoped_ptr,创建两个版本也是不难的:scoped_ptr 和 scoped_array;后者用于由 operator new[] 分配的对象。标准库通过为数组特化唯一指针来处理这个问题:可以写成 std::unique_ptr<int[]>,这样就会使用数组版本的 delete(但开发者仍需确保由 new[] 分配的数组由正确实例化的指针来管理)。

更通用的 RAII 类不仅由资源类型参数化,还由用于释放该资源的可调用对象(通常称为“删除器”或“deleter”)参数化。删除器可以是一个函数指针、成员函数指针,或定义了 operator() 的对象 —— 基本上,可以像函数一样被调用的东西。删除器必须在 RAII 对象的构造函数中传入,并存储在 RAII 对象内部,这会使对象变大。此外,删除器的类型是 RAII 类的模板参数,除非它从 RAII 类型中擦除。标准库给出了两种情况的例子:std::unique_ptr 使用删除器作为模板参数,而 std::shared_ptr 则使用类型擦除。

5.4.5 RAII 的缺点

老实说,RAII 几乎没有缺点,它无疑是 C++ 中应用最广泛的资源管理惯用法。唯一需要关注的重要问题与异常有关。

释放资源可能会失败,就像其他操作一样。在 C++ 中,报告失败的常规方式是抛出异常。当这种方式不可取时,我们会退而使用函数返回错误码。使用 RAII 时,这两种方式我们都无法采用。

很容易理解为什么错误码不是一个可行的选择 —— 析构函数不返回值。而且,也不能将错误码写入对象的某个状态数据成员中,因为对象正在销毁,其数据成员以及包含 RAII 对象的作用域中的其他局部变量都已不复存在。唯一能保存错误码以供将来检查的方法,是将其写入某种全局状态变量,或者至少是包含作用域中的某个变量。这在紧急情况下是可能的,但这样的解决方案非常不优雅且容易出错。这正是 C++ 在引入异常机制时试图解决的问题:手动传递的错误码既容易出错又不可靠。

如果异常是 C++ 中错误报告的解决方案,为什么不能在这里使用呢?通常的回答是,C++ 中的析构函数不能抛出异常。这抓住了问题的核心,但实际情况的限制更为微妙。

首先,在 C++11 之前,析构函数在技术上是可以抛出异常的,但抛出的异常会向外传播,并(希望)最终捕获和处理。从 C++11 开始,所有析构函数默认都是 noexcept 的,除非显式指定为 noexcept(false)。如果一个 noexcept 函数抛出了异常,程序将立即终止。

因此,在 C++11 中,除非明确允许,析构函数不能抛出异常。但在析构函数中抛出异常究竟有什么问题呢?如果析构函数的执行是因为对象被删除,或者是因为控制流正常到达了栈对象作用域的末尾,那么抛出异常本身并没有问题。真正的问题在于,如果控制流并非正常到达作用域末尾,而是因为已经有异常抛出,此时析构函数执行。在 C++ 中,同一时间不能有两个异常同时向外传播。如果这种情况发生,程序将立即终止(注意,析构函数内部可以抛出并捕获异常,只要该异常不从析构函数中传播出去就没有问题)。

当然,在编写程序时,我们无法预知在某个特定作用域内调用的某个函数是否会抛出异常。如果资源释放操作会抛出异常,并且 RAII 对象允许该异常从其析构函数中传播出去,那么当该析构函数在异常处理过程中被调用时,程序就会终止。唯一安全的做法就是:永远不要允许异常从析构函数中传播出去。

这并不意味着释放资源的函数本身不能抛出异常,但如果它确实抛出了异常,RAII 的析构函数必须捕获该异常:

class raii {
  ...
  ~raii() {
    try {
      release_resource(); // 可能抛出异常
    } catch ( ... ) {
      ...  处理异常,切勿重新抛出 ...
    }
  }
};

这仍然让我们无法通知资源释放过程中发生了错误 —— 抛出了异常,但需要捕获它,不让它逃逸出去。

这究竟有多大问题呢?其实并不大。首先,释放内存 —— 最常被管理的资源 —— 本身不会抛出异常。通常,内存的释放不仅仅是作为内存释放,而是通过删除一个对象来完成的。但请记住,析构函数本身不应抛出异常,这样才能确保通过删除对象来释放内存的整个过程也不会抛出异常。此时,读者可能会试图寻找反例,去查阅标准中关于解锁互斥锁失败会发生什么(这将迫使 std::lock_guard 的析构函数处理错误)。答案既令人惊讶又富有启发性 —— 解锁互斥锁不会抛出异常,但如果失败,会导致未定义行为。这绝非偶然,互斥锁的设计初衷就是与 RAII 对象协同工作。一般来说,C++ 对资源释放的处理方式就是如此:如果释放失败,不应抛出异常,或者至少不允许异常向外传播。例如,异常可以捕获并记录日志,但调用程序通常不会意识到失败的发生,其代价可能是导致未定义行为。

RAII 是一种非常成功的技术,即使语言从 C++11 之前发展到 C++20(除了一些微小的语法便利,如构造函数参数推导),本身几乎没有发生演变。这正是因为它确实没有显著的缺点,但随着语言获得新的能力,有时我们也会找到方法来改进那些最优秀、最成熟的模式,而这就是其中一个模式。