11.5. 类型擦除的作用域守卫

如果在网上搜索 ScopeGuard 的示例,可能会偶然发现一种使用 std::function 而非类模板的实现方式。该实现本身相当简单:

// Example 11
class ScopeGuard {
public:
  template <typename Func> ScopeGuard(Func&& func) :
    func_(std::forward<Func>(func)) {}

  ~ScopeGuard() { if (!commit_) func_(); }

  void commit() const noexcept { commit_ = true; }

  ScopeGuard(ScopeGuard&& other) :
    commit_(other.commit_), func_(std::move(other.func_)) {
    other.commit();
  }

private:
  mutable bool commit_ = false;
  std::function<void()> func_;
  ScopeGuard& operator=(const ScopeGuard&) = delete;
};

这个 ScopeGuard 是一个普通类,而不是类模板。拥有模板化的构造函数,可以接受与之前模板化版本相同的 Lambda 表达式或其他可调用对象。但无论可调用对象的类型是什么,用于存储该表达式的变量类型始终是固定的。这个类型就是 std::function<void()>,它是一个包装器,可以容纳无参数、无返回值的函数。

那么,任意类型的值是如何存储在一个固定类型的对象中的呢?这就是所谓的“类型擦除”的魔法。

这种非模板化的 ScopeGuard 使得使用它的代码更加简洁(至少在 C++17 之前版本中是如此),避免了复杂的类型推导:

void Database::insert(const Record& r) {
  S.insert(r);
  ScopeGuard SF([&] { S.finalize(); });
  ScopeGuard SG([&] { S.undo(); });
  I.insert(r);
  SG.commit();
}

然而,这种方法有一个严重的缺点:类型擦除对象为了实现其“魔法”需要进行非简单的计算。至少会涉及一次间接调用或虚函数调用,通常还会伴随内存的分配与释放,这带来了运行时开销。

根据第6章中的知识,我们可以设计出一个效率稍高的类型擦除实现;特别是,可以要求“退出时调用”的可调用对象直接存放在 guard 自身的缓冲区中,避免动态内存分配:

// Example 11
template <size_t S = 16>
class ScopeGuard : public CommitFlag {
  alignas(8) char space_[S];

  using guard_t = void(*)(void*);

  guard_t guard_ = nullptr;

  template<typename Callable>
  static void invoke(void* callable) {
    (*static_cast<Callable*>(callable))();
  }

  mutable bool commit_ = false;
public:
  template <typename Callable,
            typename D = std::decay_t<Callable>>
    ScopeGuard(Callable&& callable) :
    guard_(invoke<Callable>) {
    static_assert(sizeof(Callable) <= sizeof(space_));
    ::new(static_cast<void*>(space_))
    D(std::forward<Callable>(callable));
  }

  ScopeGuard(ScopeGuard&& other) = default;
  ~ScopeGuard() { if (!commit_) guard_(space_); }
};

可以使用 Google Benchmark 库来比较类型擦除版 ScopeGuard 与模板版 ScopeGuard 的运行时开销。当然,结果会因我们所保护的操作而异:对于长时间的计算或开销较大的退出操作,ScopeGuard 本身的微小差异几乎可以忽略不计。只有当作用域内的计算和退出时的操作都非常快时,这种差异会更加明显:

void BM_nodelete(benchmark::State& state) {
  for (auto _ : state) {
    int* p = nullptr;
    ScopeGuardTypeErased::ScopeGuard SG([&] { delete p; });
    p = rand() < 0 ? new int(42) : nullptr;
  }
  state.SetItemsProcessed(state.iterations());
}

由于 rand() 返回非负数,内存实际上从未被分配,指针 p 始终为空。所以实际上是在对 rand() 调用加上 ScopeGuard 的开销进行基准测试。作为对比,可以不使用保护机制,而是直接调用 delete(虽然在此例中无实际对象可删):

Benchmark                              Time
-------------------------------------------
BM_nodelete_explicit                4.48 ns
BM_nodelete_type_erased             6.29 ns
BM_nodelete_type_erased_fast        5.48 ns
BM_nodelete_template                4.50 ns

自己实现的类型擦除版本每次迭代大约增加1纳秒,而基于std::function的版本耗时几乎要长一倍。这类基准测试受编译器优化的影响很大,即使代码有微小变动,也可能产生截然不同的结果。例如,将代码改为在每次循环迭代时都构造一个新对象:

void BM_nodelete(benchmark::State& state) {
  for (auto _ : state) {
    int* p = nullptr;
    ScopeGuardTypeErased::ScopeGuard SG([&] { delete p; });
    p = rand() >= 0 ? new int(42) : nullptr;
  }
  state.SetItemsProcessed(state.iterations());
}

现在可以在循环的每次迭代中都调用operator new,相应的删除操作也必须执行。这一次,编译器能够更好地优化模板化的ScopeGuard,优于类型擦除版本:

Benchmark                              Time
-------------------------------------------
BM_delete_explicit                  4.54 ns
BM_delete_type_erased               13.4 ns
BM_delete_type_erased_fast          12.7 ns
BM_delete_template                  4.56 ns

这里几乎没有理由使用类型擦除,其运行时开销可能可以忽略不计,也可能相当显著,但通常情况下并没有什么好处来弥补这个开销。类型擦除版本的唯一优势是,守卫对象本身的类型始终相同。但守卫对象的类型几乎总是无关紧要的:通常使用auto或构造函数模板参数推导来创建变量,可能需要对守卫对象执行的唯一显式操作就是解除它的作用。因此,我们永远不需要编写依赖于守卫对象类型的代码。

总的来说,基于模板的ScopeGuard,无论是否使用宏,仍然是在作用域结束时自动释放资源和执行其他操作的首选模式。