5.5. 现代RAII技术的演进

如果真的想挑剔一点,还可以对 RAII 提出另一点批评;只有当获取或释放资源的代码很长且复杂时,这才会成为一个缺点。资源的获取和释放分别在 RAII 对象的构造函数和析构函数中完成,而这些代码可能与资源实际获取的位置相距甚远(必须在程序中来回跳转才能弄清楚到底做了什么)。

同样,如果资源的处理需要维护大量状态(例如,根据多种因素和条件执行相应的操作),就必须将所有这些状态都封装在 RAII 对象中。一个真正能挑战 RAII 可读性的例子在书页上也会完全难以理解,必须将其简化。假设我们想要一个 RAII 锁保护对象,在锁定和解锁互斥锁时需要执行多个操作,并且其处理资源的方式甚至取决于一些外部参数:

// Example 09a
class lock_guard {
  std::mutex& m_;
  const bool log_;
  const bool mt_;
public:
  lock_guard(std::mutex& m, bool log, bool mt);
  ~lock_guard();
};

lock_guard ::lock_guard(std::mutex& m, bool log, bool mt)
  : m_(m), log_(log), mt_(mt) {
  if (log_) std::cout << "Before locking" << std::endl;
  if (mt_) m.lock();
}

lock_guard::~lock_guard() {
  if (mt_) m.unlock();
  if (log_) std::cout << "After locking" << std::endl;
}

而这个守护对象的使用方式如下:

#include <mutex>

std::mutex m;
const bool mt_run = true;

void work() {
  try {
    lock_guard lg(m, true, mt_run);
    ... 这里可能会抛出异常 ...
  std::cout << "Work is happening" << std::endl;
  } catch (...) {}
}

除了锁定和解锁之外,只执行了一个可能的操作 —— 可以选择性地记录这些事件,构造函数和析构函数(这两段代码必须紧密对应)的实现彼此之间有些分离。此外,跟踪状态(是否需要记录事件?我们是在多线程还是单线程环境中运行?)也变得有些冗长。再次强调,必须记住这是一个简化的例子:在实际程序中,这仍然是一个不错的 RAII 对象。但如果代码变得更长,可能会希望有更好的方法。

