在第 9 章中,我们展示了一些非常规内存分配机制的示例及其应用方法,包括如何通过错误处理为程序提供"二次机会"继续运行,以及如何借助 C++语言设施使用非典型或特殊内存。 随后在第 10 章中,我们着眼于速度、确定性和资源消耗控制等问题,探讨了基于区域的内存分配及其若干变体。
本章我们将实现一个在 C++中不常见但在许多其他语言(特别是具有集成垃圾收集器的语言)中普遍实践的功能: 编写能够在程序执行的特定时刻延迟销毁动态分配对象的机制。
我们不会编写完整的垃圾回收器,因为这需要深入编译器内部机制,并会影响使 C++成为卓越工具的编程模型。 不过,我们将构建延迟回收机制, 即在选定时刻集中销毁特定对象并释放其底层存储空间,但不保证销毁顺序。 当然,我们不会详尽介绍实现该目标的所有技术,但希望为亲爱的读者提供足够的"思考素材",以便在需要时构建自己的延迟回收机制。
本章所述技术可与第 10 章的内容结合使用,以提升程序运行速度并减少内存碎片,但我们将延迟回收作为独立主题进行阐述,以使讨论更加清晰。 阅读本章后,您将能够:
- 理解延迟回收的权衡取舍,虽然能获得收益但也需付出相应代价(这并非万能良药!)
- 实现一个近乎透明的外部包装器,用于追踪需要回收的内存
- 实现一个近乎透明的外部包装器,协助完成延迟回收对象的终结处理
- 实现一个类似于 std::shared_ptr 引用计数器的计数指针,用于识别在选定作用域结束时可以回收的对象
我们首先需要尝试理解延迟回收可能有帮助的一些问题领域,包括其与(不同但并非完全不相似的) 垃圾回收问题的关系
终结化?回收?
您会注意到,在本章中我们将频繁使用终结(finalization) 而非析构(destruction) 这一术语,旨在强调对象生命周期结束时执行的代码(其析构函数)与释放底层存储空间的代码之间的区别。 此外, 终结在垃圾回收语言中更为常见, 而垃圾回收技术与后续章节讨论的方法具有亲缘性。 可以将终结(不包含回收)理解为调用对象析构函数(但不释放底层存储空间) 的等效操作。
如本章前文所述,我们将把内存回收定义为在特定时刻(例如作用域结束时或程序执行终止时)释放一个或多个对象内存的行为。 需要再次说明的是,这个术语在垃圾回收语言中比在 C++中更为常见,但由于本章讨论的主题在某种程度上更接近这些语言的处理机制,因此使用相似的术语将有助于形成对相关概念和技术手段的统一理解。
20年工作经验,承接微信小程序,App,网站,网站后端开发。有意向私聊我哈。微信:akluse
什么是延迟回收机制?
为何需要采用延迟回收机制? 这确实是个好问题,感谢提问!
简而言之,它解决了一个实际问题。 确实存在这样一些程序:在客户端代码停止引用对象后立即回收对象并不合理,或者在我们确定可能使用这些对象的代码执行完毕前,根本无法明确这些对象是否该被回收。 这类程序在 C++中较为罕见,这与我们对该语言的代码思维方式有关,但放眼整个编程领域却并不少见。
例如,考虑一个函数中存在局部分配对象间的循环引用,或者存在这样一棵树:可以从根节点导航至叶节点,但树的叶节点又持有对根节点的引用。 有时我们可以确定如何销毁这组对象:以树结构为例,可以决定从根节点开始沿分支向下处理。 其他情况下,如果我们知道一组对象不会逃逸出特定函数,也可以利用这个特性——在该函数结束时,它们都能被整体回收。
如果您熟悉垃圾回收语言,可能知道在大多数这类语言中,回收器会"回收字节",释放被回收对象占用的底层存储空间(有时还会在过程中压缩内存),但不会终结对象。 这样做的一个原因是,在这类语言中,对象很难(某些情况下甚至不可能)知道程序中还有哪些其他对象仍然存在,因为没有终结顺序的保证...而且当垃圾回收器需要处理相互引用的对象循环时,怎么可能有这种保证呢? 当一个对象生命周期结束时,由于不知道哪些其他对象仍然存在,这严重限制了终结代码能够执行的操作。
在许多语言中,内存回收并不意味着终结化,这一特性简化了对象收集任务:开发者可以概念性地调用 std::free() 或等效函数来释放内存,而无需担心其中包含的对象。 在保证回收前执行终结化的语言中,通常会存在一个以单一公共基类(通常称为 object 或 Object)为根的类层次结构,这使得能够对每个对象调用等效的 virtual 析构函数并进行多态终结处理。 当然,在这种情况下终结对象时的操作会受到限制,因为对象终结的次序通常是未知的。
在现代垃圾回收语言中更常见的做法是让客户端代码负责终结处理,而将回收工作交给语言本身。 这类语言通常使用特殊接口(例如 C#中的 IDisposable 和 Java 中的 Closeable),由需要终结处理的类(通常是管理外部资源的类)实现,客户端代码会显式地建立对象有序终结所需的机制。 这将部分资源管理责任从对象本身(如 C++中惯用的 RAII 模式,详见第 4 章 )转移到了使用它的代码上,这也提醒我们:垃圾回收器虽然简化了内存管理,但同时往往会使其他资源的管理复杂化。
这种由客户端代码驱动的资源管理的例子包括一个try块和一个finally块,无论try块是正常结束还是进入了某个catch块,finally块都作为清理代码的位置。还有一些简化的语法以一种不那么繁琐的方式为客户端代码执行相同的操作。例如,Java使用try-with块,并在作用域结束时隐式地对选定的Closeable对象调用close(),而C#使用using块,同样是为了隐式地对选定的IDisposable对象调用Dispose()。
C++没有finally块,也不使用侵入性技术,如语言已知的特殊接口,这些接口会受到特殊处理,或者所有类型的公共基类。在C++中,对象通常通过RAII惯用法负责管理它们的资源;与其他流行的语言相比,这导致了不同的思维模式和不同的编程技术。
本章我们将面临与垃圾回收语言相似却又不同的情况:如果希望延迟回收对象,就无法保证在析构过程中,被回收对象能够访问同一批次回收的其他对象,因此不应尝试这样做。 另一方面,我们选择对特定对象(而非所有对象)实施延迟回收,意味着不属于该分组且已知在该分组回收后仍存活的对象,仍可在被回收对象的终结阶段被访问。 这恰恰体现了非通用解决方案的优势:C++的多样性特质可谓与生俱来——想必您在开始阅读本书之前就已深有体会。
由于所有类型没有一个共同的基类,这意味着我们要么放弃终结操作(如果我们仅限于分配可平凡析构类型的对象,这在编译时是可以验证的,这种做法是可行的),要么必须找到其他方法来记住已分配对象的类型,并在适当时机调用相应的析构函数。 本章将展示如何实现这两种方法。
与普遍认知相反,C++确实实现过一些垃圾回收器。 其中最著名的(由 Hans Boehm、Alan Demers 和 Mark Weiser 开发的 Boehm-Demers-Weiser 回收器)通常不会终结对象,但允许在用户代码中注册特定的终结器。 这是通过名为 GC_register_finalizer 的功能实现的,但作者警告用户,这种终结器的功能是受限的,正如其他支持垃圾回收的语言那样(本节前文已讨论过 )。
本章将采用其他技术。 正如本书一贯宗旨,我们的目标是提供可实践的思路,供您构建符合代码需求的解决方案。 我们将展示三个不同示例:
- 在程序执行结束时回收选定对象但不进行终结处理的代码,将延迟回收限制为可简单析构的对象
- 在程序执行结束时回收并终结选定对象的代码
- 在选定作用域结束时回收并终结选定对象的代码
我们将针对每种情况采取不同处理方式,以便您更全面地了解可实现的操作。 这三种情况下,我们都会将指针存储在全局可访问对象中。 没错,就是单例模式,但在这里这是正确的工具,因为我们讨论的是影响整个程序的功能。 准备好了吗? 现在开始!
我们有时为了让示例更易读而采取的做法…
以下章节中的代码可能会让部分读者感到陌生。 为了聚焦代码的延迟回收特性并保持整体可读性,我选择不深入讨论线程安全方面的内容,尽管这在现代代码中至关重要。 不过在本章的 GitHub 仓库中,您既能找到本书展示的代码,也能看到每个示例对应的线程安全版本。
程序结束时的回收(未完成终结)
我们的首个实现将在程序执行结束时提供回收功能但不支持终结操作。 因此,它不会接受管理某些类型 T 的对象,如果 T 不是可平凡析构的——因为这类对象的析构函数可能需要执行以避免泄漏或程序运行过程中的其他问题。
与本章其他示例一样,我们将从测试代码开始,然后逐步了解回收机制的实现方式。 我们的测试代码如下:
- 我们将声明两种类型:NamedThing 和 Identifier。 前者不会简单可销毁,因为其析构函数将包含输出调试信息的用户代码;而后者则是简单可销毁的,因为它仅包含简单可销毁的非静态数据成员,且不提供用户定义的析构函数。
- 我们将提供两个 g() 函数。 第一个函数会被注释掉,因为它试图通过我们的回收系统分配 NamedThing 对象,由于 NamedThing 类型不符合我们要求的可平凡析构特性,这段代码无法通过编译。 第二个函数会被采用,因为它分配的对象类型满足这些要求。
- 程序调用栈中,f()、g() 和 main() 函数将在不同层级构造对象。 但可回收对象只会在程序执行结束时出现。
本例中的客户端代码如下:
// ...
// note: not 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;
};
// would not compile
/*
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() 会分配并返回一个对象,main() 无需显式使用智能指针即可调用该对象的 m() 成员函数,且该程序不会发生内存泄漏。 通过 gcnew<T>() 函数分配的对象会在 GC 对象中注册,而 GC 对象的析构函数将确保已注册的内存块被释放。
那么,gcnew<T>() 是如何工作的,为何要编写这样的函数而不是直接重载 operator new()? 请记住,operator new() 作为分配函数介入整个分配过程——它处理的是原始内存,并不知晓要创建的对象类型。 在本例中,我们需要:(a)为新对象分配内存,(b)构造对象(因此需要类型信息和传递给构造函数的参数),(c)拒绝非平凡可析构的类型。 我们需要知道待构造对象的类型信息,而这正是 operator new() 所不具备的。
为了能在程序执行结束时回收这些对象的内存,我们需要一种全局可用的存储形式来存放已分配的指针。 我们将这类指针称为根指针 ,并将其存储在 GC 类型的单例中(灵感来源于垃圾收集器的常见昵称,尽管我们实现的并非严格意义上的垃圾收集——这个名称能很好地传达意图,且足够简洁不会造成妨碍)。
成员函数 GC::add_root<T>(args...) 将确保 T 是一个可平凡析构的类型,分配 sizeof(T) 字节的内存块,在该位置构造 T(args...),将一个抽象指针(void*)存储到该对象并放入 roots 中,最后返回一个指向新创建对象的 T* 指针。 gcnew<T>() 函数将允许用户代码以简化方式与 GC::add_root<T>() 交互;由于我们希望用户代码使用 gcnew<T>(),我们将把 GC::add_root<T>() 限定为 private,并使 gcnew<T>() 成为 GC 类的 friend。
请注意,GC 类本身并非泛型类(不是模板类)。 它虽然暴露了模板成员函数,但在结构上仅存储原始地址(void* 对象),这使得该类基本与类型无关。 这一切导致了以下代码:
#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) {
// there will be no finalization
static_assert(
std::is_trivially_destructible_v<T>
);
return static_cast<T*>(
roots.emplace_back(
new T(std::forward<Args>(args)...)
)
);
}
// provide access privileges to 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() 但未调用析构函数,因为此实现仅回收内存而不完成对象终结。
本示例展示了一种将内存回收操作分组为单个块并在程序结束时统一执行的方法。 在可用内存超过程序需求的代码中,这种方式可以使程序执行更加流畅,尽管代价是程序终止时会略有延迟(当然,如果您想尝试此方法,请务必测量验证是否能为您的代码库带来实际收益!)。 这种方法还能帮助我们编写分析工具来检查程序执行期间的内存分配情况,并可扩展用于整理内存块大小和对齐方式等附加信息:我们只需在 roots 容器中存储成对(或根据需求使用元组)而非单独的 void* 对象,就能聚合所需数据。
当然,无法通过此机制完成分配对象的终结处理将是一个严重限制,因为任何具有非平凡析构函数的类型都无法受益于我们的设计。 让我们看看如何为现有设计添加终结支持。
程序结束时的回收与终结
我们的第二个实现不仅会释放通过延迟回收系统分配的对象的底层存储空间,还将通过调用其析构函数来完成终结处理。 为此,我们需要记录通过系统的每个对象的类型信息。 当然,实现方式有很多种,我们将展示其中之一。
通过确保回收对象的终结化,我们可以摆脱先前实现中对对象可轻易销毁的要求。 我们仍然不会保证对象终结化的顺序,因此如果要编写可靠的程序,重要的是回收对象在终结化期间不要相互引用——但这也是许多其他流行编程语言共有的约束。 不过,这个实现将保留单例模式,并在程序执行结束时对对象及其底层存储进行终结化和释放。
如前文所述,我们将首先查看客户端代码。 在本例中,我们将使用(并受益于)非平凡可析构对象,利用它们在终止阶段输出信息:这将简化程序执行跟踪的任务。 当然,我们也会使用平凡可析构类型(例如结构体 X,它位于 h() 函数局部作用域内),因为没有理由不支持这些类型。 请注意,通常(但并非总是)非平凡可析构类型会是 RAII 类型(参见第 4 章 ),其对象需要在生命周期结束前释放资源,但这里我们只需要一个简单示例,因此执行任何非平凡操作(例如像我们对 NamedThing 所做的那样输出某些值)就足以证明我们正确处理了非平凡可析构类型。
我们将通过嵌套函数调用来突出构造和分配过程的局部性特征, 以及对象销毁和内存释放的非局部性特征——因为这些操作将在程序终止时执行。 示例代码如下:
// ...
// note: not trivially destructible
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 的单例来存储"对象根"。 不过这次我们将存储语义增强的对象,而不仅仅是像前一个实现中那样存储原始地址(void* 对象)。
我们将通过一系列古老却实用的技巧来实现这一目标:
- 我们的 GC 类不会设计成泛型类,因为那样会迫使我们必须在代码中写成 GC<T> 而非简单的 GC,并且需要为每个 T 类型创建独立的 GC<T> 对象。 我们真正需要的是让单个 GC 对象能够存储所有需要延迟回收的对象信息,而无需考虑具体类型。
- 在 GC 中,我们将存储 GC::GcRoot* 类型的对象,而非 void* 类型。 这些对象也不是泛型的,而是多态的,会暴露一个 destroy() 服务来销毁对象(调用析构函数,然后释放底层存储空间)。
- 会有一些类继承自 GC::GcRoot。 我们将这类类称为 GC::GcNode<T>,程序中每个参与延迟回收机制的类型 T 都会对应一个这样的类。 这些类就是类型特定代码被"隐藏"的地方
- 通过将 GC::GcRoot* 对象保留为根节点,但在实践中存储 GC::GcNode<T>*,我们将能够正确地释放和终结 T 对象。
此段实现的代码如下:
#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() 方法,在调用 operator delete() 之前将 void* 转换为适当的 T* 类型。.
通过 GcRoot 抽象,GC 对象与其稍后需要回收的对象类型细节保持分离。 这种实现可视为外部多态的一种形式, 其中我们使用"底层"的多态层次结构来实现功能,从而让客户端代码无感知。
根据目前已完成的内容,我们的工作已接近尾声:
- 生命周期管理可委托给智能指针,因为终结代码位于 GcNode 对象的析构函数中。 这里我们将使用 std::unique_ptr 对象(既简单又高效)。
- add_root() 函数将创建 GcNode<T> 对象,将它们作为基类 GcRoot 的指针存储在 roots 容器中,并返回指向新构建对象的 T* 指针。 这样,它在安装生命周期管理机制的同时,以对 operator new() 用户看起来自然的方式暴露指针。.
该部分的代码如下:
// ...
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)...
);
}
// ...
至此,我们已经掌握了一种方法:可以在选定位置创建对象,并在程序终止时统一销毁和回收这些对象——当然,这既有优势也存在相应的局限性。 这些工具虽然实用,但属于特定场景下的专用工具,只有当确实存在需求时,才应该(根据实际需求)使用并定制这些功能。
到目前为止,我们已经见识了在程序终止时完成回收(根据工具不同可能还包括终结化)的延迟回收机制。 我们仍需要一种能在特定作用域结束时进行回收的机制。
作用域结束时的回收与终结
本章第三个也是最后一个实现方案将确保在作用域结束时按需执行回收和终结化操作。 这意味着如果用户希望在某个作用域结束时回收那些符合延迟回收条件但未被使用的对象,该机制将提供这种可能性。 仍被视为使用中的延迟回收对象不会被回收,而未被使用的对象若用户代码未主动请求也不会被回收。 当然,在程序终止时,所有剩余的延迟回收对象都将被回收,这是为了避免内存泄漏。
此实现将比之前的更为精妙,因为我们需要考虑:(a) 在程序执行的某个时间点对象是否仍被引用,以及(b) 是否需要回收那些当时未被引用的对象。
为了实现这一目标,我们将从 std::shared_ptr 中汲取灵感,该类型我们在第 6 章中提供了其学术简化版本,并将编写一个 counting_ptr<T> 类型,该类型不会在最后一个使用者断开连接时销毁指针对象,而是将其标记为可回收状态。
本示例的客户端代码如下所示。 请注意某些作用域中 scoped_collect 类型对象的存在。 这些代表了客户端代码在该作用域结束时对不再使用的对象进行回收的请求:
// ...
// note: not trivially destructible
struct NamedThing {
const char *name;
NamedThing(const char *name) : name{ name } {
std::cout << name << " ctor" << std::endl;
}
~NamedThing() {
std::cout << name << " dtor" << std::endl;
}
};
auto g() {
auto _ = scoped_collect{};
[[maybe_unused]] auto p = gcnew<NamedThing>("hi");
auto q = gcnew<NamedThing>("there");
return q;
} // a reclamation will occur here
auto h() {
struct X {
int m() const { return 123; }
};
return gcnew<X>();
}
auto f() {
auto _ = scoped_collect{};
auto p = g();
std::cout << '\"' << p->name << '\"' << std::endl;
} // a reclamation will occur here
int main() {
using namespace std;
cout << "Pre" << endl;
f();
cout << h()->m() << endl;
cout << "Post" << endl;
} // a reclamation will occur here (end of program)
当 scoped_collect 对象所在的作用域结束时,所有通过 gcnew<T>() 分配且在该时刻不再被引用的对象都将被回收;无论这些对象是在当前作用域内分配还是在程序其他位置分配,此规则均适用。 其设计意图在于,这类作用域的结束时刻是我们愿意"付出"收集一组对象所需时间和计算成本的时机。 切勿在 scoped_collect 对象的作用域内使用该机制,若该作用域对执行速度或确定性行为有严格要求!
执行这段代码后,我们得到以下结果:
Pre
hi ctor
there ctor
hi dtor
"there"
there dtor
123
Post
可以看到,仍被引用的对象会保持可用状态,而未被引用的对象要么在 scoped_collect 对象的析构函数被调用时被回收,要么在程序终止时(如果此时程序中仍存在可回收对象) 被回收。
该 scoped_collect 类型本身非常简单,其主要作用是与 GC 全局对象进行交互。 它只是一个不可复制、不可移动的 RAII 对象,会在生命周期结束时触发回收操作:
// ...
struct scoped_collect {
scoped_collect() = default;
scoped_collect(const scoped_collect &) = delete;
scoped_collect(scoped_collect &&) = delete;
scoped_collect&
operator=(const scoped_collect &) = delete;
scoped_collect &operator=(scoped_collect &&) = delete;
~scoped_collect() {
GC::get().collect();
}
};
// ...
整个基础设施是如何运作的? 让我们一步步来看。 我们将借鉴本章前几节的内容,最初在程序执行结束时收集所有对象,然后为这些对象添加终结处理。 本节的新颖之处在于,我们将实现在程序执行过程中不同时间点收集对象的能力,并编写必要的代码来跟踪对象引用。
为了追踪对象的引用,我们将使用 counting_ptr<T> 类型的对象:
#include <vector>
#include <memory>
#include <string>
#include <iostream>
#include <atomic>
#include <functional>
#include <utility>
可以看出,我们能够(也确实做到了!)仅通过标准工具实现这种类型。 请注意 count 数据成员是一个指针,因为它可能在 counting_ptr<T> 的多个实例间共享:
template <class T>
class counting_ptr {
using count_type = std::atomic<int>;
T *p;
count_type *count;
std::function<void()> mark;
public:
template <class M>
constexpr counting_ptr(T *p, M mark) try :
p{ p }, mark{ mark } {
count = new count_type{ 1 };
} catch(...) {
delete p;
throw;
}
T& operator*() noexcept {
return *p;
}
const T& operator*() const noexcept {
return *p;
}
T* operator->() noexcept {
return p;
}
const T* operator->() const noexcept {
return p;
}
constexpr bool
operator==(const counting_ptr &other) const {
return p == other.p;
}
// operator!= can be omitted since C++20
constexpr bool
operator!=(const counting_ptr &other) const {
return !(*this == other);
}
// we allow comparing counting_ptr<T> objects
// to objects of type U* or counting_ptr<U> to
// simplify the handling of types in a class
// hierarchy
template <class U>
constexpr bool
operator==(const counting_ptr<U> &other) const {
return p == &*other;
}
template <class U>
constexpr bool
operator!=(const counting_ptr<U> &other) const {
return !(*this == other);
}
template <class U>
constexpr bool operator==(const U *q) const {
return p == q;
}
template <class U>
constexpr bool operator!=(const U *q) const {
return !(*this == q);
}
// ...
既然关系运算符已经就位,我们就可以为我们的类型实现拷贝和移动语义:
// ...
void swap(counting_ptr &other) {
using std::swap;
swap(p, other.p);
swap(count, other.count);
swap(mark, other.mark);
}
constexpr operator bool() const noexcept {
return p != nullptr;
}
counting_ptr(counting_ptr &&other) noexcept
: p{ std::exchange(other.p, nullptr) },
count{ std::exchange(other.count, nullptr) },
mark{ other.mark } {
}
counting_ptr &
operator=(counting_ptr &&other) noexcept {
counting_ptr{ std::move(other) }.swap(*this);
return *this;
}
counting_ptr(const counting_ptr &other)
: p{ other.p }, count{ other.count },
mark{ other.mark } {
if (count) ++(*count);
}
counting_ptr &operator=(const counting_ptr &other) {
counting_ptr{ other }.swap(*this);
return *this;
}
~counting_ptr() {
if (count) {
if ((*count)-- == 1) {
mark();
delete count;
}
}
}
};
namespace std {
template <class T, class M>
void swap(counting_ptr<T> &a, counting_ptr<T> &b) {
a.swap(b);
}
}
// ...
与 shared_ptr<T> 不同,counting_ptr<T> 不会同时销毁计数器和指针对象,而是仅删除计数器并"标记"指针对象,使其成为后续回收的候选对象。
上一节中提到的通用 GC、GC::GcRoot 和 GC::GcNode<T> 方法仍然适用,但进行了如下增强:
- 该 roots 容器将 unique_ptr<GcRoot> 与一个类型为 bool 的"mark"数据成员耦合
- 成员函数 make_collectable(p) 将与 p 指针关联的根节点标记为可回收
- 成员函数 collect() 会回收所有被标记为可回收的根节点
该实现的功能包括:(a) 为每个可回收指针关联一个布尔标记(收集或不收集),(b) 为每个 T* 使用 counting_ptr<T> 对象来跟踪每个指针对象的使用情况,(c) 当收集请求到达时,将可回收的指针对象作为一组进行收集。 请求此类收集的最简单方法是到达 scoped_collect 对象的析构函数。
这个相对更复杂版本的代码如下:
// ...
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;
};
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());
}
};
std::vector<
std::pair<std::unique_ptr<GcRoot>, bool>
> roots;
GC() = default;
static auto &get() {
static GC gc;
return gc;
}
该集合在此情况下的功能将如下所示:
void make_collectable(void *p) {
for (auto &[q, coll] : roots)
if (static_cast<GcRoot*>(p) == q.get())
coll = true;
}
void collect() {
for (auto p = std::begin(roots);
p != std::end(roots); ) {
if (auto &[ptr, collectible] = *p; collectible) {
ptr = nullptr;
p = roots.erase(p);
} else {
++p;
}
}
}
template <class T, class ... Args>
auto add_root(Args &&... args) {
auto q = static_cast<T*>(roots.emplace_back(
std::make_unique<GcNode<T>>(
std::forward<Args>(args)...
), false
).first->get());
// the marking function is implemented as
// a lambda expression that iterates through
// the roots, then finds and marks for
// reclamation pointer q. It is overly
// simplified (linear search) and you are
// welcome to do something better!
return counting_ptr{
q, [&,q]() {
for (auto &[p, coll] : roots)
if (static_cast<void*>(q) ==
p.get()->get()) {
coll = true;
return;
}
}
};
}
template <class T, class ... Args>
friend counting_ptr<T> gcnew(Args&&...);
friend struct scoped_collect;
public:
GC(const GC &) = delete;
GC& operator=(const GC &) = delete;
};
// ...
template <class T, class ... Args>
counting_ptr<T> gcnew(Args &&... args) {
return GC::get().add_root<T>(
std::forward<Args>(args)...
);
}
// ...
正如您所见,亲爱的读者,这最后一个示例还有多处可优化的空间,但它能够运行,并且设计初衷就是保持足够简单以便理解和改进。
我们现在知道,与其他流行语言一样,在 C++中也可以实现对象分组回收。 虽然这可能不是典型的 C++编码风格,但通过合理努力,可以选择性地实现延迟回收机制。 还不错!
概述
本章带我们进入了延迟回收的领域,这对许多 C++程序员来说是个陌生的领域。 我们探讨了如何在程序特定点成组回收对象的方法,讨论了回收这类对象时的限制条件,并研究了在释放其关联的内存存储前完成对象终结的各种技术。
现在我们已经准备好探讨内存管理与 C++容器的交互方式,这个重要主题将占据接下来三章的内容。
确实,我们可以编写显式处理内存的容器,但通常这样做会适得其反(例如,如果将 std::vector<T> 与 new 和 delete 绑定,那么 std::vector<T> 该如何处理那些需要通过其他方式进行分配和释放的类型 T 呢?)。
当然,实现这一目标的方法有很多。 想了解其中一些吗? 让我们深吸一口气, 深入探讨...
4385

被折叠的 条评论
为什么被折叠?



