11.3. 程序结束时的无析构回收

我们的第一个实现将在程序结束时提供内存回收,但不执行对象的终结(即不调用析构函数)。出于这个原因,如果某个类型 T 不是简单可析构的(trivially destructible),我们将不允许管理它,这种类型的对象的析构函数可能包含必须执行的用户代码,以避免资源泄漏或其他问题。

与本章中的其他示例一样,将先从测试代码开始,然后再探讨回收机制是如何实现的。测试代码将包括以下内容:

这种情况下,使用端代码如下所示:

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

struct Identifier {
  int value;
};

// 无法编译
/*
void g() {
  [[maybe_unused]] auto p = gcnew<NamedThing>("hi");
  [[maybe_unused]] auto q = gcnew<NamedThing>("there");
}
*/
void g() {
  [[maybe_unused]] auto p = gcnew<Identifier>(2);
  [[maybe_unused]] auto q = gcnew<Identifier>(3);
}

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
123
Post
~GC with 3 objects to deallocate

f() 函数通过 gcnew<T>() 分配了一个对象并将其返回,而 main() 函数在没有显式使用智能指针的情况下调用了该对象的 m() 成员函数,但这个程序不会造成内存泄漏。通过 gcnew<T>() 函数分配的对象会注册到一个全局的 GC 对象中,而 GC 对象的析构函数会确保所有已注册的内存块在程序结束时统一释放。

那么,gcnew<T>()是如何工作的?为什么选择编写这个函数而非直接重载operator new()呢?关键在于:operator new()作为分配函数,仅参与内存分配过程 —— 处理的是原始内存,并不知晓要创建的对象类型。这个场景中,需要实现三个核心功能:

这要求我们必须在编译期获知对象类型信息,而这正是operator new()所不具备的功能。

为了能够在程序结束时回收这些对象所占用的内存,需要一种全局可用的存储机制,用于保存分配对象的指针。将这些指针称为 roots,并存储在一个名为 GC 的单例对象中(这个名称灵感来自垃圾回收器的常见简称,虽然我们并不是真正实现一个垃圾回收器 —— 但这个名字能很好地传达我们的意图,而且足够简短,不会造成混淆)。

GC::add_root<T>(args...) 成员函数将执行以下操作:

为了确保用户代码只能通过 gcnew<T>() 来使用该机制,将 GC::add_root<T>() 设为 private,并让 gcnew<T>() 成为 GC 类的 友元(friend)。

GC 类本身不是一个泛型类(不是模板类),暴露的是模板成员函数,但在结构上只存储原始地址(void*),这使得该类在类型上基本是不敏感的(type-agnostic)。这些设计最终将导出如下代码:

#include <vector>
#include <memory>
#include <string>
#include <print>
#include <type_traits>

class GC {
  std::vector<void*> roots;
  GC() = default;
  static auto &get() {
    static GC gc;
    return gc;
  }

  template <class T, class ... Args>
  T *add_root(Args &&... args) {
    // 不会在这里终结
    static_assert(
      std::is_trivially_destructible_v<T>
    );

    return static_cast<T*>(
      roots.emplace_back(
        new T(std::forward<Args>(args)...)
      )
    );
  }

  // 提供访问gcnew<T>()的权限
  template <class T, class ... Args>
  friend T* gcnew(Args&&...);

public:
  ~GC() {
    std::print("~GC with {} objects to deallocate",
    std::size(roots));
    for(auto p : roots) std::free(p);
  }

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

正如预期的那样,GC::~GC() 调用了 std::free(),但没有调用任何析构函数,该实现只负责内存回收(reclamation),而不执行对象的终结(finalization)。

这个示例展示了一种将,内存回收操作集中为一个统一块,在程序结束时执行的方式。在那些可用内存远大于程序需求的代码中,这种方法可以使程序执行更加流畅,尽管它会带来程序终止时略微的延迟作为代价。当然,如果想尝试这种方法,请务必进行性能测量,以确认它是否真的会对代码库带来了好处!此外,这种机制还可以编写内存分析工具,用于检查程序在整个执行过程中如何分配内存,并可以扩展以收集更多信息,例如:内存块的大小(size);内存对齐方式(alignment);只需在 roots 容器中存储 成对(pair)或元组(tuple) 类型的数据,而不是单个的 void* 指针,就可以聚合这些信息。

当然,无法对通过此机制分配的对象执行析构函数,这可能是一个严重的限制,因为非简单可析构的类型都无法从该机制中受益。接下来,我们来看看如何在设计中加入对终结(finalization)的支持。