我们的第一个实现将在程序结束时提供内存回收,但不执行对象的终结(即不调用析构函数)。出于这个原因,如果某个类型 T 不是简单可析构的(trivially destructible),我们将不允许管理它,这种类型的对象的析构函数可能包含必须执行的用户代码,以避免资源泄漏或其他问题。
与本章中的其他示例一样,将先从测试代码开始,然后再探讨回收机制是如何实现的。测试代码将包括以下内容:
声明两个类型:NamedThing 和 Identifier。前者 不是简单可析构的,其析构函数中包含用于输出调试信息的用户代码;后者则是简单可析构的,只包含简单可析构的非静态数据成员,且没有用户提供的析构函数。
我们会提供两个 g() 函数:第一个会注释掉,其试图通过我们的回收系统分配 NamedThing 类型的对象,而这无法编译通过,因为 NamedThing 不满足“简单可析构”的要求;第二个 g() 函数会使用,其会分配满足该要求的类型对象。
f()、g() 和 main() 函数将在程序调用栈的不同层级中构造对象,但只有在程序结束时,才会统一回收那些可回收的对象。
这种情况下,使用端代码如下所示:
// ...
// 注意:非简单可析构类型
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...) 成员函数将执行以下操作:
确保类型 T 是简单可析构的(trivially destructible);
分配一块大小为 sizeof(T) 的内存;
在该内存位置构造一个 T(args...) 对象;
将指向该对象的抽象指针(void*)存储在 roots 中;
返回一个指向新创建对象的 T* 指针。
还将提供一个 gcnew
为了确保用户代码只能通过 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)的支持。