11.4. 程序结束时的完整回收

我们的第二种实现不仅会释放通过延迟回收系统所分配对象的底层存储空间,还会通过调用析构函数进行终化(finalize)。为此,需要记住每一个通过该系统处理的对象的类型。当然,实现这一目标的方法有很多,将会看到其中一种。

通过确保回收对象的终化,可以摆脱上一实现中对“简单析构(trivially destructible)”类型的限制。我们仍然不会保证对象被终化的顺序,所以希望程序健全(sound),则回收的对象在终化期间不应相互引用。但这也是许多其他流行编程语言所共有的限制。不过,本实现仍将采用单例(singleton)方式,并在程序执行结束时对对象及其底层内存进行终化和释放。

首先来看使用端代码。本例中,将使用(并从中受益)非简单析构的对象,并在终化过程中用它们输出信息:这将简化对程序执行流程的跟踪。当然,会支持简单析构的类型(如函数 h() 内部的局部结构体 X)。通常(但并非总是)非简单析构的类型是 RAII 类型(参见第 4 章),对象需要在生命周期结束前释放资源,但在这里只需要一个简单的示例。因此,只要做一些非简单的事情,比如打印某个值(正如 namedThing 中所做的),就足以展示我们能够正确处理非简单析构类型的能力。

将使用嵌套函数调用来突出对象构造与分配的局部性,以及对象析构与释放的非局部性(因为这些操作将在程序终止时发生)。示例代码如下所示:

/ ...
// 注意:非简单可析构类型
struct NamedThing {
  const char *name;
  NamedThing(const char *name) : name{ name } {
  std::print("{} ctor\n", name);
}

~NamedThing() {
  std::print("{} dtor\n", name);
  }
};

void g() {
  [[maybe_unused]] auto p = gcnew<NamedThing>("hi");
  [[maybe_unused]] auto q = gcnew<NamedThing>("there");
}

auto h() {
  struct X {
    int m() const { return 123; }
  };
  return gcnew<X>();
}

auto f() {
  g();
  return h();
}

int main() {
  std::print("Pre\n");
  std::print("{}\n", f()->m());
  std::print("Post\n");
}

执行该程序后,期望在屏幕上看到如下信息:

Pre
hi ctor
there ctor
123
Post
hi dtor
there dtor

构造函数在源码中调用时立即执行,但正如声明的那样,析构函数是在程序终止时(main() 结束之后)才会调用。

接口的重要性

用户代码在“不进行对象终化”的实现与“进行对象终化”的实现之间几乎没有变化。这里的妙处在于,这次升级(或者说改进)是在实现层面完成的,而接口保持稳定不变,所以这种差异对使用端代码是透明的。

能够在不改变接口的前提下修改实现,是低耦合设计的一个重要标志,也是软件设计中应当努力追求的一个目标。它意味着模块之间依赖更少,维护更简单,扩展更容易,同时也更符合面向对象设计中“开闭原则”的精神。

这里如何从一个不进行终化的实现,转变为进行终化的实现的呢?好吧,这个实现同样会使用一个名为 GC 的单例,在其中存储“对象根(object roots)”。在这一次的实现中,存储的是语义上更丰富的对象,而不仅仅是像上一版实现中那样的原始地址(void* 对象)。

我们将通过一组虽然古老但依然实用的技巧来实现这一目标:

以下是该实现的代码:

#include <vector>
#include <memory>
#include <print>

class GC {
  class GcRoot {
    void *p;
  public:
    auto get() const noexcept { return p; }

    GcRoot(void *p) : p{ p } {
    }

    GcRoot(const GcRoot &) = delete;
    GcRoot& operator=(const GcRoot &) = delete;

    virtual void destroy(void *) const noexcept = 0;
    virtual ~GcRoot() = default;
  };
  // ...

GC::GcRoot 是一个抽象类,处理的是原始指针(void* 类型的对象),本身并不包含与具体类型相关的信息。

类型相关的信息则保存在派生类 GcNode<T> 类型的对象中:

// ...
  template <class T> class GcNode : public GcRoot {
  void destroy(void* q) const noexcept override {
    delete static_cast<T*>(q);
  }

public:
  template <class ... Args>
  GcNode(Args &&... args) :
    GcRoot(new T(std::forward<Args>(args)...)) {
  }

  ~GcNode() {
    destroy(get());
  }
};
// ...

一个 GcNode<T> 对象可以适用于类型 T 的参数序列来构造,并将这些参数完美转发给 T 对象的构造函数。实际的(原始)指针存储在对象的基类部分(即 GcRoot)中,但 GcNode<T> 的析构函数会在这个原始指针上调用 destroy(),该函数会先将 void* 转换为适当的 T* 类型,然后再调用 operator delete() 来释放内存。

通过 GcRoot 这一抽象,GC 对象与将来需要回收对象的类型细节保持分离。这种实现可以视为一种外部多态(external polymorphism):我们使用了一个隐藏在底层的多态类层次结构来实现功能,同时让使用端代码对此毫无感知。

基于目前所写的代码,工作几乎已经完成:

这部分代码如下所示:

  // ...
  std::vector<std::unique_ptr<GcRoot>> roots;
  GC() = default;

  static auto &get() {
    static GC gc;
    return gc;
  }

  template <class T, class ... Args>
  T *add_root(Args &&... args) {
    return static_cast<T*>(roots.emplace_back(
    std::make_unique<GcNode<T>>(
      std::forward<Args>(args)...)
    )->get());
  }

  template <class T, class ... Args>
  friend T* gcnew(Args&&...);

public:
  GC(const GC &) = delete;
  GC& operator=(const GC &) = delete;
};

template <class T, class ... Args>
T *gcnew(Args &&...args) {
  return GC::get().add_root<T>(
    std::forward<Args>(args)...
  );
}
// ...

就这样,我们实现了一种在特定时刻创建对象,并在程序终止时统一销毁和回收它们,当然这种方式也有相应的优点和缺点。这些工具是有用的,但它们也是小众工具,应该只在确实有需求时才使用(并根据需要进行定制)。

到目前为止,已经看到了在程序终止时进行延迟回收(并根据工具的不同,也包括最终化)的机制。不过,仍然需要一种在选定作用域结束时进行回收的机制。