这种情况下,更好的方法借鉴自 Python(具体来说,来自 contextmanager 装饰器)。这种技术使用了协程,需要 C++20(所以我们成功地将 C++ 中最古老的工具与最前沿的技术结合在了一起)。关于协程的总体原理以及 C++ 协程机制的详细解释超出了本书的范围(可以在我的另一本书《The Art of Writing Efficient Programs》(https://www.packtpub.com/product/the-art-of-writing-efficient-programs/9781800208117) 中找到相关内容)。

目前,只需记住两点:

先看看使用这种基于协程的 RAII 新方法后,锁保护代码的样子:

#include <mutex>
std::mutex m;

const bool mt_run = true;
co_resource<std::mutex> make_guard(std::mutex& m, bool log)
{
  if (log) std::cout << "Before locking" << std::endl;
  if (mt_run) m.lock();
  co_yield m;
  if (mt_run) m.unlock();
  if (log) std::cout << "After locking" << std::endl;
}

void work () {
  try {
    co_resource<std::mutex> lg { make_guard(m, true) };
    ... 这里的代码可能会抛出异常 ...
    std::cout << "Work is happening" << std::endl;
  } catch (...) {}
}

我们不再使用 lock_guard 对象,而是使用 make_guard 函数。这个函数是一个协程;可以通过包含 co_yield 操作符来识别 —— 这是 C++ 协程将值返回给调用者的方式。co_yield 之前的代码是资源获取部分,在获取资源时执行,相当于 lock_guard 对象的构造函数。co_yield 之后的代码则相当于 lock_guard 的析构函数。这种方式更容易阅读和维护,因为所有代码都集中在一起。可以把 co_yield 想象成一个占位符,代表调用者在拥有资源(在我们的例子中是互斥锁)期间要做的工作。此外,也不需要编写类成员和成员初始化 —— 函数参数在整个协程执行过程中都可访问。

该协程返回一个 co_resource<std::mutex> 类型的对象:这就是现代的 RAII 类型,可以实现为 co_resource 类模板:

// Example 09
#include <cassert>
#include <coroutine>
#include <cstddef>
#include <memory>
#include <utility>

template <typename T> class co_resource {
public:
  using promise_type = struct promise_type<T>;
  using handle_type = std::coroutine_handle<promise_type>;

  co_resource(handle_type coro) : coro_(coro) {}
  co_resource(const co_resource&) = delete;
  co_resource& operator=(const co_resource&) = delete;

  co_resource(co_resource&& from)
    : coro_(std::exchange(from.coro_, nullptr)) {}

  co_resource& operator=(co_resource&& from) {
    std::destroy_at(this);
    std::construct_at(this, std::move(from));
    return *this;
  }

  ~co_resource() {
    if (!coro_) return;
    coro_.resume(); // 从调用 co_yield 的点继续执行
    coro_.destroy(); // 清理
  }
private:
  handle_type coro_;
};

这个对象拥有资源,但不会直接看到资源本身或其类型 T,它们隐藏在协程句柄 coro_ 内部。这个句柄的作用类似于指向协程状态的指针。

如果暂时将这个句柄视为一种资源,可以得到的是一个相当常规的资源拥有型对象。它在构造函数中获取资源并保持独占所有权:该句柄会在 co_resource 对象的析构函数中销毁,除非通过移动操作转移了所有权,并且不允许复制该资源。

这个资源拥有型对象将由一个协程函数返回;每个这样的对象都必须包含一个名为 promise_type 的嵌套类型。它通常是一个嵌套类,但也可以是独立的类型(在这个例子中,我们选择后者,主要是为了避免出现一个过长的代码片段)。标准对 promise_type 的接口施加了若干要求,以下是满足我们需求的类型定义:

template <typename T> struct promise_type {
  const T* yielded_value_p = nullptr;
  std::suspend_never initial_suspend() noexcept {
    return {};
  }

  std::suspend_always final_suspend() noexcept {
    return {};
  }

  void return_void() noexcept {}

  void unhandled_exception() { throw; }

  std::suspend_always yield_value(const T& val) noexcept {
    yielded_value_p = &val;
    return {};
  }

  using handle_type = std::coroutine_handle<promise_type>;
  handle_type get_return_object() {
    return handle_type::from_promise(*this);
  }
};

我们的 promise_type 包含一个指向协程返回值的指针,如何设置这个值?当协程通过 co_yield 返回结果时,编译器会生成对成员函数 yield_value() 的调用。co_yield 返回的值会传递给这个函数,该函数进而捕获其地址(该值的生命周期与协程本身相同)。

promise_type 的另一个重要成员函数是 get_return_object():由编译器调用,将 co_resource 对象本身返回给 make_guard() 协程的调用者。它返回的不是一个 co_resource 对象,而是一个可以隐式转换为 co_resource 的句柄(有一个从 handle_type 的隐式构造函数)。promise_type 接口的其余部分,对我们来说,就是标准所要求的样板代码。

以下是 co_resource RAII 的工作原理:首先,调用 make_guard() 函数:

co_resource<std::mutex> make_guard(std::mutex& m, bool log)
{
  if (log) std::cout << "Before locking" << std::endl;
  if (mt_run) m.lock();
  co_yield m;
  if (mt_run) m.unlock();
  if (log) std::cout << "After locking" << std::endl;
}

从调用者的角度来看,它像其他函数一样开始执行,尽管内部细节大不相同。co_yield 之前我们编写的所有代码都会执行,然后协程暂停,co_resource<std::mutex> 对象构造并返回给调用者,在调用者处移动到一个栈变量中:

co_resource<std::mutex> lg { make_guard(m, true) };

执行继续进行,由已锁定的互斥锁提供保护。在作用域末尾,对象 lg 销毁;无论是正常退出作用域还是因抛出异常而退出,这都会发生。在 co_resource 对象的析构函数中,通过调用协程句柄 coro_ 的成员函数 resume() 来恢复协程的执行。这导致协程从之前停止的位置之后继续执行,控制流跳转到 co_yield 之后的下一行。我们在那里编写的资源释放代码被执行,协程最终从作用域底部退出。co_resource 的析构函数还有一些清理工作要做,但除此之外,其他(基本上)都完成了。

为了不使示例过于冗长,我们省略了一些内容。首先,如果资源类型 T 是引用,co_resource 模板将无法工作。这可能是完全可以接受的:使用 RAII 来管理引用并不常见。一个 static_assert 或概念检查就足够了,否则必须仔细处理模板内部的依赖类型。

其次,对像 make_guard() 这样的协程有一个隐式要求,必须通过 co_yield 恰好返回一次值(协程体中可以有多个 co_yield,但对于特定的一次调用,只能执行其中一个)。为了使代码更健壮,必须使用运行时断言来验证这一要求是否得到满足。

现在,将资源获取和释放代码紧挨在一起,而且不再需要像在构造函数和析构函数处理 RAII 时那样,将函数参数转换为数据成员。唯一能让它变得更好的事情是,甚至不必编写单独的 make_guard() 函数,至少在只调用一次的情况下。事实证明,可以将协程和 Lambda 表达式结合起来,实现这样的效果:

void work(){
  try {
    auto lg { [&](bool log)->co_resource<std::mutex> {
      if (log) std::cout << "Before locking" << std::endl;
      if (mt_run) m.lock();
      co_yield m;
      if (mt_run) m.unlock();
      if (log) std::cout << "After locking" << std::endl;
    }(true) };
    ... 这里可能会抛出异常 ...
    std::cout << "Work is happening" << std::endl;
  } catch (...) {}
}

这里的协程是 Lambda 表达式的 operator();必须显式指定返回类型。该 Lambda 会立即调用,使用捕获列表还是参数,取决于在每种情况下哪种更方便。

通过基于协程的 RAII 资源管理,成功地将所有相关的代码部分紧密地结合在一起。当然,这也有代价:启动、暂停和恢复协程需要时间(以及一些内存开销)。基本的 RAII 对象总是更快的,不要试图用 co_resource 类来替换 std::unique_ptr。但我们最初对 RAII 的不满源于一个观察:当资源获取或释放所执行的代码很长、很复杂,并且使用大量状态变量时,RAII 类可能会变得难以阅读。在这种情况下,协程的开销可能就不那么重要了(我们还应指出,第 11 章中描述的“作用域守卫”模式也解决了部分问题,有时是更好的选择)。

我们所学的 RAII 技术是一些最持久的 C++ 模式;它们从 C++ 诞生的第一天起就在使用,并持续发展,受益于最新的语言特性。在本书中,我们将使用诸如 std::unique_ptr 或 std::lock_guard 这样的类。