C---内存管理-全-
C++ 内存管理(全)
原文:
zh.annas-archive.org/md5/6e154da88ee6ca87d6f54c168a959e60
译者:飞龙
前言
程序通常需要分配和管理内存,无论它们是用哪种编程语言编写的。然而,为什么以及如何做这取决于语言和应用领域:实时系统、嵌入式系统、游戏和传统的桌面应用程序都有不同的需求和约束,并且没有一种单一的、通用的最佳方法可以解决所有问题。
本书展示了现代 C++如何让程序员编写更简单、更安全的程序,同时也展示了该语言如何使程序员能够控制内存分配机制,并确保程序遵守它们面临的约束。从语言的基本概念——对象的生存期和内存组织开始,你将学习如何编写自己的容器和分配器,以及如何调整分配操作符的行为以满足你的需求。根据你的需求,你将能够编写出更小、更快、更可预测……并且更安全的程序。
这本书面向的对象
这本书是为那些有一定编程经验并且喜欢高级和低级编程的个人所写的。如果你有泛型编程和并发编程的先验经验,将会使阅读体验更加愉快。
更具体地说,如果你(a)认为在 C++中管理内存很困难但愿意重新审视它,(b)希望更好地控制程序管理内存的方式,或者(c)希望程序更小、更快、更安全,那么这本书是为你写的。当然,如果你来自 C++背景,你可能会从这本书中受益,但即使你通常用其他语言编程,并想看看 C++允许你做什么,这本书也会很有帮助。这本书对任何程序员都有帮助,但如果你在受限制的环境中编程(如嵌入式系统或游戏机)或在其他需要严格控制资源分配机制的应用领域编程,你可能会发现它特别有用。谁知道呢,你可能甚至会喜欢它!
这本书涵盖的内容
第一章,对象、指针和引用,讨论了 C++语言中对象模型的基本概念,为我们提供了一个共同的基本词汇。
第二章,需要注意的事项,探讨了 C++的一些棘手方面,更具体地考察了可能导致我们陷入麻烦的低级编程技巧;我们将探讨这些技巧可能导致的麻烦类型。
第三章,类型转换和 cv-限定符,考察了我们可用的工具,这些工具可以强制类型系统满足我们的需求,并讨论了如何以合理的方式使用这些有时相当锋利的工具。
第四章,使用析构函数,探讨了 C++的这一个重要方面,它使得编写负责管理资源(尤其是内存)的对象成为可能。
第五章, 使用标准智能指针,提供了如何从当代 C++编程的重要部分中受益的见解,这部分将内存管理的责任写入类型系统。
第六章, 编写智能指针,探讨了编写标准智能指针的自制版本的方法,以及我们如何设计自己的智能指针来覆盖标准库尚未覆盖的领域。
第七章, 重载内存分配操作符,展示了我们可以提供自己的内存分配操作符版本的各种方法,并解释了为什么这样做可能是个好主意。
第八章, 编写一个简单的内存泄漏检测器,将我们新的内存管理技能应用于编写一个工作(尽管简单)的工具,以检测内存泄漏,这种方式对用户代码几乎是透明的。
第九章, 非典型分配机制,探讨了标准内存分配操作符的一些不寻常的应用(和重载),包括非抛出版本和其他处理“奇异”内存的版本。
第十章, 基于区域的内存管理和其他优化,使用我们的内存管理技能使程序执行得更快,行为更确定,并从特定领域或特定应用的知识中受益。
第十一章, 延迟回收,探讨了我们可以编写程序,在程序执行过程中选择的时间点自动回收动态分配的对象。
第十二章, 使用显式内存管理编写通用容器,解释了如何编写两个高效的通用容器,这些容器可以自己管理内存,并讨论了这种做法的异常安全性和复杂性权衡。
第十三章, 使用隐式内存管理编写通用容器,回顾了上一章中编写的容器,以查看从显式内存管理方法转向依赖于智能指针的隐式方法的影响。
第十四章, 使用分配器支持编写通用容器,回顾了我们自制的容器,以查看如何通过分配器定制内存管理,涵盖了从 C++11 之前的分配器到当代分配器,以及 PMR 分配器。
第十五章, 当代问题,展望了近未来的情况,并检查了 C++中一些最近(截至本书编写时)与内存管理相关的功能,以及 C++26 和 C++29 中语言的一些有趣的候选新增功能。
附录**:您应该知道的事情,提供了一些可以帮助您充分利用本书的技术背景,但这些可能不是众所周知的知识。根据需要参考它,它在那里为您服务!
为了充分利用本书
您需要一个当代的 C++编译器,理想情况下至少支持 C++20,最好是 C++23。本书不需要其他工具,但您当然可以使用您喜欢的代码编辑器,并在您前进的过程中尝试您遇到的示例 *。
为了保持标准的 C++,从可移植和安全的视角出发,我们特别留意。您会遇到一些使用非可移植代码的示例,这些示例会被明确标识 *。
代码示例已在三个不同的编译器上进行了测试,本书 GitHub 仓库中的示例除了实际源代码外,还包含在线版本(在注释中)的链接,您可以对其进行修改和适配,以满足您的需求 *。
如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误 。
我希望您享受这种体验,并发现示例是您自己探索的有意思的起点 *。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/C-Plus-Plus-Memory-Management
。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/
找到。查看它们吧!
使用的约定
本书使用了多种文本约定。
文本中的代码
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“在这种情况下,编译器可以合法地将整个f()
函数重写为return g(*p)
,将return *p
语句转换为不可达代码。”
代码块设置如下:
int g(int);
int f(int *p) {
if(p != nullptr)
return g(*p); // Ok, we know p is not null
return *p; // oops, if p == nullptr this is UB
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
class X {
public:
// #0 delegates to #1 which delegates to #0 which...
X(float x) : X{ static_cast<int>(x) } { // #0
}
任何命令行输入或输出都应如下编写:
Verbose(0)
Verbose(2)
Verbose(6)
Verbose(7)
小贴士或重要提示
看起来像这样。
联系我们
读者反馈始终欢迎。
一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件 customercare@packtpub.com 联系我们,并在邮件主题中提及书名。
勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们将不胜感激,如果你能向我们报告这个错误。请访问www.packtpub.com/support/errata并填写表格。
盗版: 如果你在互联网上以任何形式遇到我们作品的非法副本,如果你能提供其位置地址或网站名称,我们将不胜感激。请通过版权@packt.com 与我们联系,并提供材料的链接。
如果你有兴趣成为作者:如果你在某个领域有专业知识,并且你感兴趣的是撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享你的想法
一旦你阅读了C++内存管理,我们很乐意听听你的想法!请点击此处直接进入此书的亚马逊评论页面并分享你的反馈。
你的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
你喜欢在路上阅读,但又无法携带你的印刷书籍到处走吗?
你的电子书购买是否与你的选择设备不兼容?
别担心,现在,每购买一本 Packt 书籍,你都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制和粘贴代码到你的应用程序中。
优惠远不止于此,你还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。
按照以下简单步骤获取优惠:
- 扫描下面的二维码或访问以下链接
packt.link/free-ebook/978-1-80512-980-6
-
提交你的购买证明
-
就这些!我们将直接将免费 PDF 和其他优惠发送到你的邮箱
第一部分:C++中的内存
在本部分,我们将就 C++对象模型的一些关键方面建立一个常用词汇。这包括讨论诸如什么是对象、什么是引用以及 C++如何表示内存等观点;在编写底层代码时,我们需要进行一些有风险或微妙的操作(以及由此产生的后果);以及如何以不会对我们造成伤害的方式强制类型系统满足我们的需求。本部分收集的知识将作为后续章节构建的基础。
本部分包含以下章节:
-
第一章,对象、指针和引用
-
第二章,注意事项
-
第三章,类型转换和 cv-限定符
第一章:对象、指针和引用
在我们开始讨论 C++中的内存管理之前,让我们确保我们彼此理解,并就一个共同的词汇达成一致。如果你是经验丰富的 C++程序员,你可能有自己的关于指针、对象和引用的想法。你的想法将源于丰富的经验。如果你是从其他语言转向这本书,你也可能对这些术语在 C++中的含义以及它们与内存和内存管理的关系有自己的看法。
在本章中,我们将确保我们对一些基本(但深刻)的概念有一个共同的理解,以便我们可以在接下来的冒险中共同建立在这个共享理解的基础上。具体来说,我们将探讨以下问题:
-
C++中内存是如何表示的?我们所说的内存究竟是什么,至少在 C++语言的环境中?
-
对象、指针和引用是什么?在 C++中,我们通过这些术语意味着什么?对象的生存期规则是什么?它们如何与内存相关?
-
在 C++中,数组是什么?在这种语言中,数组是一种底层但效率极高的结构,其表示方式直接影响到内存管理。
技术要求
本书假设读者对 C++或与 C、Java、C#或 JavaScript 语法相似的语言有一些基本知识。因此,我们不会解释变量声明、循环、if
语句或函数的基本知识。
然而,在本章中,我们将使用一些读者可能不太熟悉的 C++语言的某些方面。在阅读本书之前,请参阅附录**: 你应该知道的事情。
一些示例使用了 C++20 或 C++23,所以请确保你的编译器支持这个标准的版本,以便充分利用它们。
本章的代码可以在以下位置找到:github.com/PacktPublishing/C-Plus-Plus-Memory-Management/tree/main/chapter1
。
C++中内存的表示
这是一本关于内存管理的书。读者们正在试图弄清楚这意味着什么,而我作为作者,正在试图传达它的含义。
标准对内存的描述可以在[wg21.link/basic.memobj]中看到。本质上,C++中的内存被表达为一或多个连续字节的序列。这为将内存表达为一系列不连续的连续内存块的可能性打开了大门,因为从历史上看,C++支持由各种不同段组成的内存。C++程序中的每个字节都有一个唯一的地址。
C++程序中的内存被各种实体填充,如对象、函数、引用等。有效地管理内存需要掌握这些实体的含义以及程序如何利用它们。
在 C++中,单词“字节”的含义非常重要。正如[wg21.link/intro.memory]中详细说明的那样,字节是 C++中的基本存储单元。在 C++中,一个字节中的位数是实现定义的。然而,标准确实声明,一个字节必须足够宽,以包含基本字面字符集中的任何元素的普通字面编码以及 UTF-8 编码形式的八位代码单元。它还指出,一个字节是由连续位序列组成的。
令人惊讶的是,在 C++中,一个字节不一定是八位:一个字节由至少八个位组成,但可能由更多位组成(这在某些异构硬件上很有用)。这种情况可能会在未来改变,因为标准委员会可能会在某个时候限制这个定义,但这是本书出版时的状况。这里的关键思想是,字节是程序中最小的可寻址内存单元。
对象、指针和引用
我们倾向于非正式地使用诸如对象、指针和引用之类的词语,而不太考虑它们的含义。在 C++这样的语言中,这些词语有精确的含义,定义并界定了我们在实践中可以做什么。
在我们动手操作之前,让我们来考察一下这些术语在 C++中的正式含义。
对象
如果我们对使用不同语言的程序员进行民意调查,并询问他们如何定义“对象”这个术语,我们可能会得到诸如“将变量和相关函数组合在一起的东西”或“类的实例”之类的答案,这对应于从面向对象编程领域对这一术语的传统理解。
C++作为一种语言,试图为用户定义的类型(如 struct 或 class)提供同质支持。它还提供了对基本类型(如int
或float
)的支持。因此,对于 C++来说,对象的定义是以其属性来表达的,而不是以这个词语的含义来表达的,并且这个定义包括最基本的数据类型。C++中对象的定义在[wg21.link/intro.object]中描述,并考虑以下因素:
-
对象如何被显式创建,例如在定义对象或通过
operator new
的多种变体之一构造它时。对象也可能被隐式创建,例如在创建临时对象作为某些表达式的结果时,或者当改变union
的当前成员时。 -
对象存在于某个地方(它有一个地址)并且占据一个非零大小的存储区域,从其构造开始到其销毁结束。
-
对象的其他属性,包括其名称(如果有的话)、其类型和其存储持续时间(
自动
、静态
、thread_local
等)。
C++标准明确指出函数不是对象,即使函数有一个地址并占用存储空间。
从这个事实中,我们可以推断出即使是普通的int
也是一个对象,但函数不是。亲爱的读者,您已经可以看到,您正在阅读的这本书将涉及基本主题,因为对象的生命周期和占用的存储空间是我们在日常程序中使用这些实体时的基本属性的一部分。诸如生命周期和存储空间这样的东西显然是内存管理的一部分。您可以通过这个简单的程序来证实这一事实:
#include <type_traits>
int main() {
static_assert(std::is_object_v<int>);
static_assert(!std::is_object_v<decltype(main)>);
}
什么是对象?它是有生命周期并占用存储空间的东西。控制这些特性是这本书存在的原因之一。
指针
在 C++标准的文本中,有无数(大约 2,000 次)提到了“指针”这个词,但如果你打开该文档的电子版并搜索,你会发现正式的定义出奇地难以找到。考虑到人们倾向于将这个想法与 C 语言以及(通过扩展)C++语言联系起来,这一点可能会让人感到惊讶。
让我们尝试给出一个有用而又非正式的定义:指针是一个类型化的地址。它将一个类型与内存中某个位置的存储内容关联起来。因此,在如下代码中,我们可以看到n
是一个int
对象,而p
指向一个int
对象,恰好是n
对象的地址:
int n = 3; // n is an int object
char c;
// int *p = &c; // no, illegal
int *p = &n;
在这里理解这一点很重要,即p
确实指向一个int
,除非p
被未初始化,p
指向nullptr
,或者程序员在类型系统中玩弄技巧,故意让p
指向其他内容。当然,指针p
是一个对象,因为它遵守了所有相关的规则。
关于指针的许多(语法上的)困惑可能源于*
和&
符号的上下文意义。关键是要记住,当它们出现在名称的引入部分和用于现有对象时,它们有不同的角色:
int m = 4, n = 3;
int *p; // p declares (and defines) a pointer to an int
// (currently uninitialized), introducing a name
p = 0; // p is a null pointer (it does not necessarily
// point to address zero; 0 as used here is
// just a convention)
p = nullptr; // likewise, but clearer. Prefer nullptr to
// literal 0 whenever possible to describe
// a null pointer
p = &m; // p points to m (p contains the address of m)
assert(*p == 4); // p already exists; with *p we are
// accessing what p points to
p = &n; // p now points to n (p contains the address of n)
int *q = &n; // q declares (and defines) a pointer to an
// int and &n represents the address of n, the
// address of an int: q is a pointer to an int
assert(*q == 3); // n holds 3 at this stage, and q points
// to n, so what q points to has value 3
assert(*p == 3); // the same holds for p
assert(p == q); // p and q point to the same int object
*q = 4; // q already exists, so *q means "whatever q
// points to"
assert(n == 4); // indeed, n now holds value 4 since we
// modified it indirectly through q
auto qq = &q; // qq is the address of q, and its type is
// "pointer to a pointer to an int", thus
// int **... But we will rarely – if ever –
// need this
int &r = n; // declaration of r as a reference to integer n
// (see below). Note that & is used in a
// declaration in this case
正如您所看到的,在引入对象时,*
表示“指向”。在现有对象上,它表示“指针指向的内容”(即被指对象)。同样,在引入名称时,&
表示“引用”(我们很快就会讨论)。在现有对象上,它表示“地址”并产生一个指针。
指针允许我们进行算术运算,但这(合法地)被视为一种危险的操作,因为它可以带我们到程序中的任意位置,因此可能导致严重损坏。指针的算术运算取决于其类型:
int *f();
char *g();
int danger() {
auto p = f(); // p points to whatever f() returned
int *q = p + 3; // q points to where p points to plus
// three times the size of an int. No
// clue where this is, but it's a bad,
// bad idea...
auto pc = g(); // pc points to whatever g() returned
char * qc = pc + 3; // qc points to where pc points
// to plus three times the size
// of a char. Please don't make
// your pointers go to places you
// don't know about like this
}
当然,访问任意地址的内容只是自找麻烦。这是因为这意味着调用未定义的行为(在第二章中描述),如果你这样做,那么你将独自承担后果。请在实际代码中不要这样做,因为这可能会伤害程序——或者更糟,伤害人们。C++ 强大而灵活,但如果你用 C++ 编程,你被期望要负责任和专业。
C++ 有四种用于指针操作的专用类型:
-
void*
表示“没有特定(类型相关)语义的地址。”一个void*
是一个没有关联类型的地址。所有指针(如果我们不考虑const
和volatile
修饰符)都可以隐式转换为void*
;一种非正式的阅读方式是“所有指针,无论类型如何,实际上都是地址。”反之则不成立。例如,并不是所有地址都可以隐式转换为int
指针。 -
char*
表示“字节的指针。”由于 C++ 的 C 语言根源,一个char*
可以与内存中的任何地址别名(无论其名称如何,它唤起的“字符”在 C 和 C++ 中实际上意味着“字节”)。在 C++ 中,有一个持续的努力给char
赋予“字符”的意义,但截至本文写作时,char*
可以与程序中的几乎所有内容别名。这阻碍了一些编译器优化机会(很难约束或推理可能导致内存中任何内容的东西)。 -
std::byte*
是新的“字节的指针”,至少从 C++17 开始。byte*
的(长期)意图是替换那些进行字节对字节操作或寻址的函数中的char*
,但由于有大量代码使用char*
来实现这一目的,这需要时间。
以下是一个从 void*
转换到和从 void*
转换的示例:
int n = 3;
int *p = &n; // fine so far
void *pv = p; // Ok, a pointer is an address
// p = pv; // no, a void* does not necessarily point to
// an int (Ok in C, not in C++)
p = static_cast<int *>(pv); // fine, you asked for it, but
// if you're wrong you're on
// your own
以下示例,相对较为详细,使用了 const char*
(但也可以使用 const byte*
)。它表明,在某些情况下,可以比较两个对象的字节对字节表示,以查看它们是否等效:
#include <iostream>
#include <type_traits>
using namespace std;
bool same_bytes(const char *p0, const char *p1,
std::size_t n) {
for(std::size_t i = 0; i != n; ++i)
if(*(p0 + i) != *(p1 + i))
return false;
return true;
}
template <class T, class U>
bool same_bytes(const T &a, const U &b) {
using namespace std;
static_assert(sizeof a == sizeof b);
static_assert(has_unique_object_representations_v<
T
>);
static_assert(has_unique_object_representations_v<
U
>);
return same_bytes(reinterpret_cast<const char*>(&a),
reinterpret_cast<const char*>(&b),
sizeof a);
}
struct X {
int x {2}, y{3};
};
struct Y {
int x {2}, y{3};
};
#include <cassert>
int main() {
constexpr X x;
constexpr Y y;
assert(same_bytes(x, y));
}
has_unique_object_representations
特性对于唯一由其值定义的类型是真实的,也就是说,免于填充位的类型。这有时很重要,因为 C++ 没有说明对象中的填充位会发生什么,并且对两个对象进行位对位的比较可能会产生令人惊讶的结果。请注意,浮点类型的对象不被认为是唯一由其值定义的,因为有许多不同的值可以被认为是 NaN,或“不是一个数字”。
参考文献
C++ 语言支持两个相关的间接引用家族:指针和引用。与它们的表亲指针一样,引用在 C++ 标准中经常被提及(超过 1,800 次),但很难找到它们的正式定义。
我们将再次尝试提供一个非正式但实用的定义:引用可以被视为现有实体的别名。我们故意没有使用对象,因为可以引用函数,而且我们已经知道函数不是对象。
指针是对象。因此,它们占用存储空间。另一方面,引用不是对象,不使用自己的存储空间,尽管实现可以用指针来模拟它们的存在。比较std::is_object_v<int*>
与std::is_object_v<int&>
:前者为true
,后者为false
。
将sizeof
运算符应用于引用,将返回它所引用的大小。因此,取引用的地址将返回它所引用的地址。
在 C++中,引用始终绑定到对象,并且直到引用的生命周期结束都绑定到该对象。另一方面,指针在其生命周期内可以指向许多不同的对象,正如我们之前所看到的:
// int &nope; // would not compile (what would nope
// refer to?)
int n = 3;
int &r = n; // r refers to n
++r; // n becomes 4
assert(&r == &n); // taking the address of r means taking
// the address of n
指针和引用之间的另一个区别是,与指针的情况不同,没有引用算术这样的东西。这使得引用比指针更安全。程序中可以容纳这两种类型的间接引用(我们将在本书中使用它们!),但对于日常编程,一个很好的经验法则是尽可能使用引用,必要时使用指针。
现在我们已经检查了内存的表示,并查看 C++如何定义一些基本概念,如字节、对象、指针或引用,我们可以深入探讨对象的一些重要定义属性。
理解对象的基本属性
我们之前提到,在 C++中,一个对象有一个类型和一个地址。它从构造开始到销毁结束,占据一段存储空间。现在,我们将更详细地研究这些基本属性,以便了解这些属性如何影响我们编写程序的方式。
对象生命周期
C++的一个优点,但也是其相对复杂性的原因之一,来自于对对象生命周期的控制。在 C++中,一般来说,自动对象在其作用域结束时按定义好的顺序被销毁。静态(全局)对象在程序终止时按某种定义好的顺序被销毁(在给定文件中,销毁顺序是清晰的,但对于不同文件中的静态对象来说更复杂)。动态分配的对象在“你的程序说的时候”被销毁(这里有很多细微差别)。
让我们通过以下(非常)简单的程序来检查对象生命周期的某些方面:
#include <string>
#include <iostream>
#include <format>
struct X {
std::string s;
X(std::string_view s) : s{ s } {
std::cout << std::format("X::X({})\n", s);
}
~X(){
std::cout << std::format("~X::X() for {}\n", s);
}
};
X glob { "glob" };
void g() {
X xg{ «g()» };
}
int main() {
X *p0 = new X{ "p0" };
[[maybe_unused]] X *p1 = new X{ "p1" }; // will leak
X xmain{ "main()" };
g();
delete p0;
// oops, forgot delete p1
}
当程序执行时,将打印以下内容:
X::X(glob)
X::X(p0)
X::X(p1)
X::X(main())
X::X(g())
~X::X() for g()
~X::X() for p0
~X::X() for main()
~X::X() for glob
构造函数和析构函数的数量不匹配是一个迹象,表明我们做错了什么。更具体地说,在这个例子中,我们手动使用operator new
创建了一个对象(由p1
指向),但之后从未手动销毁该对象。
对于不熟悉 C++的程序员来说,指针和被指对象之间的区别是一个常见的混淆来源。在这个程序中,p0
和p1
都在到达它们的范围末尾时被销毁(由main()
函数的闭合括号),就像xmain
一样。然而,由于p0
和p1
指向动态分配的对象,被指对象必须显式地被销毁,我们为p0
做了这件事,但(为了示例的目的,故意)没有为p1
做。
那么p1
的指针对象会发生什么?嗯,它已经被手动构造,但尚未手动销毁。因此,它在内存中漂浮,没有人可以再访问它。这就是人们通常所说的内存泄露:程序分配但从未释放的内存块。
然而,比泄露由p1
指向的X
对象的存储空间更糟糕的是,被指向对象的析构函数永远不会被调用,这可能导致各种资源泄露(文件未关闭、数据库连接未关闭、系统句柄未释放等)。在第四章《使用析构函数》中,我们将探讨如何避免这种情况,并同时编写干净、简单的代码。
对象大小、对齐和填充
由于每个对象都占用存储空间,与对象关联的空间是 C++类型的一个重要(如果说是低级)属性。例如,看看以下代码:
class B; // forward declaration: there will be a class B
// at some point in the future
void f(B*); // fine, we know what B is, even if we don't
// know the details yet, and all object
// addresses are of the same size
// class D : B {}; // oops! To know what a D is, we have
// to know how big a B is and what a
// B object contains since a D is a B
在这个例子中,尝试定义D
类将无法编译。这是因为为了创建一个D
对象,编译器需要为D
对象预留足够的空间,但D
对象也是一个B
对象,因此我们不知道D
对象的大小,除非我们知道B
对象的大小。
一个对象的大小,或者说一个类型的大小,可以通过sizeof
运算符获得。这个运算符产生一个编译时非零无符号整数值,对应存储对象所需的字节数:
char c;
// a char occupies precisely one byte of storage, per
// standard wording
static_assert(sizeof c == 1); // for objects parentheses
// are not required
static_assert(sizeof(c) == 1); // ... but you can use them
static_assert(sizeof(char) == 1); // for types, parentheses
// are required
struct Tiny {};
// all C++ types occupy non-zero bytes of storage by
// definition, even if they are "empty" like type Tiny
static_assert(sizeof(Tiny) > 0);
在前面的例子中,Tiny
类是空的,因为它没有数据成员。一个类可以具有成员函数,但仍然是空的。在 C++中,暴露成员函数的空类非常常见。
C++对象总是至少占用一个字节的存储空间,即使在像Tiny
这样的空类的情况下也是如此。这是因为如果对象的大小为零,那么该对象可以与它的直接邻居位于相同的内存位置,这会很难理解。
C++与许多其他语言不同,它没有标准化所有基本类型的大小。例如,sizeof(int)
的值可能因编译器和平台而异。尽管如此,关于对象大小的规则仍然存在:
-
运算符
sizeof
报告的类型为signed char
、unsigned char
和char
的对象的大小是 1,同样sizeof(std::byte)
也是如此,因为这些类型都可以用来表示一个字节。 -
表达式
sizeof(short)>=sizeof(char)
和sizeof(int)>=sizeof(short)
在所有平台上都成立,这意味着可能存在sizeof(char)
和sizeof(int)
都为 1 的情况。在基本类型宽度的方面(即值表示中使用的位数),C++ 标准仅限于声明每种类型的最低宽度。该列表可以在 [wg21.link/tab:basic.fundamental.width] 找到。 -
正如我们之前所说的,表达式
sizeof(T)>0
对任何类型T
都成立。在 C++ 中,没有零大小的对象,即使是空类也没有。 -
任何
struct
或class
类型的对象占用的空间不能小于其数据成员的大小之和(但有一些例外)。
这最后一条规则值得解释。考虑以下情况:
class X {};
class Y {
X x;
};
int main() {
static_assert(sizeof(X) > 0);
static_assert(sizeof(Y) == sizeof(X)); // <-- here
}
标记为 <-- here
的行可能很有趣。为什么如果每个 Y
对象都包含一个 X
对象,sizeof(Y)
会等于 sizeof(X)
?记住,即使 X
是一个空类,sizeof(X)
仍然大于 0
,因为每个 C++ 对象都必须至少占用一个字节的存储空间。然而,在 Y
的情况下,它不是一个空类,每个 Y
对象由于其 x
数据成员已经占用了存储空间。没有必要为这种类型的对象人为地增加存储空间。
现在,考虑这一点:
class X {
char c;
};
class Y {
X x;
};
int main() {
static_assert(sizeof(X) == sizeof(char)); // <-- here
static_assert(sizeof(Y) == sizeof(X)); // <-- here too
}
同样的推理再次适用:类型为 X
的对象占用的存储空间与其唯一的数据成员(类型为 char
)相同,类型为 Y
的对象占用的存储空间与其唯一的数据成员(类型为 X
)相同。
继续这一探索,考虑这一点:
class X { };
class Y {
X x;
char c;
};
int main() {
static_assert(sizeof(Y) >= sizeof(char) + sizeof(X));
}
这是之前提到的规则,但以正式的方式针对特定类型进行了表达。在这种情况下,假设 sizeof(X)
等于 1
是高度可能的,甚至可以合理地预期 sizeof(Y)
将等于 sizeof(char)
和 sizeof(X)
的总和。
最后,考虑这一点:
class X { };
class Y : X { // <-- private inheritance
char c;
};
int main() {
static_assert(sizeof(Y) == sizeof(char)); // <-- here
}
我们从 X
类型的对象作为 Y
类型的数据成员,转变为 X
成为 Y
的基类。这有一个有趣的结果:由于基类 X
是空的,并且根据定义我们知道派生类 Y
的对象将至少占用一个字节的存储空间,因此可以将基类 X
融合到派生类 Y
中。这是一种有用的优化,称为 空基优化。你可以合理地预期编译器在实际中会执行这种优化,至少在单继承关系中是这样。
注意,由于X
在Y
中的存在是一个实现细节,而不是参与类Y
接口的东西,所以我们在这个例子中使用了私有继承。空基优化在公共或保护继承中同样适用,但在这个情况下,私有继承保留了Y
的X
部分是只有Y
知道的事实。
自 C++20 以来,如果你认为组合比继承更适合描述类X
和Y
等两个类之间的关系,你可以将数据成员标记为[[no_unique_address]]
,以通知编译器,如果这个成员是一个空类对象,它不需要在封装对象内占用存储空间。编译器不必强制遵守,因为属性可以被忽略,所以请确保在编写依赖于此的代码之前,验证你选择的编译器实现了这一功能:
class X { };
class Y {
char c;
[[no_unique_address]] X x;
};
int main() {
static_assert(sizeof(X) > 0);
static_assert(sizeof(Y) == sizeof(char)); // <-- here
}
到目前为止的所有示例都非常简单,使用了具有零、一个或两个非常小的数据成员的类。代码很少这么简单。考虑以下程序:
class X {
char c; // sizeof(char) == 1 by definition
short s;
int n;
};
int main() {
static_assert(sizeof(short) == 2); // we suppose this...
static_assert(sizeof(int) == 4); // ... and this
static_assert(
sizeof(X) >= sizeof(char)+sizeof(short)+sizeof(int)
);
}
假设前两个静态断言成立,这是很可能的但并非保证,我们知道sizeof(X)
至少会是7
(其数据成员大小的总和)。然而,在实践中,你可能会看到sizeof(X)
等于8
。现在,这可能会让人一开始感到惊讶,但这却是被称为对齐的某种逻辑结果。
对象的对齐(或其类型的对齐)告诉我们该对象可以在内存中的哪个位置。char
类型具有1
的对齐,因此可以将char
对象直接放置在任何地方(只要可以访问该内存)。对于2
的对齐(对于short
类型可能是这种情况),对象只能放置在地址是2
的倍数的位置。更普遍地说,如果一个类型具有n
的对齐,那么该类型的对象必须放置在地址是n
的倍数的位置。请注意,对齐必须是严格正的 2 的幂;不遵守此规则会导致未定义行为。当然,你的编译器不会让你陷入这种境地,但如果你不小心,考虑到我们将在本书中使用的某些技巧,你可能会陷入这样的麻烦。权力越大,责任越大。
C++语言提供了两个与对齐相关的运算符:
-
alignof
运算符,它返回类型T
或该类型对象的自然对齐。 -
alignas
运算符,它允许程序员强制对齐对象。这在玩弄内存(正如我们将要做的那样)或与异构硬件(这里的“异构”可以非常广泛地理解)接口时非常有用。当然,alignas
只能合理地增加类型的自然对齐,而不能减少它。
对于某些基本类型T
,可以期望断言sizeof(T)
等于alignof(T)
成立,但这个断言并不适用于复合类型。例如,考虑以下情况:
class X {
char c;
short s;
int n;
};
int main() {
static_assert(sizeof(short) == alignof(short));
static_assert(sizeof(int) == alignof(int));
static_assert(sizeof(X) == 8); // highly probable
static_assert(alignof(X) == alignof(int)); // likewise
}
一般而言,对于复合类型,对齐将对应其数据成员的最坏对齐。在这里,“最坏”意味着“最大”。对于类X
,最坏对齐的数据成员是n
类型的int
,因此,X
对象将对齐在alignof(int)
字节边界上。
你现在可能想知道,如果sizeof(short)==2
和sizeof(int)==4
,我们为什么可以期望断言sizeof(X)
等于8
成立。让我们看看X
类型对象的可能布局:
图 1.1 – 内存中类型 X 对象的紧凑布局
图中的每个方框都是内存中的一个字节。正如我们所见,c
和s
的第一个字节之间有一个?
。这来自于对齐。如果alignof(short)==2
和alignof(int)==4
,那么X
对象的唯一正确布局是将其n
成员放置在4
字节边界上。这意味着在c
和s
之间将有一个填充字节(一个不参与X
值表示的字节)来对齐s
在两个字节边界上,并将n
对齐在四个字节边界上。
可能更令人惊讶的是,在类中数据成员的布局顺序会影响该类对象的大小。例如,考虑以下情况:
class X {
short s;
int n;
char c;
};
int main() {
static_assert(sizeof(short) == alignof(short));
static_assert(sizeof(int) == alignof(int));
static_assert(alignof(X) == alignof(int));
static_assert(sizeof(X) == 12); // highly probable
}
这通常会让人们感到惊讶,但这是真的,值得思考。通过这个例子,X
对象的可能布局如下:
图 1.2 – 内存中类型 X 对象的非紧凑布局
到现在为止,s
和n
之间的两个?
“方块”可能已经很清楚,但三个尾随的?
“方块”可能看起来有些令人惊讶。毕竟,为什么在对象的末尾添加填充?
答案是由于数组。正如我们很快将要讨论的,数组的元素在内存中是连续的,因此,确保数组的每个元素都正确对齐是很重要的。在这种情况下,类X
对象尾部的填充字节确保如果X
对象数组中的某个元素被正确对齐,那么下一个元素也将被正确对齐。
现在你已经了解了对齐,考虑一下,仅仅改变类X
的一个版本到另一个版本中元素顺序,就导致该类型每个对象的内存消耗增加了 50%。这同时损害了你的程序内存空间消耗和速度。C++编译器不能为你重新排序数据成员,因为你的代码看到了对象的地址。改变数据成员的相对位置可能会破坏用户的代码,因此程序员需要小心选择他们的布局。请注意,保持对象小并不是影响对象布局选择的唯一因素,特别是在多线程代码中(有时将两个对象彼此隔开可以导致更好的缓存使用),因此应该记住布局很重要,但不是一件可以天真对待的事情。
拷贝和移动
在这一点上,我们需要对拷贝和移动这两个在像 C++这样的有实际对象的语言中的基本考虑因素说几句话。
C++语言认为六个成员函数是特殊的。除非你采取措施防止它,否则这些函数将自动为你生成。这些如下:
-
默认构造函数:可能是六个中最不特殊的一个,因为它只有在你没有编写自己的构造函数时才会隐式生成。
-
析构函数:在对象的生命周期结束时被调用。
-
拷贝构造函数:当使用与同一类型的单个对象作为参数来构造对象时被调用。
-
拷贝赋值操作:当用另一个对象的副本替换现有对象的内容时会被调用。
-
std::move()
. -
移动赋值操作:它的行为类似于拷贝赋值,但应用于当传递给赋值运算符的参数是可以移动的时候。
当一个类型没有在其自身上显式管理任何资源时,通常可以不写这些特殊函数,因为编译器生成的将正好是想要的。例如,考虑以下:
struct Point2D {
float x{}, y{};
};
这里,类型Point2D
代表一个没有不变量(x
和y
数据成员的所有值都是可接受的)的 2D 坐标。由于我们为x
和y
使用了默认初始化器,将这些数据成员设置为 0,因此默认的Point2D
对象将代表坐标(0,0)
,并且六个特殊成员函数将按预期工作。拷贝构造函数将调用数据成员的拷贝构造函数,拷贝赋值将调用它们的拷贝赋值运算符,析构函数将是平凡的,并且移动操作将像拷贝操作一样行为,因为数据成员是基本类型。
如果我们决定添加一个参数化构造函数来显式允许用户代码将x
和y
数据成员初始化为除我们选择的默认值之外的其他值,我们可以这样做。然而,这将使我们失去隐式默认构造函数:
struct Point2D {
float x{}, y{};
Point2D(float x, float y) : x{ x }, y{ y } {
}
};
void oops() {
Point2D pt; // does not compile, pt has no default ctor
}
我们当然可以解决这个问题。一种方法是通过显式编写默认构造函数的细节:
struct Point2D {
float x, y; // no need for default initializations
Point2D(float x, float y) : x{ x }, y{ y } {
}
Point2D() : x{ }, y{ } { // <-- here
}
};
void oops() {
Point2D pt; // Ok
}
另一种方法是,将默认构造函数的工作委托给参数化构造函数:
struct Point2D {
float x, y; // no need for default initializations
Point2D(float x, float y) : x{ x }, y{ y } {
}
Point2D() : Point2D{ 0, 0 } { // <-- here
}
};
void oops() {
Point2D pt; // Ok
}
另一种更好的方法是通知编译器,尽管我们做了某些事情(编写另一个构造函数),否则会阻止它,但我们仍然希望保留默认行为:
struct Point2D {
float x{}, y{};
Point2D(float x, float y) : x{ x }, y{ y } {
}
Point2D() = default; // <-- here
};
void oops() {
Point2D pt; // Ok
}
后者通常会导致生成的代码质量最好,因为当编译器理解程序员的意图时,它们在从最小的努力中获得最大结果方面非常出色。在这种情况下,=default
使得意图非常明确:请按照通常情况下如果我的代码没有 干扰 我会做的事情来做。
关于这些构造函数的说明
我们为了这个例子在Point2D
中添加了参数化构造函数,但在这个情况下并不必要,因为Point2D
是一个聚合类型。这些类型有特殊的初始化支持,但这并不是我们演示的重点。聚合类型是符合几个限制的类型(没有用户声明的或继承的构造函数,没有私有非静态数据成员,没有虚拟基类等),并且通常没有需要维护的不变量,但可以非常高效地由编译器初始化。
当一个类显式管理资源时,默认生成的特殊函数很少做我们想要的事情。实际上,编译器在这种情况下如何知道我们的意图呢?假设我们创建了一个类似string
的简单类,从以下(不完整)的摘录开始:
#include <cstring> // std::strlen()
#include <algorithm> // std::copy()
class naive_string { // too simple to be useful
char *p {}; // pointer to the elements (nullptr)
std::size_t nelems {}; // number of elements (zero)
public:
std::size_t size() const {
return nelems;
}
bool empty() const {
return size() == 0;
}
naive_string() = default; // empty string
naive_string(const char *s)
: nelems{ std::strlen(s) } {
p = new char[size() + 1]; // leaving room for a
// (convenient) trailing 0
std::copy(s, s + size(), p);
p[size()] = '\0';
}
// index-wise access to characters, const and non-const
// versions: the const version is useful for const
// naive_string objects, whereas the non-const version
// lets user code modify elements
// precondition: n < size()
char operator[](std::size_t n) const { return p[n]; }
char& operator[](std::size_t n) { return p[n]; }
// ... additional code (below) goes here
};
尽管这个类很简单,但它显然通过分配一个size()+1
字节的块来显式地分配资源,以存储从p
开始的字符序列的副本。因此,编译器提供的特殊成员函数不会为我们这个类做正确的事情。例如,默认生成的复制构造函数会复制指针p
,但这意味着我们将有两个指针(原始的p
和副本中的p
)共享一个共同的指向,这可能不是我们想要的。默认生成的析构函数会销毁指针,但我们还希望释放指向的资源,避免内存泄漏,等等。
在这种情况下,我们希望实现所谓的“三法则”,并编码析构函数以及两个复制操作(复制构造函数和复制赋值操作)。在 C++11 引入移动语义之前,这已经足够正确地实现我们类型资源管理了。从技术上讲,现在仍然是这样的,但考虑到移动语义将帮助我们以多种方式获得更有效的类型。在当代代码中,当讨论实现“三法则”以及两个移动操作(除了“三法则”)的代码时,我们通常称之为“五法则”。
析构
由于我们的naive_string
类型使用p
所指向的动态分配数组进行资源管理,因此该类的析构函数将很简单,因为它的作用仅限于释放p
所指向的内存块:
// ...
~naive_string() {
delete [] p;
}
// ...
注意,没有必要检查p
是否非空(在 C++中,delete nullptr;
什么也不做,并且本质上是无害的)。另外,请注意我们使用的是delete[]
而不是delete
,因为我们使用new[]
而不是new
来分配内存块。这些操作之间的细微差别将在第七章中解释。
复制操作
复制构造函数是在使用另一个该类的对象作为参数构造naive_string
类的对象时调用的函数。例如,考虑以下:
// ...
void f(naive_string); // pass-by-value
void copy_construction_examples() {
naive_string s0{ "What a fine day" };
naive_string s1 = s0; // constructs s1 so this is
// copy construction
naive_string s2(s0); // ...this too
naive_string s3{ s0 }; // ...and so is this
f(s0); // likewise because of pass-by-value
s1 = s0; // this is not a copy construction as s1
// already exists: this is a copy assignment
}
对于我们的naive_string
类,一个正确的复制构造函数可以写成如下:
// ...
naive_string(const naive_string &other)
: p{ new char[other.size() + 1] },
nelems{ other.size() } {
std::copy(other.p, other.p + other.size(), p);
p[size()] = '\0';
}
// ...
复制赋值可以以多种方式编写,但其中许多都是复杂的或者直接危险。例如,考虑以下示例……但不要像这样编写你的赋值运算符!:
// ...
// bad copy assignment operator
naive_string& operator=(const naive_string &other) {
// first, release the memory held by *this
delete [] p;
// then, allocate a new chunk of memory
p = new char[other.size() + 1]; // <-- note this line
// copy the contents themselves
std::copy(other.p, other.p + other.size(), p);
// adjust the size and add the trailing zero
nelems = other.size();
p[size()] = '\0';
return *this;
}
// ...
现在,这看起来可能合理(如果有点冗长),但如果我们看看执行内存分配的行,我们不禁要问:如果失败了会怎样?确实可能会。例如,如果进程正在耗尽可用内存,而other.size()
对于剩余的资源来说太大,它可能会失败。在 C++中,默认情况下,使用operator new
在失败时抛出异常。这将完成复制赋值函数的执行,使*this
处于一个不正确(并且危险!)的状态,其中p
非空且nelems
非零,但p
指向的内存大多数情况下被认为是垃圾:我们不再拥有的内存,如果使用其内容会导致未定义的行为。
我们可以声称我们可以做得更好,并编写更多的代码来尝试修复这个错误。避免以这种方式编写复制赋值运算符的建议也适用于这种情况:
// ...
// another bad copy assignment operator
naive_string& operator=(const naive_string &other) {
// first, allocate a new chunk of memory
char *q = new char[other.size() + 1];
// then release the memory held by *this and make
// p point to the new chunk
delete [] p; // <-- pay attention to this line
p = q;
// copy the contents themselves
std::copy(other.p, other.p + other.size(), p);
// adjust the size and add the trailing zero
nelems = other.size();
p[size()] = '\0';
return *this;
}
// ...
表面上看这似乎更安全,因为我们不会尝试清理*this
的现有状态,直到我们确定分配已经成功。它甚至可能通过大多数测试——直到有人构建以下测试:
void test_self_assignment() {
naive_string s0 { "This is not going to end well..." };
s0 = s0; // oops!
}
在这个用例中,我们的复制赋值操作将表现得非常糟糕。在为q
分配了适当大小的内存块之后,它将删除p
所指向的内容。不幸的是,这也正是other.p
所指向的内容,破坏了我们试图从其复制的实际源数据。接下来的步骤将从我们不再拥有的内存中读取,程序将变得没有意义。
我们仍然可以尝试修复这个问题,甚至让它工作,但请注意:
// ...
// this works, but it's getting complicated and
// is a sign we're doing something wrong
naive_string& operator=(const naive_string &other) {
// prevent self-assignment
if(this == &other) return *this;
// then, do that sequence of steps
char *q = new char[other.size() + 1];
delete [] p; // <-- pay attention to this line
p = q;
std::copy(other.p, other.p + other.size(), p);
nelems = other.size();
p[size()] = '\0';
return *this;
}
// ...
这个修复是一个 pessimization,因为我们将使每个复制赋值调用都为实际上几乎从未使用过的if
分支付费。 brute-force 问题解决方法使我们编写了复杂的代码,尽管它工作得很好(尽管它不一定显而易见),并且我们需要在编写每个资源管理类时重新考虑它。
关于词语 pessimization
词语pessimization通常用作optimization的反义词,指的是一种编程策略或技术,使得程序行为不如应有的效率。前面的例子是这种策略的一个著名例子:每个人都会为if
语句引入的潜在分支付费,即使它只为罕见和异常情况所必需——这些情况本不应该发生。
面对一个“pessimization”机会时,后退一步重新考虑通常是值得的。也许我们在处理问题时采取了错误的角度。
幸运的是,C++中有一个著名的习语,称为安全赋值习语,口语上称为复制-交换。技巧在于认识到赋值由两部分组成:一个破坏性部分,它清理目标对象拥有的现有状态(赋值的左侧),以及一个构建性部分,它从源对象复制状态到目标对象(赋值的右侧)。破坏性部分通常等同于类型析构器中的代码,而构建性部分通常等同于类型复制构造器中的代码。
这种技术的非正式复制-交换名称来源于它通常是通过类型复制构造器、析构器和swap()
成员函数的组合来实现的,该函数逐个交换成员变量:
// ...
void swap(naive_string &other) noexcept {
using std::swap; // make the standard swap function
// available
swap(p, other.p); // swap data members
swap(nelems, other.nelems);
}
// idiomatic copy assignment
naive_string& operator=(const naive_string &other) {
naive_string { other }.swap(*this); // <-- here
return *this; // yes, that's it!
}
// ...
这个习语非常实用,值得了解和使用,因为它具有异常安全性、简单性,并且适用于几乎所有的类型。执行所有工作的那一行执行了三个步骤:
-
首先,它使用该类型的复制构造器构建
other
的匿名副本。现在,如果抛出异常,这可能会失败,但如果真的发生了,*this
没有被修改,因此保持未损坏。 -
其次,它交换了那个匿名临时变量的内容(包含我们要放入
*this
中的内容)与目标对象的内容(将现在不再需要的状态放入那个匿名临时对象中)。 -
最后,在表达式的末尾销毁匿名临时对象(因为它是匿名的),留下
*this
持有other
的状态副本。
这个习语甚至可以安全地用于自我赋值。它会产生不必要的复制,但它将每个调用都会支付但几乎没有人从中受益的if
分支,与很少会无用的复制进行了交易。
你可能会注意到swap()
成员函数开头的大括号前的noexcept
。我们稍后会回到这个问题,但就目前而言,重要的观点是我们可以声称这个函数(因为它操作的是基本类型的对象)永远不会抛出异常。这个信息将帮助我们在这本书的后面部分实现一些宝贵的优化。
移动操作
我们通过其析构函数、复制构造函数和复制赋值成员函数增强的naive_string
现在适当地管理资源。然而,它可以变得更快,有时甚至更安全。
考虑以下某人可能想要添加到我们的类中以补充的非成员字符串连接运算符:
// returns the concatenation of s0 and s1
naive_string operator+(naive_string s0, naive_string s1);
这样的操作可以用在以下用户代码中:
naive_string make_message(naive_string name) {
naive_string s0{ "Hello "},
s1{ "!" };
return s0 + name + s1; // <-- note this line
}
在return
语句之后的表达式首先调用operator+()
,并从s0
和name
的连接中创建一个未命名的naive_string
对象。然后,这个未命名的对象作为另一个调用operator+()
的第一个参数传递,该调用从第一个未命名的对象和s1
的连接中创建另一个未命名的对象。根据我们当前的实施,每个未命名的对象都会产生分配、其缓冲区中数据的复制、销毁以及更多操作。这比乍一看要昂贵,而且由于每个分配都可能抛出异常,所以情况变得更糟。
尽管如此,它仍然有效。
自 C++11 以来,我们可以通过移动语义使此类代码的效率显著提高。除了我们刚才讨论的传统三法则函数之外,我们还可以通过添加移动构造函数和移动赋值运算符来增强像naive_string
这样的类。当编译器操作它知道将不再使用的对象时,这些操作会隐式地启动。考虑以下内容:
// ...
return s0 + name + s1;
// ...
这可以翻译为以下内容:
// ...
return (s0 + name) + s1;
// ^^^^^^^^^^^ <-- anonymous object (we cannot
/ refer to it afterward)
// ...
然后它翻译为以下内容:
// ...
((s0 + name) + s1);
// ^^^^^^^^^^^^^^^^^^^ <-- anonymous object (idem)
// ...
当我们仔细思考时,复制操作的原因是为了在需要时保持源对象完整。没有名称的临时对象不需要从进一步的修改中保留,因为它们以后无法被引用。因此,我们可以对这些操作更加激进,实际上是将它们的内容移动而不是复制。标准要求我们遵循的规则是,将移动后的对象留在有效但不确定的状态。本质上,移动后的对象必须处于一种可以安全销毁或赋值的状态,并且其不变性仍然保持。在实践中,这通常意味着将移动后的对象留在类似于其默认状态的东西。
对于我们的naive_string
类型,移动构造函数可能看起来像这样:
// ...
naive_string(naive_string &&other) noexcept
: p{ std::move(other.p) },
nelems{ std::move(other.nelems) } {
other.p = nullptr;
other.nelems = 0;
}
// ...
在这个特定情况下,可以避免调用 std::move()
(移动基本类型对象等同于复制它们),但可能更卫生的做法是确保在源代码中明确表达移动这些对象的意图。我们将在本节稍后简要地看看 std::move()
,但重要的是要记住,std::move()
并不移动任何东西。它只是向编译器标记一个对象是可移动的。换句话说,它 是一个类型转换。
关于我们的移动构造函数,需要注意的重要事项如下:
-
参数的类型是
naive_string&&
。这意味着它是一个对rvalue
的引用,其中rvalue
非正式地意味着“可以在赋值运算符的右侧找到的东西。” -
与
swap()
一样,它被标记为noexcept
,以表达在执行过程中不会抛出异常的事实。 -
这实际上是将状态从源对象
other
转移到正在构建的对象*this
。在完成这次转移后,我们将other
留在有效状态(相当于默认的naive_string
对象),遵循标准的建议。
可以使用一个在 <utility>
头文件中找到的名为 std::exchange()
的小巧但非常有用的函数,以稍微简略的方式编写这个函数。确实,考虑以下表达式:
a = std::exchange(b, c);
这个表达式意味着“将 b
的值赋给 a
,但将 b
的值替换为 c
的值。”这在实际代码中是一个非常常见的操作序列。使用这个函数,我们的移动构造函数变成了以下形式:
// ...
naive_string(naive_string &&other) noexcept
: p{ std::exchange(other.p, nullptr) },
nelems{ std::exchange(other.nelems, 0) } {
}
// ...
这种形式是典型的 C++ 代码,在某些情况下可能导致一些有趣的优化。
那么,移动赋值又如何呢?嗯,我们可以参考我们之前详细讨论过的典型复制赋值,并如下表达:
// idiomatic copy assignment
naive_string& operator=(naive_string &&other) noexcept {
naive_string { std::move(other) }.swap(*this);
return *this;
}
沿着我们的复制赋值运算符设定的路径,我们将移动赋值运算符表达为 swap()
、析构函数和移动构造函数的组合。这两个惯用语的背后逻辑是相同的。
数组
我们在前面的例子中使用了数组,但并没有真正提供一个正式的定义来描述这个有用但低级的结构。注意,在本节中,“数组”一词指的是原始的内置数组,而不是其他非常有用但更高级的结构,如 std::vector<T>
或 std::array<T,N>
。
简单来说,在 C++ 中,数组是相同类型元素的连续序列。因此,在以下摘录中,a0
对象在内存中占用 10*sizeof(int)
字节,而 a1
对象占用 20*sizeof(std::string)
字节:
int a0[10];
std::string a1[20];
在某个类型 T
的数组中,索引 i
和 i+1
之间的字节数恰好等于 sizeof(T)
。
考虑以下表达式,这在 C++ 中,就像在 C 中一样,用于某个数组 arr
:
arr[i]
它计算出的地址与以下相同:
*(arr + i)
由于指针运算是有类型的,这个表达式中的+ i
部分意味着“加上i
个元素”或者“加上i
个元素的字节大小”。
数组的大小必须是正数,除非数组是动态分配的:
int a0[5]; // Ok
static_assert(sizeof a0 == 5 * sizeof(int));
enum { N = sizeof a0 / sizeof a0[0] }; // N == 5
// int a1[0]; // not allowed: the array would be at the
// same address as the next object in memory!
int *p0 = new int[5]; // Ok, but you have to manage the
// pointee now
int *p1 = new int[0]; // Ok, dynamically allocated; you
// still have to manage the pointee
// ...
delete [] p1; // good
delete [] p0; // good; be responsible
每次调用operator new[]
都必须产生不同的地址,即使数组的大小是 0。每次调用在技术上都会返回不同对象的地址。
摘要
在本章中,我们探讨了 C++语言的基本概念,例如:什么是对象?指针和引用是什么?当我们谈论对象或类型的尺寸和对齐时,我们指的是什么?为什么 C++中没有零大小对象?类的特殊成员是什么,我们何时需要显式地编写它们?这个非详尽的主题列表为我们提供了一个共同词汇表,从其中我们可以构建你,亲爱的读者,将在接下来的章节中找到的内容。
有了这个,我们就可以动手实践了。我们已经给自己提供了一套低级工具和思想,用于构建高级抽象,但我们必须给自己一些自律。
下一章将讨论我们需要避免的一些事情。这包括未定义的行为、实现定义的行为(程度较小)、不规范的代码(不需要诊断),缓冲区溢出以及其他不推荐的行为。
然后,我们将接着介绍一个章节,描述 C++类型转换,以及它们如何帮助我们表达清晰的想法,即使在我们觉得需要规避语言类型系统为我们设定的某些规则时。
之后,我们将开始构建美丽而强大的抽象,这将帮助我们实现我们的目标,即安全有效地管理资源,特别是管理内存。
第二章:需要小心的事情
因此,你决定阅读一本关于 C++内存管理的书,你愿意查看高级方法和技巧,就像你愿意“动手”一样,以便对内存管理过程有精细的控制。多么出色的计划!
由于你知道你将编写非常高级的代码,但也会编写非常底层的代码,有一些事情我们需要确保你意识到,这样你就不会陷入麻烦或编写看似工作但实际上并不工作(至少不是可移植的)的代码。
在本章中,我们将指出一些在本书中将发挥作用但你应该小心处理的 C++编程方面。这看起来可能像(非常)小的不良实践汇编或鼓励你陷入麻烦,但请将以下内容视为使用某些危险或棘手特性的好方法。你使用 C++,你有很大的表达自由,并且如果你了解并理解它们,你可以访问一些有用的特性。
我们希望代码干净高效,我们希望有责任感的程序员。让我们共同努力实现这个目标。
在本章中,我们将学习以下内容:
-
我们将涵盖一些可能导致麻烦的 C++代码的方式。确实,有些事情编译器无法可靠地诊断,就像有些事情 C++标准没有说明会发生什么一样,编写执行这些事情的代码是灾难的配方——至少是令人惊讶或不可移植的行为。
-
尤其是我们将探讨一个人如何因为指针而陷入麻烦。由于这本书讨论了内存管理,我们将经常使用指针和指针运算,能够区分适当的用法和不适当的用法将非常有价值。
-
最后,我们将讨论我们可以不使用类型转换(第三章的主要主题*)进行哪些类型转换,以及这与普遍看法相反,这种情况很少是好的主意。
我们的整体目标将是学习我们不应该做的事情(尽管有时我们也会做一些类似的操作),并在之后避免它们,希望理解我们这样做的原因。解决了这个问题之后,我们将有大量的章节来探讨我们应该做的事情,以及如何做好它们!
不同的邪恶类型
在深入研究需要谨慎处理的一些实际实践之前,看看如果我们的代码不遵守语言规则,我们可能会遇到的主要风险类别是很有趣的。每个这样的类别都伴随着一种我们应该努力避免的不愉快。
形式不当,无需诊断
C++中的一些结构被称为不合法,无需诊断(IFNDR)。确实,你会在标准中找到许多类似“如果[...], 程序是不合法的,无需诊断。”的表述。当某物是 IFNDR 时,意味着你的程序是有问题的。可能会发生一些不好的事情,但编译器不需要告诉你(实际上,有时编译器没有足够的信息来诊断问题情况)。
alignas
)在不同的翻译单元(基本上是不同的源文件)中,或者有一个构造函数直接或间接地委托给自己。以下是一个示例:
class X {
public:
// #0 delegates to #1 which delegates to #0 which...
X(float x) : X{ static_cast<int>(x) } { // #0
}
X(int n) : X{ n + 0.5f } { // #1
}
};
int main() {}
注意,你的编译器可能会给出诊断信息;但这不是强制要求的。并不是编译器懒惰——在某些情况下,它们甚至可能无法提供诊断信息!因此,要小心不要编写导致 IFNDR(无需诊断)情况的代码。
不确定行为
我们在第一章中提到了不确定行为(UB)。UB 通常被视为 C++程序员头痛和痛苦的原因,但它指的是 C++标准没有要求的任何行为。在实践中,这意味着如果你编写的代码包含 UB,你不知道运行时会发生什么(至少如果你希望代码具有一定的可移植性)。UB 的典型例子包括解引用空指针或未初始化的指针:这样做会让你陷入严重的麻烦。
对于编译器来说,UB 不应该发生(毕竟,尊重语言规则的代码不包含 UB)。因此,编译器会“围绕”包含 UB 的代码进行优化,有时会产生令人惊讶的效果:它们可能会开始移除测试和分支、优化循环等。
UB 的影响往往局限于局部。例如,在以下示例中,有一个测试确保在使用*p
之前p
不是空指针,但至少有一个对*p
的访问是没有检查的。这段代码是有问题的(未检查的*p
访问是 UB),因此编译器允许以这种方式重写它,从而有效地移除所有验证p
不是空指针的测试。毕竟,如果p
是nullptr
,那么损害已经造成,因此编译器有权利假设程序员传递了一个非空指针给函数!
int g(int);
int f(int *p) {
if(p != nullptr)
return g(*p); // Ok, we know p is not null
return *p; // oops, if p == nullptr this is UB
}
在这种情况下,编译器可以合法地将整个f()
函数体重写为return g(*p)
,将return *p
语句转换为不可达代码。
语言中存在潜在的不确定行为(UB)的多个地方,包括有符号整数溢出、访问数组越界、数据竞争等。目前有持续的努力在减少潜在 UB 案例的数量(甚至有一个专门致力于此的SG12研究小组),但 UB 可能在未来一段时间内仍然是语言的一部分,我们需要对此有所警觉。
实现定义的行为
标准中的一些部分属于实现定义的行为范畴,或者说是你可以依赖特定平台的行为。这种行为是你选择的平台应该记录的,但并不保证可以移植到其他平台。
实现定义的行为出现在许多情况下,包括如下事物:实现定义的限制,例如最大嵌套括号数;switch 语句中的最大 case 标签数;对象的实际大小;constexpr
函数中的最大递归调用数;字节中的位数;等等。其他已知的实现定义行为案例包括int
对象中的字节数或char
类型是有符号还是无符号整型。
实现定义的行为本身并不是邪恶的源头,但如果追求可移植代码但依赖于一些不可移植的假设,则可能会出现问题。有时,当假设可以在编译时或类似的潜在运行时机制中验证时,通过static_assert
在代码中表达这些假设是有用的,以便在为时已晚之前意识到这些假设对于特定目标平台是错误的。
例如:
int main() {
// our code supposes int is four bytes wide, a non-
// portable assumption
static_assert(sizeof(int)==4);
// only compiles if condition is true...
}
除非你确信你的代码永远不会需要移植到另一个平台,否则应尽可能少地依赖实现定义的行为,并且如果确实需要,确保通过static_assert
(如果可能的话)或运行时(如果没有其他选择)验证并记录这种情况。这可能会帮助你避免未来的一些令人不快的惊喜。
未指定行为(未记录)
当实现定义的行为在特定平台上不可移植但有文档记录时,未指定行为是指即使对于给定正确数据的良好格式程序,其行为也依赖于实现但不需要记录的行为。
一些未指定行为的案例包括已移动对象的状体(例如,f(g(),h())
将首先评估g()
或h()
,新分配内存块中的值等)。这个后者的例子对我们研究很有趣;调试构建可能会用可识别的位模式填充新分配的内存块以帮助调试过程,而使用相同工具集的优化构建可能会留下新分配内存块初始位的“未初始化”,保留分配时的位,以获得速度提升。
ODR
ODR(One Definition Rule,单一定义规则)简单来说,就是在一个翻译单元中,每个“事物”(函数、作用域中的对象、枚举、模板等)只能有一个定义,尽管可以有多个声明。
int f(int); // declaration
int f(int n); // Ok, declaration again
int f(int m) { return m; } // Ok, definition
// int f(int) { return 3; } // not Ok (ODR violation)
在 C++ 中,避免 ODR 违反很重要,因为这些“邪恶”可以逃过编译器的审查,落入 IFNDR 情境。例如,由于源文件的独立编译,包含非 inline
函数定义的头文件会导致该定义在每个包含该头文件的源文件中重复。然后,每次编译可能都会成功,而同一构建中该函数存在多个定义的事实可能在稍后(在链接时)被发现,或者根本未被检测到,从而造成混乱。
错误行为
C++ 中持续进行的与安全相关的工作导致了对一种新类型的“邪恶”的讨论,这种类型暂时被命名为 错误行为。这个新类别旨在涵盖过去可能被视为未定义行为(UB)的情况,但对于这些情况,我们可以提供诊断并定义良好的行为。这种行为仍然是不正确的,但错误行为在某种程度上为后果提供了边界。请注意,截至本文撰写时,错误行为的这项工作仍在进行中,这个新的措辞功能可能针对 C++26。
错误行为的预期用例之一是从未初始化的变量中读取,实现(出于安全原因)可以为读取的位提供固定值,从读取该变量产生的概念性错误是实施者鼓励诊断的东西。另一个用例是忘记从非 void 赋值运算符返回值。
现在我们已经探讨了如果不行为可能会影响我们程序的许多“不愉快”的“家族”,让我们深入研究一些可能会让我们陷入麻烦的主要设施,并看看我们应该避免做什么。
指针
第一章 讨论了 C++ 中指针的概念及其所代表的意义。它描述了指针算术是什么,以及它允许我们做什么。现在,我们将探讨指针算术的实际应用,包括这个低级(但有时宝贵)工具的恰当和不恰当使用。
在数组中使用指针算术
指针算术是一个既好又实用的工具,但它是一把锋利的工具,往往被误用。对于原始数组,以下两个标记为 A
和 B
的循环的行为完全相同:
void f(int);
int main() {
int vals[]{ 2,3,5,7,11 };
enum { N = sizeof vals / sizeof vals[0] };
for(int i = 0; i != N; ++i) // A
f(vals[i]);
for(int *p = vals; p != vals + N; ++p) // B
f(*p);
}
你可能会对循环 B
中的 vals + N
部分感到好奇,但它是有效的(并且是惯用的)C++ 代码。你可以观察到数组末尾之后的指针,尽管你不允许观察它指向的内容;标准保证这个特定的一个超出末尾的地址对你的程序是可访问的。然而,对于下一个地址,没有这样的保证,所以请小心!
只要你遵守规则,你就可以使用指针在数组内部跳来跳去。如果你超出了范围,并使用指针超出数组末尾一个位置,你将进入 UB 区域;也就是说,你可能会尝试访问不在你的进程地址空间中的地址:
int arr[10]{ }; // all elements initialized to zero
int *p = &arr[3];
p += 4; assert(p == &arr[7]);
--p; assert(p == &arr[6]);
p += 4; // still Ok as long as you don't try to access *p
++p; // UB, not guaranteed to be valid
指针可转换性
C++标准定义了对象如何进行reinterpret_cast
(我们将在第三章中详细说明),因为它们具有相同的地址。广义上,以下几点是正确的:
-
一个对象与其自身是可指针转换的
-
一个
union
与其数据成员是可指针转换的,如果它们是复合类型,则还包括其第一个数据成员 -
在某些限制下,如果
x
是一个对象而y
是那个对象的第一个非静态数据成员的类型,那么x
和y
是可指针转换的
这里包含了一些示例:
struct X { int n; };
struct Y : X {};
union U { X x; short s; };
int main() {
X x;
Y y;
U u;
// x is pointer-interconvertible with x
// u is pointer-interconvertible with u.x
// u is pointer-interconvertible with u.s
// y is pointer-interconvertible with y.x
}
如果你尝试以不尊重指针可转换性规则的方式应用reinterpret_cast
,你的代码在技术上是不正确的,并且在实践中不一定能保证工作。不要这样做。
我们将在代码示例中偶尔使用指针可转换性属性,包括在下一节中。
在对象内使用指针算术的应用
在 C++中,对象内的指针算术也是允许的,尽管人们应该小心处理这一点(使用适当的类型转换,我们将在第三章中探讨,并确保适当地执行指针算术)。
例如,以下代码是正确的,尽管这不是人们应该追求的事情(这没有意义,它以不必要的复杂方式做事,但它是合法的,并且不会造成伤害):
struct A {
int a;
short s;
};
short * f(A &a) {
// pointer interconvertibility in action!
int *p = reinterpret_cast<int*>(&a);
p++;
return reinterpret_cast<short*>(p); // Ok, within the
// same object
}
int main() {
A a;
short *p = f(a);
*p = 3; // fine, technically
}
我们不会在本书中滥用 C++语言的这一方面,但我们需要意识到它,以便编写正确、低级别的代码。
关于指针和地址的区别
为了加强硬件和软件安全,人们已经在可以提供“指针标记”形式的硬件架构上进行了工作,这允许硬件跟踪指针来源,以及其他方面。两个著名的例子是 CHERI 架构(packt.link/cJeLo
)和内存标记扩展(MTEs)(Linux: packt.link/KXeRn
| Android: packt.link/JDfEo
, 和 packt.link/fQM2T
| Windows: packt.link/DgSaH
))。
为了利用这样的硬件,语言需要区分地址的低级概念和指针的高级概念,因为后者需要考虑到指针不仅仅是内存位置。如果你的代码绝对需要比较无关的指针以确定顺序,你可以做的一件事是将指针转换为std::intptr_t
或std::uintptr_t
,然后比较(数值)结果而不是比较实际的指针。请注意,编译器对这两种类型的支持是可选的,尽管所有主要的编译器供应商都提供了它。
空指针
空指针作为指向无效位置的指针的可识别值的想法可以追溯到 C.A.R. Hoare (packt.link/ByfeX
)。在 C 语言中,通过NULL
宏,它最初被表示为一个值为0
的char*
,然后是一个值为0
的void*
,然后在 C++中,由于像int *p = NULL;
这样的带有类型NULL
的语句在 C 中是合法的,但在 C++中不是,所以它简单地表示值为0
。这是因为 C++的类型系统更加严格。请注意,值为0
的指针并不意味着“指向地址零”,因为这个地址本身是完全有效的,并且在许多平台上被这样使用。
在 C++中,表达空指针的首选方式是nullptr
,这是一个std::nullptr_t
类型的对象,它可以转换为任何类型的指针,并按预期行为。这解决了 C++中一些长期存在的问题,如下所示:
int f(int); //#0
int f(char*); // #1
int main() {
int n = 3;
char c;
f(n); // calls #0
f(&c); // calls #1
f(0); // ambiguous before C++11, calls #0 since
f(nullptr); // only since C++11; unambiguously calls #1
}
注意,nullptr
不是一个指针;它是一个可以隐式转换为指针的对象。因此,std::is_pointer_v<nullptr>
特性是假的,C++提供了一个名为std::is_null_pointer<T>
的独立特性,用于静态测试T
是否是std::nullptr_t
(考虑const
和volatile
)。
解引用空指针是未定义的行为,就像解引用未初始化的指针一样。在代码中使用nullptr
的目的就是为了使这种状态可识别:nullptr
是一个可区分的值,而未初始化的指针可能什么都是。
在 C++中(与 C 不同),对空指针进行算术运算是有明确定义的……只要你在空指针上加上零。或者,换一种说法:如果你在空指针上加上零,代码仍然是有定义的,但如果你加上任何其他东西,那就得你自己负责了。在 wg21.link/c++draft/expr.add#4.1 中有一个明确的规定。这意味着以下情况是正确的,就像空数组
的情况一样,begin()
返回nullptr
,size()
返回零,所以end()
实际上计算的是nullptr+0
,这符合规则:
template <class T> class Array {
T *elems = nullptr; // pointer to the beginning
std::size_t nelems = 0; // number of elements
public:
Array() = default; // =empty array
// ...
auto size() const noexcept { return nelems; }
// note: could return nullptr
auto begin() noexcept { return elems; }
auto end() noexcept { return begin() + size(); }
};
我们将在第十二章、第十三章和第十四章中更详细地回到这个数组
示例;这将帮助我们讨论高效内存管理技术的一些重要方面。现在,让我们看看另一个危险的编程操作来源。
类型转换
C++程序员可能陷入麻烦的另一个领域是类型欺骗。通过类型欺骗,我们指的是在一定程度上颠覆语言类型系统的技术。执行类型转换的圣洁工具是类型转换,因为它们在源代码文本中是显式的,并且(除 C 风格类型转换外)表达了转换的意图,但这个主题值得单独成章(第三章,如果你想知道的话)。
在本节中,我们将探讨其他实现类型欺骗的方法,包括可推荐的方法和应避免的方法。
通过联合成员进行类型欺骗
联合是一种成员都位于同一地址的类型。联合的大小是其最大成员的大小,联合的对齐是其成员的最严格对齐。
考虑以下示例:
struct X {
char c[5]; short s;
} x;
// one byte of padding between x.c and x.s
static_assert(sizeof x.s == 2 && sizeof x == 8);
static_assert(alignof(x) == alignof(short));
union U {
int n; X x;
} u;
static_assert(sizeof u == sizeof u.x);
static_assert(alignof(u) == alignof(u.n));
int main() {}
很容易想到,可以使用union
隐式地将诸如四字节的浮点数转换为四字节的整数,在 C 语言(而不是 C++)中,这确实是可能的。
尽管广泛认为这种做法在 C++中是合法的,但实际情况并非如此(有一个特殊的注意事项,我们将在稍后探讨)。实际上,在 C++中,已写入的联合的最后一个成员被称为联合的constexpr
函数:
union U {
float f;
int n;
};
constexpr int f() {
U u{ 1.5f };
return u.n; // UB (u.f is the active member)
}
int main() {
// constexpr auto r0 = f(); // would not compile
auto r1 = f(); // compiles, as not a constexpr
// context, but still UB
}
如你所知,在先前的示例中,像f()
这样的constexpr
函数不能包含在constexpr
上下文中调用时会导致未定义行为的代码。这有时使其成为一个有趣的表达观点的工具。
在union
成员之间的转换方面存在一个注意事项,这个注意事项与公共初始序列有关。
公共初始序列
如在 wg21.link/class.mem.general#23 中解释的那样,A
和B
由它们的前两个成员组成(int
与const int
布局兼容,float
与volatile float
布局兼容):
struct A { int n; float f; char c; };
struct B{ const int b0; volatile float x; };
如果读取的值是成员的公共初始序列和活动成员的一部分,则可以使用union
从非活动成员中读取。以下是一个示例:
struct A { int n0; char c0; };
struct B { int n1; char c1; float x; };
union U {
A a;
B b;
};
int f() {
U u{ { 1, '2' } }; // initializes u.a
return u.b.n1; // not UB
}
int main() {
return f(); // Ok
}
注意,这种类型欺骗应尽量减少,因为它可能会使推理源代码变得更加困难,但它非常有用。例如,它可以用来实现一些有趣的底层表示,这些表示对于可以有两个不同表示的类(例如optional
或string
)来说是有用的,这使得从一个切换到另一个变得更加容易。可以基于此构建一些有用的优化。
intptr_t 和 uintptr_t 类型
如本章前面所述,在 C++中,无法以定义良好的方式直接比较指向内存中任意位置的指针。然而,可以以定义良好的方式比较与指针相关联的整数值,如下所示:
#include <iostream>
#include <cstdint>
int main() {
using namespace std;
int m,
n;
// simply comparing &m with &n is not allowed
if(reinterpret_cast<intptr_t>(&m) <
reinterpret_cast<intptr_t>(&n))
cout << "m precedes n in address order\n";
else
cout << "n precedes m in address order\n";
}
std::intptr_t
和 std::uintptr_t
类型是足够大的整数类型的别名,可以容纳地址。对于可能导致负值操作(例如,减法)的情况,请使用有符号类型 intptr_t
。
std::memcpy()
函数
由于历史(和与 C 的兼容性)原因,std::memcpy()
是特殊的,因为它如果使用得当可以启动对象的生命周期。对 std::memcpy()
的错误使用进行类型转换可能如下所示:
// suppose this holds for this example
static_assert(sizeof(int) == sizeof(float));
#include <cassert>
#include <cstdlib>
#include <cstring>
int main() {
float f = 1.5f;
void *p = malloc(sizeof f);
assert(p);
int *q = std::memcpy(p, &f, sizeof f);
int value = *q; // UB
//
}
这之所以非法,是因为对 std::memcpy()
的调用将一个 float
对象复制到由 p
指向的存储中,实际上是在那个存储中启动了一个 float
对象的生命周期。由于 q
是一个 int*
,解引用它是未定义行为(UB)。
另一方面,以下操作是合法的,展示了如何使用 std::memcpy()
进行类型转换:
// suppose this holds for this example
static_assert(sizeof(int) == sizeof(float));
#include <cassert>
#include <cstring>
int main() {
float f = 1.5f;
int value;
std::memcpy(&value, &f, sizeof f); // Ok
// ...
}
的确,在这个第二个例子中,使用 std::memcpy()
从 f
复制位到 value
启动了 value
的生命周期。从那时起,该对象可以像任何其他 int
一样使用。
char*
、unsigned char*
和 std::byte*
的特殊情况
char*
、unsigned char*
(不是 signed char*
)和 std::byte*
类型在 C++ 中具有特殊地位,因为它们可以指向任何地方并代表任何类型(wg21.link/basic.lval#11)。因此,如果您需要访问对象的值表示形式下的底层字节,这些类型是您工具箱中的重要工具。
在本书的后续内容中,我们偶尔会使用这些类型来执行低级字节操作。请注意,此类操作本质上是脆弱且不可移植的,因为整数中字节的顺序可能会因平台而异。请谨慎使用此类低级设施。
std::start_lifetime_as<T>()
函数
本章最后介绍的一组设施是 std::start_lifetime_as<T>()
和 std::start_lifetime_as_array<T>()
。这些函数讨论了多年,但直到 C++23 才真正发挥其作用。它们的作用是将原始内存字节数组作为参数,并返回一个指向 T
的指针(指向该缓冲区),其生命周期已开始,从而可以从该点开始将指针所指的内容用作 T
类型:
static_assert(sizeof(short) == 2);
#include <memory>
int main() {
char buf[]{ 0x00, 0x01, 0x02, 0x03 };
short* p = std::start_lifetime_as<short>(buf);
// use *p as a short
}
这同样是一个需要谨慎使用的低级特性。这里的意图是能够用纯 C++ 实现诸如低级文件 I/O 和网络代码(例如,接收 UDP 数据包并将其值表示形式视为现有对象)等,而不会陷入未定义行为的陷阱。我们将在第十五章中更详细地讨论这些函数。
摘要
本章探讨了我们将有时使用的一些低级和有时令人不快的设施,目的是设置适当的“警告标志”,并提醒我们必须负责任地编写合理且正确的代码,尽管我们选择的语言提供了很大的自由度。
当在本书的后续章节中编写高级内存管理功能时,这些危险的设施有时对我们是有用的。受到本章关于需要注意的事项的内容的启发,我们将谨慎、小心地使用这些设施,并使其难以被误用。
在我们接下来的章节中,我们将探讨置于我们手中的关键 C++类型转换;目的是让我们了解每种转换的作用,以及何时(以及为了什么目的)应该使用它,这样我们就可以构建我们想要使用的强大内存管理抽象。
第三章:类型转换和 cv-qualifications
我们正在进步。在第一章中,我们探讨了内存、对象和指针是什么,因为我们知道如果我们想要掌握内存管理机制,我们就需要理解这些基本概念。然后在第二章中,我们查看了一些低级构造,如果误用可能会给我们带来麻烦,但在某些情况下理解这些构造对于掌握程序如何管理内存是至关重要的。这是一个相对枯燥的开端,但也意味着我们工作的有趣部分还在后面。我希望这能给你带来鼓舞!
在第二章的结尾,我们探讨了类型欺骗的方法,这是一种绕过类型系统的方法,包括一些被认为可以工作但实际上并不奏效的方法。C++提供了一些受控和明确的方式来与类型系统交互,通知编译器它应该将表达式的类型视为与从源代码中推断出的不同。这些工具,即类型转换(或简称转换),是本章的主题。
我们首先将探讨在一般意义上什么是类型转换,区分进行类型转换的各种基本原因,并说明为什么在 C++程序中 C 风格类型转换通常是不合适的(除了某些特定情况)。然后,我们将快速查看 C++系统的一个与安全相关的方面,即cv-qualifications,并讨论 cv 限定符在 C++代码的卫生性和整体质量中的作用。之后,我们将检查我们可用的六个 C++类型转换。最后,我们将回到 C 类型转换,以展示它们在何种有限情况下可能仍然适用。
在本章中,我们将学习以下内容:
-
类型转换是什么以及它们在程序中的含义
-
cv-qualifications 是什么以及它们如何与类型转换交互
-
C++类型转换是什么,包括 C 类型转换,以及何时应该使用它们
技术要求
你可以在本书的 GitHub 仓库中找到本章的代码文件:github.com/PacktPublishing/C-Plus-Plus-Memory-Management/tree/main/chapter3
。
什么是类型转换?
你将使用类型转换来调整编译器对表达式类型的看法。问题是,编译器看到我们的源代码,理解我们写了什么,以及别人的代码表达了什么。大多数时候(希望如此),这段代码是有意义的,编译器将把你的源代码转换为适当的二进制文件而不会抱怨。
当然,有时程序员意图与代码之间会有(希望是暂时的)差异,这种差异通过编译器看到的源代码表达出来。大多数时候,编译器是正确的,程序员会重写源代码,至少部分地,以便更好地表达意图,受到揭示问题的错误或警告信息的启发(以它们自己诗意的方式)。当然,有时源代码与程序员的意图相匹配,但仍然与编译器存在分歧,需要调整以达到某种程度的共识。例如,假设程序员想要分配一个足够大的缓冲区来存储大量的整数(lots
是一个太大以至于无法合理使用栈或编译时未知的值);实现这一目标的一种(低级且容易出错但仍然合法)方法就是调用 std::malloc()
函数:
// ...
int *p = std::malloc(lots * sizeof(int)); // <-- HERE
if(p) {
// use p as an array of int objects
std::free(p);
}
// ...
如你所知,这段代码摘录不是有效的 C++代码 – std::malloc()
返回 void*
(一个指向至少请求大小的原始内存块的指针,如果分配失败则返回 nullptr
),而在 C++中 void*
不能隐式转换为 int*
(反之亦然,当然,int*
可以隐式转换为 void*
)。
注意,在这种情况下,我们可以用 new int[lots]
替换 std::malloc(lots*sizeof(int))
(这是一个过于简化的例子),但事情并不总是这么简单,有时我们需要对类型系统撒谎,即使只是一瞬间。这就是类型转换的作用所在。
那么,什么是类型转换呢?类型转换是一种受控的方式来引导编译器的类型系统理解程序员的意图。类型转换还在源代码中提供了关于这种暂时性谎言背后原因的信息;它们记录了程序员在需要撒谎的那一刻的意图。C++的类型转换在传达意图方面非常明确,在效果上非常精确;C 风格类型转换(在其他语言中也可见)在意图方面更为模糊,正如我们将在本章后面看到的那样,并且可以在具有如此丰富类型系统的 C++语言中执行不适当的转换。
类型系统中的安全性 – cv-资格
C++在其类型系统中提供了两个与安全性相关的资格符。这些被称为 const
和 volatile
,它们在许多方面都有关联。
const
资格符表示被此资格符指定的对象在当前作用域中被认为是不可变的,例如以下情况:
const int N = 3; // global constant
class X {
int n; // note: not const
public:
X(int n) : n{ n } {
}
int g() { // note: not const
return n += N; // thus, n's state can be mutated
}
int f() const { // const applies to this, and
// transitively to its members
// return g(); // illegal as g() is not const
return n + 1;
}
};
int f(const int &n) { // f() will not mutate argument n
return X{ n }.f() + 1; // X::X(int) takes its argument
// by value so n remains intact
}
int main() {
int a = 4;
a = f(a); // a is not const in main()
}
将一个对象标记为 const
意味着在它被标记为这样的上下文中,它不能被修改。在类成员的情况下,const
保证通过 const
成员函数传递,也就是说,一个 const
成员函数不能修改 *this
的成员,也不能调用同一对象的非 const
成员函数。在前面的例子中,X::f
是 const
的,因此它不能调用 X::g
,后者不提供这种保证;允许 X::f
调用 X::g
将实际上破坏 const
保证,因为 X::g
可以修改 *this
,而 X::f
不能。
const
标记在 C++ 中是众所周知且文档齐全的。通常认为“const
-correct”是良好的代码卫生习惯,并且在实践中应该努力做到;在合理的地方使用 const
是 C++ 语言最强大的特性之一,许多声称自己是“类型安全”的语言缺乏这一基本特性,没有它,正确性就难以实现。
volatile
关键字是 const
的对应词;因此,术语 cv-qualifier 指的是这两个术语。在标准中定义得相当不充分,volatile
有几种含义。
当应用于基本类型(例如,volatile int
)时,它意味着它所指定的对象可能通过编译器所不知的方式访问,并且不一定从源代码中可见。因此,这个术语在编写设备驱动程序时非常有用,其中程序本身之外的动作(例如,按键的物理压力)可能会改变与对象关联的内存,或者当某些硬件或软件组件(在源代码之外)可以观察该对象状态的变化时。
非正式地说,如果源代码声明“请读取那个 volatile
对象的值”,那么生成的代码应该读取那个值,即使程序看起来没有以任何方式修改它;同样,如果源代码声明“请写入那个* volatile
对象”,那么应该向那个内存位置写入,即使程序看起来在随后的操作中没有从那个内存位置读取。因此,volatile
可以被视为一种防止编译器执行其本可以执行优化的机制。
在 C++ 的抽象机器中,访问 volatile
标记的对象相当于 I/O 操作的道德等价物——它可以改变程序的状态。对于某些类类型的对象,volatile
可以应用于成员函数,就像 const
一样。实际上,一个非 static
成员函数可以是 const
、volatile
、const volatile
或这些都不是(以及其他事项)。
在之前的描述中,关于在成员函数上应用const
限定符的意义是通过X::f
成员函数来阐述的——*this
是const
;在该函数中,其非mutable
、非static
的数据成员是const
的,并且只有那些带有const
限定符的成员函数才能通过*this
来调用。同样,被volatile
限定的非static
成员函数也非常相似——在该函数执行期间,*this
是volatile
的,以及它的所有成员也都是volatile
的,这会影响你可以对这些对象执行的操作。例如,取volatile int
的地址会得到volatile int*
,这不能隐式转换为int*
,因为转换会丢失一些安全保证。这也是我们为什么有类型转换的原因之一。
C++的类型转换
传统上,C++支持四种执行我们称为类型转换的显式类型转换方式——static_cast
、dynamic_cast
、const_cast
和reinterpret_cast
。C++11 添加了第五种,duration_cast
,它与本书相关,但有时会出现在示例中,尤其是在我们测量函数执行时间时。最后,C++20 引入了第六种情况,bit_cast
,这对于本书中的工作很有兴趣。
以下几节简要概述了每种 C++类型转换,并附带了一些示例,说明它们何时以及如何有用。
你最好的朋友(大多数时候)——static_cast
在我们的类型转换工具集中,static_cast
是最好的、最有效的工具。它大多数情况下是安全的,基本上不花费任何成本,并且可以在constexpr
上下文中使用,这使得它适合于编译时操作。
你可以在涉及潜在风险的情境中使用static_cast
,例如将int
转换为float
或相反。在后一种情况下,它明确承认了小数部分的丢失。你还可以使用static_cast
将指针或引用从派生类转换为它的直接或间接基类(只要没有歧义),这是完全安全的,也可以隐式地进行,以及从基类转换为它的派生类。使用static_cast
从基类到派生类的转换效率很高,但如果转换不正确,风险极高,因为它不执行运行时检查。
下面有一些示例:
struct B { virtual ~B() = default; /* ... */ };
struct D0 : B { /* ... */ };
struct D1 : B { /* ... */ };
class X {
public:
X(int, double);
};
void f(D0&);
void f(D1*);
int main() {
const float x = 3.14159f;
int n = static_cast<int>(x); // Ok, no warning
X x0{ 3, 3.5 }; // Ok
// compiles, probably warns (narrowing conversion)
X x1(3.5,0);
// does not compile, narrowing not allowed with braces
// X x2{ 3.5, 0 };
X x3{ static_cast<int>(x), 3 }; // Ok
D0 d0;
// illegal, no base-derived relationship with D0 and D1
// D1* d1 = static_cast<D1*>(&d0);
// Ok, static_cast could be omitted
B *b = static_cast<B*>(&d0);
// f(*b); // illegal
f(*static_cast<D0*>(b)); // Ok
f(static_cast<D1*>(b)); // compiles but very dangerous!
}
特别注意前一个示例中static_cast
的最后使用——从基类转换为其派生类之一是适当地使用static_cast
完成的。然而,你必须确保转换会导致所选类型的对象,因为不会对转换的有效性进行运行时验证;正如其名称所暗示的,这个转换只进行编译时检查。如果你不确定如何使用向下转换,这不是你需要的工具。
static_cast
不仅改变编译器对表达式类型的看法;它还可以调整访问的内存地址,以考虑转换中涉及的类型。例如,当 D
类至少有两个非空的基类 B0
和 B1
时,这个派生类的这两个部分在 D
对象中的地址并不相同(如果它们是相同的,它们就会重叠!),所以从 D*
到其基类之一的 static_cast
可能会产生与 D*
本身不同的地址。我们将在讨论 reinterpret_cast
时回到这一点,对于 reinterpret_cast
,其行为不同(且更危险)。
出现问题的迹象——dynamic_cast
有时会遇到这样的情况,你有一个指向某个类类型对象的指针或引用,而这个类型恰好与所需的类型不同(但相关)。这种情况经常发生——例如,在游戏引擎中,大多数类都从某个 Component
基类派生,函数通常接受 Component*
参数,但需要访问期望的派生类对象的成员。
这里的主要问题是,通常,函数的接口是错误的——它接受类型不足够精确的参数。尽管如此,我们都有软件要交付,有时,即使我们在过程中做出了我们可能希望以后重新审视的一些选择,我们也需要让事情工作。
进行此类转换的安全方法是 dynamic_cast
。这种转换允许你将指针或引用从一个类型转换为另一个相关类型,以便你可以测试转换是否成功;对于指针,不正确的转换会产生 nullptr
,而对于引用,不正确的转换会抛出 std::bad_cast
。dynamic_cast
的类型相关性不仅限于基类派生关系,还包括在多重继承设计中从一个基类到另一个基类的转换。然而,请注意,在大多数情况下,dynamic_cast
要求要转换的表达式是具有至少一个 virtual
成员函数的多态类型。
这里有一些例子:
struct B0 {
virtual int f() const = 0;
virtual ~B0() = default;
};
struct B1 {
virtual int g() const = 0;
virtual ~B1() = default;
};
class D0 : public B0 {
public: int f() const override { return 3; }
};
class D1 : public B1 {
public: int g() const override { return 4; }
};
class D : public D0, public D1 {};
int f(D *p) {
return p? p->f() + p->g() : -1; // Ok
}
// g has the wrong interface: it accepts a D0& but
// tries to use it as a D1&, which makes sense if
// the referred object is publicly D0 and D1 (for
// example, class D
int g(D0 &d0) {
D1 &d1 = dynamic_cast<D1&>(d0); // throws if wrong
return d1.g();
}
#include <iostream>
int main() {
D d;
f(&d); // Ok
g(d); // Ok, a D is a D0
D0 d0;
// calls f(nullptr) as &d0 does not point to a D
std::cout << f(dynamic_cast<D*>(&d0)) << '\n'; // -1
try {
g(d0); // compiles but will throw bad_cast
} catch(std::bad_cast&) {
std::cerr << "Nice try\n";
}
}
注意,尽管这个例子在抛出 std::bad_cast
时显示了一条消息,但这绝对不能称为异常处理;我们没有解决“问题”,代码执行在可能已损坏的状态下继续,这可能会在更严重的代码中使事情变得更糟。在这个玩具示例中,只是让代码失败并停止执行也是一个合理的选择。
在实践中,dynamic_cast
的使用应该是罕见的,因为它往往是我们以可完善的方式选择了函数接口的标志。请注意,dynamic_cast
需要编译时包含 运行时类型信息(RTTI),这会导致二进制文件更大。不出所料,由于这些成本,一些应用领域可能会避免使用这种转换,我们也会这样做。
玩弄安全性的把戏——const_cast
无论是 static_cast
还是 dynamic_cast
(甚至包括 reinterpret_cast
),都不能改变表达式的 cv-限定符;要实现这一点,你需要 const_cast
。使用 const_cast
,你可以从表达式中添加或移除 const
或 volatile
限定符。正如你可能已经猜到的,这仅在指针或引用上才有意义。
为什么你会做诸如从表达式中移除 const
限定符之类的事情呢?令人惊讶的是,有许多情况下这很有用,但一个常见的情况是允许在 const
限定符未适当使用的情况下使用 const
-correct 类型——例如,未使用 const
的遗留代码,如下所示:
#include <vector>
struct ResourceHandle { /* ... */ };
// this function observes a resource without modifying it,
// but the type system is not aware of that fact (the
// argument is not const)
void observe_resource(ResourceHandle*);
class ResourceManager {
std::vector<ResourceHandle *> resources;
// ...
public:
// note: const member function
void observe_resources() const {
// we want to observe each resource, for example
// to collect data
for(const ResourceHandle * h : resources) {
// does not compile, h is const
// observe_resource(h);
// temporarily dismiss constness
observe_resource(const_cast<ResourceHandle*>(h));
}
}
// ...
};
const_cast
是一个用于玩弄类型系统安全性的工具;它应在特定、受控的情况下使用,而不是做不合理的事情,比如改变数学常数(如 pi)的值。如果你尝试这样做,你将遇到 未定义行为(UB)——这是理所当然的。
“相信我,编译器”—— reinterpret_cast
有时候,你只是要让编译器相信你。例如,知道在你的平台上 sizeof(int)==4
,你可能想将 int
作为 char[4]
来与期望该类型的现有 API 进行交互。请注意,你应该确保这个属性成立(可能通过 static_assert
),而不是依赖于所有平台上这个属性都成立(它并不成立)。
这就是 reinterpret_cast
给你的——将某种类型的指针转换为无关类型的指针的能力。这可以在我们看到的第二章中寻求利用指针互转换性的情况下使用,就像这也可以以几种相当危险且不便携的方式欺骗类型系统一样。
以从整数到四个字节的数组的上述转换为例——如果目的是为了便于对单个字节进行寻址,你必须意识到整数的字节序取决于平台,以及除非采取一些谨慎的措施,否则所编写的代码可能是不便携的。
此外,请注意,reinterpret_cast
只改变与表达式关联的类型——例如,它不会执行 static_cast
在多重继承情况下从派生类转换为基类时所做的轻微地址调整。
以下示例显示了这两种转换之间的区别:
struct B0 { int n = 3; };
struct B1 { float f = 3.5f; };
// B0 is the first base subobject of D
class D : public B0, public B1 { };
int main() {
D d;
// b0 and &d point to the same address
// b1 and &d do not point to the same address
B0 *b0 = static_cast<B0*>(&d);
B1 *b1 = static_cast<B1*>(&d);
int n0 = b0->n; // Ok
float f0 = b1->f; // Ok
// r0 and &d point to the same address
// r1 and &d also point to the same address... oops!
B0 *r0 = reinterpret_cast<B0*>(&d); // fragile
B1 *r1 = reinterpret_cast<B1*>(&d); // bad idea
int nr0 = r0->n; // Ok but fragile
float fr0 = r1->f; // UB
}
请谨慎使用 reinterpret_cast
。相对安全的使用包括在给定足够宽的整型类型时将指针转换为整型表示(反之亦然),在转换不同类型的空指针之间,以及在函数指针类型之间进行转换——尽管在这种情况下,通过结果指针调用函数的结果是未定义的。如果您想了解更多,可以查看使用此转换可以执行的所有转换的完整列表,请参阅 wg21.link/expr.reinterpret.cast。
我知道位是正确的——bit_cast
C++20 引入了 bit_cast
,这是一种新的转换,可以用来从一个对象复制位到另一个相同宽度的对象,在复制过程中开始目标对象(以及其中可能包含的对象)的生命周期,只要源和目标类型都是简单可复制的。这个有点神奇的库函数可以在 <bit>
头文件中找到,并且是 constexpr
的。
这里有一个例子:
#include <bit>
struct A { int a; double b; };
struct B { unsigned int c; double d; };
int main() {
constexpr A a{ 3, 3.5 }; // ok
constexpr B b = std::bit_cast<B>(a); // Ok
static_assert(a.a == b.c && a.b == b.d); // Ok
static_assert((void*)&a != (void*)&b); // Ok
}
如此例所示,A
和 B
都是在编译时构建的,并且它们在位上是相同的,但它们的地址是不同的,因为它们是完全不同的对象。它们的数据成员部分是不同类型的,但大小相同,顺序相同,并且都是简单可复制的。
此外,请注意在此示例的最后一行使用了 C 风格的转换。正如我们很快将要讨论的,这是 C 风格转换的少数合理用途之一(我们也可以在这里使用 static_cast
,它同样高效)。
有点不相关,但仍然——duration_cast
我们不会过多地讨论 duration_cast
,因为它与我们感兴趣的主题只有间接关系,但既然它将是本书中微基准测试工具集的一部分,它至少值得提一下。
duration_cast
库函数可以在 <chrono>
头文件中找到,它是 std::chrono
命名空间的一部分。它是 constexpr
的,并且可以用来在表示不同测量单位的表达式之间进行转换。
例如,假设我们想要测量执行某个函数 f()
所花费的时间,使用我们库供应商提供的 system_clock
。我们可以在调用 f()
之前和之后使用它的 now()
静态成员函数来读取那个时钟,这给了我们该时钟的两个 time_point
对象(两个时间点),然后计算它们之间的差异以获得该时钟的 duration
。我们不知道用来表示该持续时间的测量单位是什么,但如果我们想以,比如说,microseconds
的形式使用它,我们使用 duration_cast
来执行那个转换:
#include <chrono>
#include <iostream>
int f() { /* ... */ }
int main() {
using std::cout;
using namespace std::chrono;
auto pre = system_clock::now();
int res = f();
auto post = system_clock::now();
cout << "Computed " << res << " in "
<< duration_cast<microseconds>(post - pre);
}
我们将在本书的后面部分系统地介绍我们的基准测试实践,展示一种更正式的方式来衡量函数或代码块的执行时间,但 duration_cast
将成为我们选择用来确保我们展示结果格式的工具。
可恶的一个——C 转换
当需要类型转换时,你可能想使用 C 风格的转换,因为 C 语法出现在其他语言中,并且通常可以简洁地表达——(T)expr
将表达式expr
视为类型T
。这种简洁性实际上是一个缺点,而不是优点,正如我们将看到的。在 C++代码中将 C 风格的转换限制在最小范围内:
-
当在源代码文本中执行自动搜索时,C 风格的转换更难找到,因为它们看起来像函数调用中的参数。由于转换是我们欺骗类型系统的方式,因此时不时地回顾使用它们的决定是值得的,因此能够找到它们是有价值的。相比之下,C++的转换是关键字,这使得它们更容易找到。
-
C 风格的转换不传达关于转换发生原因的信息。当编写
(T)expr
时,我们并没有说明我们是否想要更改 cv 限定符、导航类层次结构、仅更改指针类型,等等。特别是,当在指向不同类型的指针之间进行转换时,C 风格的转换通常表现得像reinterpret_cast
,正如我们所看到的,在某些情况下可能会导致灾难性的结果。
你有时会在 C++代码中看到 C 风格的转换,大多数情况下是因为意图非常明确。我们在bit_cast
部分的末尾看到了一个例子。另一个例子是消除编译器警告——例如,当调用一个标记为[[nodiscard]]
的函数,但出于某种原因仍然想要丢弃结果时。
在另一个例子中,考虑以下泛型函数:
template <class ItA, class ItB>
bool all_equal(ItA bA, ItA eA, ItB bB, ItB eB) {
for(; bA != eA && bB != eB; ++bA, (void) ++bB)
if (*bA != bB)
return false;
return true;
}
此函数遍历两个分别由bA,eA)
和[bB,eB)
(确保在处理完最短序列后立即停止)分隔的序列,比较这两个序列中“相同位置”的元素,并且只有在那些两个序列之间的所有元素比较都相等时才返回true
。
注意,在这个代码中,将类型转换为void
使用了 C 风格的转换,在bA
和bB
的增量之间进行转换,将++bB
的结果转换为void
。这看起来可能有些奇怪,但这是几乎任何人,包括敌对(或分心的)用户都可以在许多情况下使用的代码。假设有人决定在operator++(ItA)
和operator++(ItB)
的类型之间重载逗号运算符(是的,你可以这样做)。那个人就可以基本上劫持我们的函数来运行意外的代码。通过将其中一个参数转换为void
,我们确保这是不可能的。
概述
这就结束了我们对 C++中转换和 cv 限定符的快速概述。现在我们已经看到了一些欺骗类型系统并陷入麻烦的方法,以及为什么我们应该谨慎(如果有的话)做这些事情的原因,我们可以开始用 C++构建美丽的事物,并朝着在编写正确程序以控制我们管理内存的尝试中实现安全、高效的抽象而努力。
在下一章中,我们将首先使用语言的一个定义性特征,即析构函数,来自动化我们代码处理资源的方式,特别是关注内存的处理方式。
第二部分:隐式内存管理技术
在这部分,我们将探讨一些在 C++中实现隐式资源管理(包括内存管理)的知名方法。这些都是你可以在日常编程实践中使用的技巧,它们将使你的程序比显式管理内存时更加简单和安全。可以说,这部分章节涉及人们所说的“现代”或“当代”C++。
本部分包含以下章节:
-
[第四章, 使用析构函数
-
第五章, 使用标准智能指针
-
第六章, 编写智能指针
第四章:使用析构函数
我们对 C++内存管理的更好和更深入的理解之旅现在进入了干净代码和当代实践的领域。在前面的章节中,我们探讨了内存表示的基本概念(什么是对象、引用、指针等等),如果我们以不适当的方式偏离良好的编程实践,会面临哪些陷阱,以及我们如何以受控和有纪律的方式欺骗类型系统,所有这些都将有助于本书的其余部分。现在,我们将讨论我们语言中资源管理的根本方面;内存作为一种特殊的资源,本章中找到的思想和技术将帮助我们编写干净和健壮的代码,包括执行内存管理任务的代码。
C++是一种支持(包括其他范例)面向对象编程的编程语言,但使用实际的对象。这听起来像是一种玩笑,但实际上这是一个正确的陈述:许多语言只提供对对象的间接访问(通过指针或引用),这意味着在这些语言中,赋值的语义通常是共享所引用的对象(目标)。当然,这也有其优点:例如,复制一个引用通常不会失败,而复制一个对象可能会失败,如果复制构造函数或复制赋值(根据情况而定)抛出异常。
在 C++中,默认情况下,程序使用对象、复制对象、赋值给对象等,间接访问是可选的,需要为指针和引用提供额外的语法。这要求 C++程序员考虑对象的生命周期,复制对象意味着什么,从对象移动意味着什么……这些话题可能很深,取决于涉及的类型。
注意
参见第一章了解更多关于对象和对象生命周期的信息,包括构造函数和析构函数的作用。
即使在源代码中实际使用对象需要调整编程时的思维方式,但它也提供了一个显著的优势:当自动对象达到它们声明的范围结束时(当它们达到该范围的闭合花括号时)以及当一个对象被销毁时,会调用一个特殊函数,即类型的}
,闭合花括号。
在本章中,我们将探讨析构函数的作用,它们不应该做什么,何时应该编写(以及何时我们应该坚持编译器默认的行为),以及我们的代码如何有效地使用析构函数来管理资源,一般而言……以及更具体地是内存。然后,我们将快速查看一些标准库中的关键类型,这些类型利用析构函数为我们带来便利。
更详细地说,在他的章节中,我们将:
-
提供一个概述,说明如何在 C++中安全地管理资源;
-
仔细研究 RAII 习语,这是一种众所周知的惯用实践,它使用对象的生存期来确保该对象管理的资源得到适当释放;
-
检查与自动化资源管理相关的一些陷阱;
-
快速概述标准库提供的某些自动化资源管理工具。
到本章结束时,我们将了解与 C++ 资源管理相关的一些最常见思想和实践。这将使我们能够在本书的剩余部分构建更强大的抽象。
技术要求
您可以在本书的 GitHub 仓库中找到本章的代码文件:github.com/PacktPublishing/C-Plus-Plus-Memory-Management/tree/main/chapter4
。
关于析构函数:简要回顾
本章旨在讨论使用析构函数来管理资源,特别是内存,但由于我们之前已经讨论过析构函数(在第第一章中),我们将快速回顾一下这个强大想法背后的基本概念:
-
当一个对象到达其生命周期的末尾时,会调用一个特殊的成员函数,称为析构函数。对于某些类
X
,该成员函数的名称为X::~X()
。这个函数是类型X
在结束其生命周期之前执行一些“最后时刻”行动的机会。正如我们将在本章中讨论的,析构函数的一种惯用用法是释放正在销毁的对象所持有的资源; -
在类层次结构中,当一个对象到达其生命周期的末尾时,发生的情况是(a)调用该对象的析构函数,然后(b)按照声明顺序调用每个非
static
数据成员的析构函数,最后(c)按照声明顺序调用每个基类子对象(其“父类”,非正式地)的析构函数; -
当通过在指针上应用
operator delete
来显式销毁对象时,涉及的过程是先销毁指针指向的对象,然后释放对象所在内存块的分配。不出所料,这里有一些注意事项,我们将在第七章中看到; -
在某些情况下,特别是当某个类
X
至少公开一个virtual
成员函数时,这表明X*
实际上可能指向一个从X
直接或间接派生的类Y
的对象。为了确保调用Y
的析构函数而不是X
的析构函数,通常也将X::~X()
标记为virtual
。如果不这样做,可能会不调用正确的析构函数,从而导致资源泄露。
以一个小例子为例,考虑以下内容:
#include <iostream>
struct Base {
~Base() { std::cout << "~Base()\n"; }
};
struct DerivedA : Base {
~DerivedA() { std::cout << "~DerivedA()\n"; }
};
struct VirtBase {
virtual ~VirtBase() {
std::cout << "~VirtBase()\n";
}
};
struct DerivedB : VirtBase {
~DerivedB() {
std::cout << "~DerivedB()\n";
}
};
int main() {
{
Base base;
}
{
DerivedA derivedA;
}
std::cout << "----\n";
Base *pBase = new DerivedA;
delete pBase; // bad
VirtBase *pVirtBase = new DerivedB;
delete pVirtBase; // Ok
}
如果您运行这段代码,您将看到为 base
调用一个析构函数,为 derivedA
调用两个析构函数:派生类的析构函数后跟基类的析构函数。这是预期的,并且这段代码是正确的。
有问题的案例是pBase
,一个指向Base*
类型的指针,它指向一个从Base
派生出来的类的对象,因为Base
的析构函数不是virtual
,这表明尝试通过基类指针删除派生对象可能是意图的违规:delete pBase
只调用Base::~Base()
,永远不会调用DerivedA::~DerivedA()
。通过pVirtBase
这个问题可以避免,因为VirtBase::~VirtBase()
是virtual
。
当然,在 C++中,我们有选择,因为总会有一些令人惊讶的使用场景出现,我们将在第七章中看到其中一个,我们将删除一个指向派生类的指针,而无需通过virtual
析构函数进行中介,这是出于(如果专门化)的良好(如果专门化)原因。
注意,virtual
成员函数是有用的,但它们也有成本:一个典型的实现将为每个类型创建一个包含至少一个virtual
成员函数的函数指针表,并将该表的指针存储在每个这样的对象中,这使得对象稍微大一些。因此,当你期望从一个基类的指针使用派生类的指针时,尤其是在你期望通过基类指针调用析构函数时,应该使用virtual
析构函数。
话虽如此,让我们来探讨一下这一切与资源管理之间的关系。
资源管理
假设你正在编写一个函数,该函数打开一个文件,从中读取数据,然后关闭它。你在一个过程式平台上进行开发(就像大多数操作系统 API 一样),该平台提供了一组执行这些任务的函数。请注意,在这个例子中,所有的“操作系统”函数都是故意虚构的,但与现实世界的对应物相似。在这个 API 中,对我们来说有趣的函数是:
// opens the file called "name", returns a pointer
// to a file descriptor for that file (nullptr on failure)
FILE *open_file(const char *name);
// returns the number of bytes read from the file into
// buf. Preconditions: file is non-null and valid, buf
// points to a buffer of at least capacity bytes, and
// capacity >= 0
int read_from(FILE *file, char *buf, int capacity);
// closes file. Precondition: file is non-null and valid,
void close_file(FILE *file);
假设你的代码需要处理从文件中读取的数据,但这种处理可能会抛出一个异常。这里异常的原因并不重要:可能是数据损坏、内存分配失败、调用某个会抛出异常的辅助函数,等等。关键点是,函数可能会抛出异常的风险。
如果我们尝试为该函数天真地编写代码,它可能看起来像这样:
void f(const char *name) {
FILE *file = open_file(name);
if(!file) return false; // failure
vector<char> v;
char buf[N]; // N is a positive integral constant
for(int n = read_from(file, buf, N); n != 0;
n = read_from(file, buf, N))
v.insert(end(v), buf + 0, buf + n);
process(v); // our processing function
close_file(file);
}
那段代码是可行的,在没有异常的情况下,基本上能完成我们想要的功能。现在,假设process(v)
抛出了一个异常…会发生什么?
在这种情况下,函数f()
退出,未能满足其后置条件。对process(v)
的调用从未结束…并且close_file(file);
也从未被调用。我们有一个泄漏。不一定是内存泄漏,但确实是泄漏,因为file
从未被关闭,因为从process()
抛出的异常在调用代码f()
中没有被捕获,这将结束f()
并让异常流经f()
的调用者(等等,直到被捕获或程序崩溃,哪个先到来)。
有一些方法可以绕过这种情况。一种方法是“手动”进行,并在可能抛出异常的代码周围添加一个try
… catch
块:
void f(const char *name) {
FILE *file = open_file(name);
if(!file) return; // failure
vector<char> v;
char buf[N]; // N is a positive integral constant
try {
for(int n = read_from(file, buf, N); n != 0;
n = read_from(file, buf, N))
v.insert(end(v), buf + 0, buf + n);
process(v); // our processing function
close_file(file);
} catch(...) { // catch anything
close_file(file);
throw; // re-throw what we caught
}
}
我同意这有点“笨拙”,有两个close_file(file)
的调用,一个在try
块的末尾,以在正常情况下关闭文件,另一个在catch
块的末尾,以避免文件资源的泄露。
手动方法可以使其工作,但这是一种脆弱的解决问题的方法:在 C++中,任何既不是noexcept
也不是noexcept(true)
的函数都可能抛出异常;这意味着在实践中,几乎任何表达式都可能抛出异常。
捕获任何东西
在 C++中,与某些其他语言中可以看到的相比,没有为所有异常类型指定一个单一的基类。确实,throw 3;
是完全合法的 C++代码。除此之外,C++拥有极其强大的泛型编程机制,这使得泛型代码在我们的语言中很普遍。因此,我们经常发现自己调用可能会抛出异常但无法真正知道会抛出什么的函数。要知道catch(...)
会捕获任何用于表示异常的 C++对象:你不知道你捕获了什么,但你确实捕获了它。
在这种情况下,我们通常会想要拦截异常,可能为了做一些清理工作,然后让那个异常保持不变地继续其路径,以便让客户端代码按需处理它。清理部分是因为我们希望我们的函数能够成为catch(...)
块,简单地使用throw;
,这被称为“重新抛出”。
异常处理…还是不处理?
这引出了另一个问题:在一个像f()
这样的函数中,我们只旨在消费数据并为我们自己的目的处理它,我们真的应该寻求处理异常吗?想想看:抛出异常的要求与处理异常的要求显著不同。
的确,我们从函数中抛出异常是为了表示我们的函数无法满足其后置条件(它无法完成其预期要做的任务):可能是内存不足,可能是要读取的文件不存在,可能是执行你要求的那个积分除法会导致除以零,从而摧毁宇宙(我们不想发生这种情况),可能是我们调用的某个函数无法以我们没有预见或不想处理的方式满足其自己的后置条件……函数失败有很多原因。许多情况下,函数可能会发现自己处于进一步执行会导致严重问题的位置,在某些情况下(构造函数和重载运算符就是例子),异常确实是向客户端代码发出问题的唯一合理方式。
处理异常本身是一种较为罕见的情况:抛出异常需要识别问题,但处理异常则需要理解上下文。确实,在交互式控制台应用程序中针对异常采取的行动与在人们跳舞时针对音频应用程序采取的行动不同,或者与面对核反应堆熔毁时所需的行动也不同。
大多数函数在某种程度上需要异常安全性(这一点有多种形式),而不仅仅是处理问题。在我们的例子中,困难源于在异常发生时手动关闭 file
。避免这种手动资源处理的最简单方法就是自动化它,而函数结束时发生的事情,无论该函数是否正常完成(到达函数的结束括号,遇到 return
语句,看到异常“飞过”),最好用析构函数来模拟。这种做法已经深深植根于 C++ 程序员的实践中,以至于被认为是惯用法,并被赋予了一个名称:RAII 习语。
RAII 习语
C++ 程序员倾向于使用析构函数来自动释放资源,这确实可以称得上是我们语言中的惯用编程技术,以至于我们给它起了一个名字。可能不是最好的名字,但无论如何是一个众所周知的名字:RAII,代表资源获取即初始化(有些人也建议责任获取即初始化,这也适用,并且有相似的含义)。一般想法是,对象倾向于在构造时间(或之后)获取资源,但(更重要的是!)释放对象持有的资源通常应该在对象生命周期的末尾完成。因此,RAII 更多地与析构函数有关,而不是与构造函数有关,但正如我所说的,我们往往在名称和缩写上做得不好。
回顾本章早期“管理资源”部分中的文件读取和处理示例,我们可以构建一个 RAII 资源处理器,以便无论函数如何结束都能方便地关闭文件:
class FileCloser { // perfectible, as we will see
FILE * file;
public:
FileCloser(FILE *file) : file{ file } {
}
~FileCloser() {
close_file(file);
}
};
void f(const char *name) {
FILE *file = open_file(name);
if(!file) return; // failure
FileCloser fc{ file }; // <-- fc manages file now
vector<char> v;
char buf[N]; // N is a positive integral constant
for(int n = read_from(file, buf, N); n != 0;
n = read_from(file, buf, N))
v.insert(end(v), buf + 0, buf + n);
process(v); // our processing function
} FileCloser does will vary with our perception of its role: does this class just manage the closing of the file or does it actually represent the file with all of its services? I went for the former in this case but both options are reasonable: it all depends on the semantics you are seeking to implement. The key point is that by using a FileCloser object, we are relieving client code of a responsibility, instead delegating the responsibility of closing a file to an object that automates this task, simplifying our own code and reducing the risks of inadvertently leaving it open.
This `FileCloser` object is very specific to our task. We could generalize it in many ways, for example through a generic object that performs a user-supplied set of actions when destroyed:
template
F f;
public:
scoped_finalizer(F f) : f{ f } {
}
~scoped_finalizer() {
f();
}
};
void f(const char *name) {
FILE *file = open_file(name);
if(!file) return; // 失败
auto sf = scoped_finalizer{ [&file] {
close_file(file);
} }; // <-- 文件现在由 sf 管理
vector
char buf[N]; // N 是一个正整数常量
for(int n = read_from(file, buf, N); n != 0;
n = read_from(file, buf, N))
v.insert(end(v), buf + 0, buf + n);
process(v); // 我们的处理函数
} 使用代码块,Java 有 try-with 语句,Go 有 defer 关键字等,但在 C++中,使用作用域来自动化与资源管理相关的操作的可能性直接来自类型系统,使得对象而不是用户代码成为习惯性地管理资源的一方。
RAII 和 C++的特殊成员函数
第一章描述了六个特殊成员函数(默认构造函数、析构函数、复制构造函数、复制赋值运算符、移动构造函数和移动赋值运算符)。当一个类实现这些函数时,通常意味着该类负责某些资源。如第一章中所述,当一个类没有明确管理资源时,我们通常可以将这些函数留给编译器,并且结果的行为通常会导致更简单、更高效的代码。
现在考虑一下,RAII 习语主要关于资源管理,因为我们把对象的销毁时刻与释放之前获取的资源的行为联系起来。许多 RAII 对象(包括前面示例中的FileCloser
和scoped_finalizer
类)可以说对它们提供的资源负责,这意味着复制这些对象可能会引入错误(谁将负责资源,原始对象还是副本?)。因此,除非你有充分的理由明确实现它们,否则请考虑删除你的 RAII 类型的复制操作:
template <class F> class scoped_finalizer {
F f;
public:
scoped_finalizer(const scoped_finalizer&) = delete;
scoped_finalizer& operator=
(const scoped_finalizer&) = delete;
scoped_finalizer(F f) : f{ f } {
}
~scoped_finalizer() {
f();
}
};
就像大多数习语一样,RAII 是一种普遍接受的优秀编程实践,但它并非万能良药,析构函数的使用也是如此。我们将探讨与析构函数相关的风险,以及如何避免陷入这样的困境。
一些陷阱
析构函数很棒。它们使我们能够自动化任务,简化代码,并在一般情况下使代码更安全。尽管如此,还有一些注意事项,使用析构函数的一些方面需要特别注意。
析构函数不应该抛出异常
本节的标题简单明了:析构函数不应该抛出异常。它们可以抛出异常,但这样做是个坏主意。
这可能一开始看起来有些令人惊讶。毕竟,构造函数可以(并且确实!)抛出异常。当构造函数抛出异常时,这意味着构造函数无法满足其后置条件:正在构建的对象没有被构建(构造函数没有完成!)因此,该对象不存在。这是一个简单、有效的模型。
如果析构函数抛出异常……嗯,这可能是你程序的终结。确实,析构函数是隐式noexcept
的,这意味着从析构函数中抛出异常将调用std::terminate()
,这将导致你的程序结束。
好吧,你可能想,如果我明确地将我的析构函数标记为noexcept(false)
,从而覆盖默认行为呢?好吧,这可以工作,但要注意,如果析构函数在栈回溯期间抛出异常,比如当异常已经在飞行中时,这仍然会调用std::terminate()
,因为你已经做错了事,违反了规则,编译器可以优化掉你的一些代码。例如,在以下程序中,即使此时Evil
的析构函数尚未被调用,也有可能既不会打印"A\n"
也不会打印"B\n"
:
#include <iostream>
class Darn {};
void f() { throw 3; }
struct Evil {
Evil() { std::cout << "Evil::Evil()\n"; }
~Evil() noexcept(false) {
std::cout << "Evil::~Evil()\n";
throw Darn {};
}
};
void g() {
std::cout << "A\n";
Evil e;
std::cout << "B\n";
f();
std::cout << "C\n";
}
int main() {
try {
g();
} catch(int) {
std::cerr << "catch(int)\n";
} catch(Darn) {
std::cerr << "darn...\n";
}
}
从这段代码可能得到的一个结果是,程序将什么也不显示,并且会输出一些类似“抛出Darn
导致调用std::terminate()
”的信息。为什么一些代码(特别是我们试图输出的消息)会被编译器明显地移除呢?答案是,未捕获的异常会进入实现定义的行为,而在这个例子中,抛出Darn
无法被捕获(因为它在栈回溯期间直接调用std::terminate()
),这使得编译器可以显著优化我们的代码。
总结一下:除非你真的知道自己在做什么,否则不要从析构函数中抛出异常,控制它将被调用的上下文,并且与其他人讨论以确保即使所有证据都指向相反的方向,这也是合理的。即便如此,寻找替代方案可能更好。
了解你的析构顺序
这个小节的标题可能看起来像是一个有趣的告诫。为什么了解我们的对象将被销毁的顺序很重要呢?毕竟,基本规则很简单:对象的构造和析构是对称的,因此对象将以构造的相反顺序被销毁…对吗?
好吧,这就是局部、自动对象的情况。如果你编写以下代码:
void f() {
A a; // a's ctor
B b; // b's ctor
{
C c; // c's ctor
} // c's dtor
D d; // d's ctor
} // d's dtor, b's dtor, a's dtor (in that order)
…然后构造和析构的顺序将如注释中所述:作用域内的自动对象将以构造的相反顺序被销毁,嵌套的作用域会按预期行为工作。
如果混合使用非自动对象,情况会变得更加复杂。C++ 允许在函数内声明static
对象:这些对象在函数第一次被调用时构造,并从那时起一直存活到程序执行结束。C++ 允许声明全局变量(这里有很多细微差别,例如static
或extern
链接说明),C++ 允许在类中有static
数据成员:这些本质上也是全局变量。我不会在这里提到thread_local
变量,因为它们超出了这本书的范围,但如果你使用它们,要知道它们可以被延迟初始化,这增加了整体图景的复杂性。全局对象将以构造的相反顺序被销毁,但这个构造顺序并不总是可以从我们的角度来看轻易预测。
考虑以下示例,它使用Verbose
对象,这些对象会告诉我们它们的构造时刻以及销毁时刻:
#include <iostream>
#include <format>
struct Verbose {
int n;
Verbose(int n) : n{ n } {
std::cout << std::format(«Verbose({})\n», n);
}
~Verbose(){
std::cout << std::format(«~Verbose({})\n», n);
}
};
class X {
static inline Verbose v0 { 0 };
Verbose v1{ 1 };
};
Verbose v2{ 2 };
static void f() {
static Verbose v3 { 3 };
Verbose v4{ 4 };
}
static void g() { // note : never called
static Verbose v5 { 5 };
}
int main() {
Verbose v6{ 6 };
{
Verbose v7{ 7 };
f();
X x;
}
f();
X x;
}
仔细思考这个示例,并试图弄清楚将会显示什么。我们有一个全局对象,一个类中的static
和inline
数据成员,两个局部于函数的static
对象,以及一些局部自动对象。
那么,如果我们运行这个程序,将会显示什么?如果你尝试运行它,你应该会看到:
Verbose(0)
Verbose(2)
Verbose(6)
Verbose(7)
Verbose(3)
Verbose(4)
~Verbose(4)
Verbose(1)
~Verbose(1)
~Verbose(7)
Verbose(4)
~Verbose(4)
Verbose(1)
~Verbose(1)
~Verbose(6)
~Verbose(3)
~Verbose(2)
~Verbose(0)
首先被构造(也是最后被销毁)的是v0
,即static
的inline
数据成员。它也恰好是我们的第一个全局对象,接着是v2
(我们的第二个全局对象)。然后我们进入main()
并创建v6
,它将在main()
结束时被销毁。
现在,如果你查看该程序的输出,你会看到在这一点上对称性被打破了,因为v6
构造之后,我们构造了v7
(在一个内部更窄的作用域中;v7
将在之后很快被销毁),然后第一次调用f()
,这构造了v3
,但v3
是一个全局对象,因此它将在v6
和v7
之后被销毁。
整个过程是机械的和确定的,但理解它需要一些思考和解析。如果我们使用对象的析构函数来释放资源,如果未能理解发生了什么以及何时发生,可能会导致我们的代码尝试使用已经释放的资源。
对于一个涉及自动和手动资源管理的具体示例,让我们看看 C++标准一无所知的东西:动态链接库(.dll
文件)。这里我不会深入细节,所以知道如果你在 Linux 机器上(使用共享对象,.so
文件)或在 Mac 上(.dylib
文件),总体思路是相同的,但函数名称将不同。
我们程序将(a)加载一个动态链接库,(b)获取一个函数的地址,(c)调用这个函数,然后(d)卸载库。假设库的名称为Lib
,我们想要调用的函数名为factory
,它返回一个X*
,我们想要调用其成员函数f()
:
#include "Lib.h"
#include <Windows.h> // LoadLibrary, GetProcAddress
int main() {
using namespace std;
HMODULE hMod = LoadLibrary(L"Lib.dll");
// suppose the signature of factory is in Lib.h
auto factory_ptr = reinterpret_cast<
decltype(&factory)
>(GetProcAddress(hMod, "factory"));
X *p = factory_ptr();
p->f();
delete p;
FreeLibrary(hMod);
}
你可能已经注意到了其中的手动内存管理:我们通过factory_ptr
调用factory()
来获取一个资源(一个指向至少是X
的X*
),然后我们使用(在pointee
上调用f()
)并手动释放该指针。
到目前为止,你可能正在告诉自己手动资源管理并不是一个好主意(这里:如果p->f()
抛出异常,资源会发生什么?),所以你查阅了标准,发现std::unique_ptr
类型的对象将负责指针,并在其析构函数被调用时销毁它。这很美,不是吗?事实上,它可能确实如此,但考虑以下摘录,重新编写以使用std::unique_ptr
并自动化资源管理过程:
#include "Lib.h"
#include <memory> // std::unique_ptr
#include <Windows.h> // LoadLibrary, GetProcAddress
int main() {
using namespace std;
HMODULE hMod = LoadLibrary(L"Lib.dll");
// suppose the signature of factory is in Lib.h
auto factory_ptr = reinterpret_cast<
decltype(&factory)
>(GetProcAddress(hMod, "factory"));
std::unique_ptr<X> p { factory_ptr() };
p->f();
// delete p; // not needed anymore
FreeLibrary(hMod);
} p is now an RAII object responsible for the destruction of the *pointee*. Being destroyed at the closing brace of our main() function, we know that the destructor of the *pointee* will be called even if p->f() throws, so we consider ourselves more exception-safe than before…
… except that this code crashes on that closing brace! If you investigate the source of the crash, you will probably end up realizing that the crash happens at the point where the destructor of `p` calls operator `delete` on the `X*` it has stored internally. Reading further, you will notice that the reason why this crash happens is that the library the object came from has been freed (call to `FreeLibrary()`) before the destructor ran.
Does that mean we cannot use an automated memory management tool here? Of course not, but we need to be more careful with the way in which we put object lifetime to contribution. In this example, we want to make sure that `p` is destroyed before the call to `FreeLibrary()` happens; this can be achieved through the simple introduction of a scope in our function:
include "Lib.h"
include // std::unique_ptr
include <Windows.h> // LoadLibrary, GetProcAddress
int main() {
using namespace std;
HMODULE hMod = LoadLibrary(L"Lib.dll");
// 假设 factory 的签名在 Lib.h 中
auto factory_ptr = reinterpret_cast<
decltype(&factory)
(GetProcAddress(hMod, "factory"));
{
std::unique_ptr
p->f();
} // p 被销毁在这里
FreeLibrary(hMod);
}
In this specific example, we could find a simple solution; in other cases we might have to move some declarations around to make sure the scopes in which our objects find themselves don’t alter the intended semantics of our function. Understanding the order in which objects are destroyed is essential to properly using this precious resource management facility that is the destructor.
Standard resource management automation tools
The standard library offers a significant number of classes that manage memory efficiently. One needs only consider the standard containers to see shining examples of the sort. In this section, we will take a quick look at a few examples of types useful for resource management. Far from providing an exhaustive list, we’ll try to show different ways to benefit from the RAII idiom.
As mentioned before, when expressing a type that provides automated resource management, the key aspects of that type’s behavior are expressed through its six special member functions. For that reason, with each of the following types, we will take a brief look at what the semantics of these functions are.
unique_ptr<T> and shared_ptr<T>
This short section aims to provide a brief overview of the two main standard smart pointers types in the C++ standard library: `std::unique_ptr<T>` and `std::shared_ptr<T>`. It is meant to provide a broad overview of each type’s role; a more detailed examination of how these types can be used appears in *Chapter 5*, and we will implement simplified versions of both types (as well as of a few other smart pointer types) in *Chapter 6*.
We have seen an example using `std::unique_ptr<T>` earlier in this chapter. An object of this type implements “single ownership of the resource” semantics: an object of type `std::unique_ptr<T>` is uncopiable, and when provided with a `T*` to manage, it destroys the *pointee* at the end of its lifetime. By default, this type will call `delete` on the pointer it manages, but it can be made to use some other means of disposal if needed.
A default `std::unique_ptr<T>` represents an empty object and mostly behaves like a null pointer. Since this type expresses exclusive ownership of a resource, it is uncopiable. Moving from a `std::unique_ptr<T>` transfers ownership of the resource, leaving the moved-from object into an empty state conceptually analogous to a null pointer. The destructor of this type destroys the resource managed by the object, if any.
Type `std::shared_ptr<T>` implements “shared ownership of the resource” semantics. With this type, each `std::shared_ptr<T>` object that co-owns a given pointer shares responsibilities with respect to the pointee’s lifetime and the last co-owner of the resource is responsible for freeing it; as is the case with most smart pointers, this responsibility falls on the object’s destructor. This type is surprisingly complicated to write, even in a somewhat naïve implementation like the one we will write in *Chapter 6*, and is less frequently useful than some people think, as the main use case (expressing ownership in the type system for cases where the last owner of the pointee is a priori unknown, something most frequently seen in multithreaded code) is more specialized than many would believe, but when one needs to fill this niche, it’s the kind of type that’s immensely useful.
A default `std::shared_ptr<T>` also represents an empty object and mostly behaves like a null pointer. Since this type expresses shared ownership of a resource, it is copyable but copying an object means sharing the *pointee*; copy assignment releases the resource held by the object on the left hand of the assignment and then shares the resource held by the object on the right side of the assignment between both objects. Moving from a `std::unique_ptr<T>` transfers ownership of the resource, leaving the moved-from object into an empty state. The destructor of this type releases ownership of the shared resource, destroying the resource managed by the object if that object was the last owner thereof.
What does the “shared” in shared_ptr mean?
There can be confusion with respect to what the word “shared” in the name of the `std::shared_ptr` type actually means. For example, should we use that type whenever we want to share a pointer between caller and callee? Should we use it when whenever client code makes a copy of a pointer with the intent of sharing the pointee, such as when passing a pointer by value to a function or sharing resources stored in a global manager object?
The short answer is that this is the wrong way to approach smart pointers. Sharing a dynamically allocated resource does not mean co-owning that resource: only the latter is what `std::shared_ptr` models, whereas the former can be done with much more lightweight types. We will examine this idea in detail in *Chapter 5* from a usage perspective, then reexamine it in *Chapter 6* with our implementer eyes, hopefully building a more comprehensive understanding of these deep and subtle issues.
lock_guard and scoped_lock
Owning a resource is not limited to owning memory. Indeed, consider the following code excerpt and suppose that `string_mutator` is a class used to perform arbitrary transformations to characters in a `string`, but is expected to be used in a multithreaded context in the sense that one needs to synchronize accesses to that `string` object:
include
include
include
include
include <string_view>
class string_mutator {
std::string text;
mutable std::mutex m;
public:
// note: m in uncopiable so string_mutator
// also is uncopiable
- string_mutator(std::string_view src)
- text{ src.begin(), src.end() } {
}
template
m.lock();
std::transform(text.begin(), text.end(),
text.begin(), f);
m.unlock();
}
std::string grab_snapshot() const {
m.lock();
std::string s = text;
m.unlock();
return s;
}
};
In this example, a `string_mutator` object’s function call operator accepts an arbitrary function `f` applicable to a `char` and that returns something that can be converted to a `char`, then applies `f` to each `char` in the sequence. For example, the following call would display `"I LOVE` `MY INSTRUCTOR"`:
// ...
string_mutator sm{ "I love my instructor" };
sm([](char c) {
return static_cast
});
std::cout << sm.grab_snapshot();
// ...
Now, since `string_mutator::operator()(F)` accepts any function of the appropriate signature as argument, it could among other things accept a function that could throw an exception. Looking at the implementation of that operator, you will notice that with the current (naïve) implementation, this would lock `m` but never unlock it, a bad situation indeed.
There are languages that offer specialized language constructs to solve this problem. In C++, there’s no need for such specialized support as robust code just flows from the fact that one could write an object that locks a mutex at construction time and unlocks it when destroyed… and that’s pretty much all we need. In C++, the simplest such type is `std::lock_guard<M>`, where a simple implementation could look like:
template
class lock_guard { // 简化版本
M &m;
public:
lock_guard(M &m) : m { m } { m.lock(); }
~lock_guard() { m.unlock(); }
lock_guard(const lock_guard&) = delete;
lock_guard& operator=(const lock_guard&) = delete;
};
The simplest types are often the best. Indeed, applying this type to our `string_mutator` example, we end up with a simpler, yet much more robust implementation:
include
include
include
include
include <string_view>
class string_mutator {
std::string text;
mutable std::mutex m;
public:
// note: m in uncopiable so string_mutator
// also is uncopiable
- string_mutator(std::string_view src)
- text{ src.begin(), src.end() } {
}
template
std::lock_guard lck{ m };
std::transform(text.begin(), text.end(),
text.begin(), f);
} // 隐式 m.unlock
std::string grab_snapshot() const {
std::lock_guard lck{ m };
return text;
} // 隐式 m.unlock
};
Clearly, using destructors to automate unlocking our mutex is advantageous for cases such as this: it simplifies code and helps make it exception-safe.
stream objects
In C++, stream objects are also resource owners. Consider the following code example where we copy each byte from file `in.txt` to the standard output stream:
include
include
int main() {
std::ifstream in{ "in.txt" };
for(char c; in.get(c); )
std::cout << c;
}
You might notice a few interesting details in this code: we never call `close()`, there’s no `try` block where we would be preparing ourselves for exception management, there’s no call to `open()` in order to open the file, there’s no explicit check for some end-of-file state… yet, this code works correctly, does what it’s supposed to do, and does not leak resources.
How can such a simple program do all that? Through “the magic of destructors”, or (more precisely) the magic of a good API. Think about it:
* The constructor’s role is to put the object in a correct initial state. Thus, we use it to open the file as it would be both pointless and inefficient to default-construct the stream, then open it later.
* Errors when reading from a stream are not exceptional at all… Think about it, how often do we face errors when reading from a stream? In C++, reading from a stream (here: calling `in.get(c)`) returns a reference to the stream after reading from it, and that stream behaves like a `false` Boolean value if the stream is in an error state.
* Finally, the destructor of a stream object closes whatever representation of a stream it is responsible for. Calling `close()` on a stream in C++ is unnecessary most of the time; just using the stream object in a limited scope generally suffices.
Destructors (and constructors!), when used appropriately, lead to more robust and simpler code.
vector<T> and other containers
We will not write a full-blown comparison of containers with raw arrays or other low-level constructs such as linked lists with manually managed nodes or dynamic arrays maintained explicitly through client code. We will however examine how one can write containers such as `std::vector` or `std::list` in later chapters of this book (*Chapters 12*, *13*, and *14*) when we know a bit more on memory management techniques.
Please note, still, that using `std::vector<T>` (for example) is not only significantly simpler and safer than managing a dynamically allocated array of `T`: in practice, it’s most probably significantly *faster*, at least if used knowledgeably. As we will come to see, there’s no way users can invest the care and attention that goes into memory management and object creation, destruction and copying or movement that goes in a standard container when writing day-to-day code. The destructor of these types, coupled with the way their other special member functions are implemented, make them almost as easy to use as `int` objects, a worthy goal if there ever was one!
Summary
In this chapter, we have discussed some safety-related issues, with a focus on those involving exceptions. We have seen that some standard library types offer specialized semantics with respect to resource management, where “resource” includes but is not limited to memory. In *Chapter 5*, we will spend some time examining how to use and benefit from standard smart pointer; then, in *Chapter 6*, we will go further and look at some of the challenges behind writing your own versions of these smart pointers, as well as some other smart pointer-inspired types with other semantics. Then, we will delve into deeper memory management-related concerns.
第五章:使用标准智能指针
C++ 强调使用值进行编程。默认情况下,你的代码使用对象,而不是对象的间接引用(引用和指针)。当然,对象的间接访问是允许的,而且很少有程序从不使用这种语义,但这是一种可选的,并且需要额外的语法。第四章 探讨了通过析构函数和 RAII 习语将资源管理与对象生命周期相关联,展示了 C++在该方面的主要优势,即基本上所有资源(包括内存)都可以通过语言的机制隐式地处理。
C++ 允许在代码中使用原始指针,但并不积极鼓励这样做。事实上,恰恰相反——原始指针是一种低级设施,效率极高但容易误用,并且从源代码中直接推断出对指针所指内容的责任并不容易。从几十年前的(现在已移除的)auto_ptr<T>
设施开始,C++社区一直在努力定义围绕低级设施(如原始指针)的抽象,通过提供清晰、定义良好的语义的类型来减少编程错误的风险。这一努力取得了显著的成功,这在很大程度上得益于 C++语言的丰富性和其创建强大且高效的抽象的能力,而不会在运行时损失速度或使用更多内存。因此,在当代 C++中,原始指针通常封装在更难误用的抽象之下,例如标准容器和智能指针,这些内容我们将在本章中探讨;未封装的原始指针主要用于表示“这里有一个你可以使用但 不拥有的资源。”
本章将探讨如何使用 C++的标准智能指针类型。我们首先将了解它们是什么,然后深入探讨如何有效地使用主要智能指针类型。最后,我们将探讨那些需要“亲自动手”(如此说法)并使用原始指针的时刻,理想情况下(但不仅限于此)通过智能指针的介来实现。这应该会引导我们学习如何为特定用例选择标准智能指针,如何适当地使用它们,以及如何处理必须通过自定义机制释放的资源。在整个过程中,我们将牢记并解释我们所做选择的开销。
在本章中,我们将做以下几件事:
-
快速了解一下标准智能指针的一般概念,以形成它们存在原因的认识
-
更仔细地看看
std::unique_ptr
,包括它是如何被用来处理标量、数组和以非典型方式分配的资源 -
查看
std::shared_ptr
以及这种基本但成本更高的类型的用例,以便了解何时应优先考虑替代方案 -
快速看一下
std::weak_ptr
,它是std::shared_ptr
的伴侣,当需要模拟临时共享所有权时非常有用 -
看看哪些情况下应该使用原始指针,因为它们在 C++生态系统中仍有其位置
准备好了吗?让我们深入探讨!
技术要求
您可以在本书的 GitHub 仓库中找到本章的代码文件:github.com/PacktPublishing/C-Plus-Plus-Memory-Management/tree/main/chapter5
.
标准智能指针
C++的智能指针种类相对较少。在查看标准提供的选项集之前,让我们花点时间展示我们试图解决的问题。考虑以下(故意不完整)的程序。你看到它有什么问题吗?
class X {
// ...
};
X *f();
void g(X *p);
void h() {
X *p = f();
g(p);
delete p;
}
这段代码在语法上是合法的,但你不希望在当代程序中看到它。这里可能出错的地方太多了,以下是一个潜在问题的非详尽列表:
-
我们不知道
g()
是否会调用delete p
,这可能导致在h()
之后的第二次delete
(在已销毁的对象上!) -
我们不知道
g()
是否可能会抛出异常,在这种情况下,h()
中的delete p;
指令将永远不会被执行 -
我们不知道是否应该假设
h()
拥有p
,也就是说,我们不知道它是否应该负责在p
上调用operator delete()
(也许它应该是g()
或其他函数的责任) -
我们不知道
p
指向的内容是否是用new
、new[]
或其他方式(如malloc()
、来自其他语言的某些设施、代码库中的某些自定义实用工具等)分配的 -
我们甚至不知道
p
指向的内容是否已经动态分配;例如,p
可能指向在f()
中声明的全局或static
变量(这是一个坏主意,但有些人确实这样做——例如,以非惯用方式在 C++中实现单例设计模式)
例如,比较f()
的两种可能实现(我们可以考虑的还有很多,但这里这些就足够了):
X *f() { // here’s one possibility
return new X;
}
X *f() { // here’s another
static X x;
return &x;
}
在第一种情况下,调用返回指针上的delete
可能是有意义的,但在第二种情况下,这样做将是灾难性的。函数签名中没有任何内容明确告知客户端代码我们面临的是这种情况,还是另一种情况,甚至完全是其他情况。
作为某种“奖励”,如果有人调用f()
而没有使用返回值会怎样?如果f()
实现为return new X;
或类似的内容,那么代码将发生泄漏——这确实是一个不愉快的视角。请注意,自 C++17 以来,您可以通过在f()
的返回类型上使用[[nodiscard]]
属性来减轻这个问题,但您仍然应该注意。从函数返回原始指针是我们主要试图避免的,尽管有时我们不得不这样做。
这里还有其他可能的陷阱,它们都有一个共同的主题——使用原始指针,我们传统上无法从源代码中知道语义是什么。更具体地说,我们无法确定谁负责指针及其指向的对象。原始指针不提供清晰的所有权信息,这多年来一直是 C++中 bug 的反复来源。
现在,考虑另一种情况,以下代码片段:
// ...
void f() {
X *p = new X;
thread th0{ [p] { /* use *p */ };
thread th1{ [p] { /* use *p */ };
th0.detach();
th1.detach();
}
在这种情况下,f()
分配了一个由p
指向的X
对象,之后两个线程th0
和th1
复制p
(从而共享p
指向的X
对象)。最后,th0
和th1
被分离,这意味着线程将一直运行到完成,即使f()
已经执行完毕。如果我们不知道th0
和th1
将如何结束,我们就不能明确地说哪个线程应该负责在p
上调用operator delete()
。这是关于指针所指对象责任不明确的问题,但与我们的第一个例子不同,因此需要不同的解决方案。
对于有明确标识的最后所有者的指针所指对象的情况,无论指针之间是否共享指针所指对象,你可能希望使用std::unique_ptr
。在(更专业,但非常真实且相当微妙)指针所指对象至少由两个“共同所有者”共享,并且这些所有者将销毁的顺序是先验未知的情况下,std::shared_ptr
是首选工具。以下几节将更详细地介绍这些类型的作用和意义,希望有助于你在选择智能指针类型时做出明智的选择。
在通过函数签名表达意图的解释中
尽管我们还没有详细研究标准智能指针,但可能适当地提供一些关于它们含义的说明,特别是对于std::unique_ptr
和std::shared_ptr
。这两种类型传达所有权语义——std::unique_ptr
代表唯一所有权,而std::shared_ptr
代表共同所有权(或共享所有权)。
理解拥有(特别是共同拥有)指针所指对象与共享指针所指对象之间的区别非常重要。考虑以下示例,该示例使用std::unique_ptr
(尽管我们尚未介绍它,但我们正在接近这个目标)和原始指针一起来在类型系统中记录所有权语义:
#include <memory>
#include <iostream>
// print_pointee() shares a pointer with the caller
// but does not take ownership
template <class T> void print_pointee(T *p) {
if (p) std::cout << *p << ‘\n’;
}
std::unique_ptr<T> make_one(const T &arg) {
return std::make_unique<T>(arg);
}
int main() {
auto p = make_one(3); // p is a std::unique_ptr<int>
print_pointee(p.get()); // caller and callee share the
// pointer during this call
}
如在介绍此示例时提到的,我们使用 std::unique_ptr
对象来表示所有权——make_one()
构造 std::unique_ptr<T>
并将所有权转让给调用者;然后,该调用者保持对该对象的所有权,并与他人(此处为 print_pointee()
)共享基础指针,但不放弃对指向对象的所有权。使用但不拥有是通过原始指针来模拟的。这在一个高度简化的设置中向我们展示了拥有和共享资源之间的区别——main()
中的 p
拥有资源,但与非所有者 p
在 print_pointee()
中共享资源。这全部都是安全且符合 C++ 习惯的代码。
了解标准智能指针类型模型表示所有权的知识,我们知道只要有一个明确的最后用户使用资源,std::unique_ptr
往往是首选类型;它比 std::shared_ptr
(我们将会看到)轻量得多,并且提供了适当的所有权语义。
当然,有些情况下 std::unique_ptr
不是一个好的选择。考虑以下简化、非线程安全且不完整的代码片段:
class entity {
bool taken{ false };
public:
void take() { taken = true; }
void release() { taken = false; }
bool taken() const { return taken; }
// ...
};
constexpr int N = ...;
// entities is where the entity objects live. We did
// not allocate them dynamically, but if we had we would
// have used unique_ptr<entity> as this will be the
// single last point of use for these objects
array<entity,N> entities;
class nothing_left{};
// this function returns a non-owning pointer (Chapter 6
// will cover more ergonomic options than a raw pointer)
entity * borrow_one() {
if(auto p = find_if(begin(entities), end(entities),
[](auto && e) { return !e.taken(); };
p != end(entities)) {
p->take();
return &(*p); // non-owning pointer
}
throw nothing_left{};
}
注意,borrow_one()
与调用代码共享一个指针,但不共享 所有权 ——在这种情况下,entity
对象的提供者仍然对这些对象的生存期负责。这既不是 std::unique_ptr
(资源的唯一所有者)的情况,也不是 std::shared_ptr
(资源的共同所有者)的情况。我们将看到,有其他方法可以使用原始指针来表示非所有权的指针,正如我们在 第六章 中将看到的那样。
这里的重要点是 函数签名传达了意义,并且使用传达我们意图的类型是很重要的。为了做到这一点,我们必须理解这个意图。让我们在以下章节中探索如何使用标准智能指针来发挥优势时记住这一点。
输入 unique_ptr
如其名称所示,unique_ptr<T>
对象表示对指向对象的唯一(独特)所有权。这恰好是处理动态分配内存时所有权语义的常见情况——甚至可能是最常见的情况。
考虑本章的第一个(仍然故意不完整)示例,其中我们无法从源代码中确定 *指向对象的所有权,并让我们用 unique_ptr
对象而不是原始指针来重写它:
#include <memory>
class X {
// ...
};
std::unique_ptr<X> f();
void g(std::unique_ptr<X>&);
void h() {
// we could write std::unique_ptr<X> instead of auto
auto p = f();
g(p);
} f() is responsible for the lifetime of the X object it points to, and it’s also clear that g() uses the enclosed X* without becoming responsible for the pointed-to X object. Add to this the fact that p is an object and, as such, will be destroyed if g() throws or if f() is called in such a way that the calling code forgets to use the return value, and you get an exception-safe program – one that’s shorter and simpler than the original one!
Murphy and Machiavelli
You might be thinking, “*But I’m sure I could steal the pointer managed by the* `std::unique_ptr` *in* `g()`,” and you would be correct. Not only is it possible but also easy, as `unique_ptr` gives you direct access to the underlying pointer in more than one way. However, the type system is designed to protect us from accidents and make reasonable well-written code work well. It will protect you from Murphy, the accidents that happen, not from Machiavelli, the deliberately hostile code.
If you write deliberately broken code, you will end up with a deliberately broken program. It’s pretty much what you would expect.
In terms of semantics, you could tell a story just with function signatures, using `std::unique_ptr` objects. Note that in the following example, the functions have been left deliberately incomplete to make it clear that we are concerned with their signatures only:
// ...
// 动态创建一个 X 或其派生类
// X 并返回它,而不会存在泄漏的风险
unique_ptr
// 值传递,这在实践中意味着移动传递
// 由于 unique_ptr 不可复制
unique_ptr
// 通过引用传递以允许修改指向对象。在
// 实践中,X* 会是一个更好的选择
void possible_mutation(unique_ptr
// 通过引用到 const 传递以查询指向对象,但
// 不要修改它。在实践中,这里更喜欢 const X*
void consult(const unique_ptr
// sink() 消耗作为参数传递的对象 : 获取
// in, never gets out. This could use pass-by-value but
// 用右值引用可能更清晰
void sink(unique_ptr
// ...
As we can see, function signatures talk to us. It’s better if we pay attention.
Handling objects
The `unique_ptr` type is a remarkable tool, one you should strive to get acquainted with if you have not done so already. Here are some interesting facts about that type and how it can be used to manage pointers to objects.
A `unique_ptr<T>` object is non-copyable, as its copy constructor and copy assignment member functions are marked as deleted. That’s why `g()` in the first example of the *Type unique_ptr* section takes its argument by reference – `g()` shares the pointee with the caller but does not take ownership of it. We could also have expressed `g()` as taking `X*` as an argument, with the contemporary acceptance that function arguments that are raw pointers are meant to model using a pointer but without owning it:
include
class X {
// ...
};
std::unique_ptr
void g(X*);
void h() {
// 我们可以写 std::unique_ptr
auto p = f();
g(p.get());
} // p 在这里隐式释放了指向的 X 对象
`unique_ptr<T>` is also movable – a moved-from `unique_ptr<T>` behaves like a null pointer, as the movement for this type semantically implements a transfer of ownership. This makes it simpler to implement various types that need to manage resources indirectly.
Consider, for example, the following `solar_system` class, which supposes a hypothetical `Planet` type as well as a hypothetical implementation for `create_planet()`:
include “planet.h”
include
include
include
std::unique_ptr
create_planet(std::string_view name);
class solar_system {
std::vector<std::unique_ptr
create_planet(“mercury.data”),
create_planet(“venus.data”), // 等。
};
public:
// solar_system 默认是不可复制的
// solar_system 默认是可移动的
// 无需写 ~solar_system,因为行星
// 管理其资源隐式
};
If we had decided to implement `solar_system` with `vector<Planet*>` or as `Planet*` instead, then the memory management of our type would have to be performed by `solar_system` itself, adding to the complexity of that type. Since we used a `vector<unique_ptr<Planet>>`, everything is implicitly correct by default. Of course, depending on what we are doing, `vector<Planet>` might be even better, but let’s suppose we need pointers for the sake of the example.
A `unique_ptr<T>` offers most of the same operations as `T*`, including `operator*()` and `operator->()`, as well as the ability to compare them with `==` or `!=` to see whether two `unique_ptr<T>` objects point to the same `T` object. The latter two might seem strange, as the type represents sole ownership of the *pointee*, but you could use references to `unique_ptr<T>`, in which case these functions make sense:
include
template
bool point_to_same(const std::unique_ptr
const std::unique_ptr
return p0 == p1;
}
template
bool have_same_value(const std::unique_ptr
const std::unique_ptr
return p0 && p1 && *p0 == *p1;
}
include
int main() {
// 两个指向具有相同值的对象的独立指针
std::unique_ptr
std::unique_ptr
assert(point_to_same(a, a) && have_same_value(a, a));
assert(!point_to_same(a, b) && have_same_value(a, b));
}
For good reasons, you cannot do pointer arithmetic on `unique_ptr<T>`. If you need to do pointer arithmetic (and we sometimes will – for example, when we write our own containers in *Chapter 13*), it’s always possible to get to the raw pointer owned by a `unique_pointer<T>` through its `get()` member function. This is often useful when interfacing with C libraries, making system calls, or calling functions that use a raw pointer without taking ownership of it.
Oh, and here’s a fun fact – `sizeof(unique_ptr<T>)==sizeof(T*)` with a few exceptions that will be discussed later in this chapter. This means that there’s generally no cost in terms of memory space to using a smart pointer instead of a raw pointer. In other words, by default, the only state found in a `unique_ptr<T>` object is `T*`.
Handling arrays
A nice aspect of `unique_ptr` is that it offers a specialization to handle arrays. Consider the following:
void f(int n) {
// p 指向一个值为 3 的 int
std::unique_ptr
// q 指向一个 n 个 int 对象的数组
// 初始化为零
std::unique_ptr<int[]> q{ new int[n] {} };
// 示例用法
std::cout << *p << ‘\n’; // 显示 3
for(int i = 0; i != n; ++i) {
// operator[] 对 unique_ptr<T[]> 支持操作
q[i] = i + 1;
}
// ...
} // q 的析构函数在其指针上调用 delete []
// p 的析构函数在其指针上调用 delete
What, you might think, is the use case for this? Well, it all depends on your needs. For example, if you require a variable-sized array of `T` that grows as needed, use `vector<T>`. It’s a wonderful tool and extremely efficient if used well.
If you want a fixed-sized array that’s small enough to fit on your execution stack where the number of elements, `N`, is known at compile time, use a raw array of `T` or an object of type `std::array<T,N>`.
If you want a fixed-sized array that’s either not small enough to fit on your execution stack or where the number of elements, `n`, is known at runtime, you can use `vector<T>`, but you’ll pay for facilities you might not require (`vector<T>` remains an awesome choice, that being said), or you could use `unique_ptr<T[]>`. Note that if you go for this latter option, you will end up having to track the size yourself, separately from the actual array, since `unique_ptr` does no such tracking. Alternatively, of course, you can wrap it in your own abstraction, such as `fixed_size_array<T>`, as follows:
include
include
template
class fixed_size_array {
std::size_t nelems{};
std::unique_ptr<T[]> elems {};
public:
fixed_size_array() = default;
auto size() const { return nelems; }
bool empty() const { return size() == 0; }
- fixed_size_array(std::size_t n)
- nelems { n }, elems{ new T[n] {} } {
}
T& operator[](int n) { return elems[n]; }
const T& operator[](int n) const { return elems[n]; }
// 等。
};
This is a naïve implementation that brings together knowledge of the number of elements with implicit ownership of the resource. Note that we don’t have to write the copy operations (unless we want to implement them!), the move operations, or the destructor, as they all implicitly do something reasonable. Also, this type will be relatively efficient if type `T` is trivially constructible but will (really) not be as efficient as `vector<T>` for numerous use cases. Why is that? Well, it so happens that `vector` does significantly better memory management than we do… but we’ll get there.
Note that, as with scalar types, the fact that `sizeof(unique_ptr<T[]>)` is equal to `sizeof(T*)` is also true, which I’m sure we can all appreciate.
Custom deleters
You might think, “*Well, in my code base, we don’t use* `delete` *to deallocate objects because [insert your favorite reason here], so I cannot use* `unique_ptr`.” There are indeed many situations where applying `operator delete` on a pointer to destroy the pointed-to object is not an option:
* Sometimes, `T::~T()` is `private` or `protected`, making it inaccessible to other classes such as `unique_ptr<T>`.
* Sometimes, the finalization semantics require doing something else than calling `delete` – for example, calling a `destroy()` or `release()` member function
* Sometimes, the expectation is to call a free function that will perform auxiliary work in addition to freeing a resource.
No matter what the reasons are for freeing a resource in an unconventional manner, `unique_ptr<T>` can take a `T*` stored within `unique_ptr<T>` when the destructor of that smart pointer is called. Indeed, the actual signature of the `unique_ptr` template is as follows:
template<class T, class D = std::default_delete
class unique_ptr {
// ...
};
Here, `default_delete<T>` itself is essentially the following:
template
constexpr default_delete() noexcept = default;
// ...
constexpr void operator()(T *p) const { delete p; }
};
The presence of a default type for `D` is what usually allows us to write code that ignores that parameter. The `D` parameter in the `unique_ptr<T,D>` signature is expected to be stateless, as it’s not stored within the `unique_ptr` object but instantiated as needed, and then it’s used as a function that takes the pointer and does whatever is required to finalize the *pointee*.
As such, imagine the following class with a `private` destructor, a common technique if you seek to prevent instantiation through other means than dynamic allocation (you cannot use an automatic or a static object of that type, since it cannot be implicitly destroyed):
include
class requires_dynamic_alloc {
~requires_dynamic_alloc() = default; // 私有
// ...
friend struct cleaner;
};
// ...
struct cleaner {
template
void operator()(T *p) const { delete p; }
};
int main() {
using namespace std;
// requires_dynamic_alloc r0; // 不行
//auto p0 = unique_ptr<requires_dynamic_alloc>{
// new requires_dynamic_alloc
//}; // 不行,因为默认删除器无法访问 delete
auto p1 = unique_ptr<requires_dynamic_alloc, cleaner>{
new requires_dynamic_alloc
}; // 好的,将使用 cleaner::operator() 来删除指针
}
Note that by making the `cleaner` functor its friend, the `requires_dynamic_alloc` class lets `cleaner` specifically access both its `protected` and `private` members, which includes access to its `private` destructor.
Imagine now that we are using an object through an interface that hides from client code information on whether we are the sole owner of the pointed-to resource, or whether we share that resource with others. Also, imagine that the potential sharing of that resource is done through intrusive means, as is done on many platforms, such that the way to signal that we are disconnecting from that resource is to call its `release()` member function, which will, in turn, either take into account that we have disconnected or free the resource if we were its last users. To simplify client code, our code base has a `release()` free function that calls the `release()` member function on such a pointer if it is non-null.
We can still use `unique_ptr` for this, but note the syntax, which is slightly different, as we will need to pass the function pointer as an argument to the constructor, since that pointer will be stored within. Thus, this specialization of `unique_ptr` with a function pointer as a *deleter* leads to a slight size increase:
include
struct releasable {
void release() {
// 为了本例简化过度
delete this;
}
protected:
~releasable() = default;
};
class important_resource : public releasable {
// ...
};
void release(releasable *p) {
if(p) p->release();
}
int main() {
using namespace std;
auto p = unique_ptr<important_resource,
void()(releasable)>{
new important_resource, release
}; // 好的,将使用 release() 来删除指针
}
If the extra cost of a function pointer’s size (plus alignment) in the size of `unique_ptr` is unacceptable (for example, because you are on a resource-constrained platform or because you have a container with many `unique_ptr` objects, which makes the costs increase significantly faster), there’s a neat trick you can play by pushing the runtime use of the `deleter` function into the wonderful world of the type system:
include
struct releasable {
void release() {
// 为了本例简化过度
delete this;
}
protected:
~releasable() = default;
};
class important_resource : public releasable {
// ...
};
void release(releasable *p) {
if(p) p->release();
}
int main() {
using namespace std;
auto p = unique_ptr<important_resource,
void()(releasable)>{
new important_resource, release
}; // 好的,将使用 release() 来删除指针
static_assert(sizeof(p) > sizeof(void*));
auto q = unique_ptr<
important_resource,
decltype([](auto p) { release(p); })}{
new important_resource
};
static_assert(sizeof(q) == sizeof(void*));
}
As you can see, in the case of `p`, we used a function pointer as a deleter, which requires storing the address of the function, whereas with `q`, we replaced the function pointer with the *type of a hypothetical lambda*, which will, when instantiated, call that function, passing the pointer as an argument. It’s simple and can save space if used judiciously!
make_unique
Since C++14, `unique_ptr<T>` has been accompanied by a factory function that perfectly forwards its arguments to a constructor of `T`, allocates and constructs the `T` as well as `unique_ptr<T>` to hold it, and returns the resulting object. That function is `std::make_unique<T>(args...)`, and a naïve implementation would be as follows:
template <class T, class ... Args>
std::unique_ptr
return std::unique_ptr
new T(std::forward
}
}
There are also variants to create a `T[]`, of course. You might wonder what the point of such a function is, and indeed, that function was not shipped along with `unique_ptr` initially (`unique_ptr` is a C++11 type), but consider the following (contrived) example:
template
class pair_with_alloc {
T *p0, *p1;
public:
- pair_with_alloc(const T &val0, const T &val1)
- p0{ new T(val0) }, p1{ new T(val1) } {
}
~pair_with_alloc() {
delete p1; delete p0;
}
// 复制和移动操作留给你的想象
};
We can suppose from this example that this class is used when, for some reason, client code prefers to dynamically allocate the `T` objects (in practice, using objects rather than pointers to objects makes your life simpler). Knowing that subobjects in a C++ object are constructed in order of declaration, we know that `p0` will be constructed before `p1`:
// ...
T *p0, *p1; // p0 在 p1 之前声明
public:
// 下面:
// - new T(val0) 将在 p0 构造之前发生
// - new T(val1) 将在 p1 构造之前发生
// - p0 的构造将在 p1 的构造之前发生
- pair_with_alloc(const T &val0, const T &val1)
- p0{ new T(val0) }, p1{ new T(val1) } {
}
// ...
However, suppose that the order of operations is `new T(val0)`, the construction of `p0`, `new T(val1)`, and the construction of `p1`. What happens then if `new T(val1)` throws an exception, either because `new` fails to allocate sufficient memory or because the constructor of `T` fails? You might be tempted to think that the destructor of `pair_with_alloc` will clean up, but that will not be the case – for a destructor to be called, the corresponding constructor must have completed first; otherwise, there is no object to destroy!
There are ways around this ,of course. One of them might be to use `unique_ptr<T>` instead of `T*`, which would be wonderful, given that this is what we’re currently discussing! Let’s rewrite `pair_with_alloc` that way:
include
template
class pair_with_alloc {
std::unique_ptr
public:
- pair_with_alloc(const T &val0, const T &val1)
- p0{ new T(val0) }, p1{ new T(val1) } {
}
// 析构函数隐式正确
// 复制和移动操作隐式工作
// 或留给你的想象
};
With this version, if the order of operations is `new T(val0)`, the construction of `p0`, `new T(val1)`, the construction of `p1`, then if `new T(val1)` throws an exception, the `pair_with_alloc` object will still not be destroyed (it has not been constructed). However, `p0` itself *has* been constructed by that point, and as such, it will be destroyed. Our code has suddenly become simpler and safer!
What then has that to do with `make_unique<T>()`? Well, there’s a hidden trap here. Let’s look closer at the order of operations in our constructor:
// ...
std::unique_ptr
public:
// 下面,假设我们按照以下方式识别操作:
// A: new T(val0)
// B: p0 的构造
// C: new T(val1)
// D: p1 的构造
// 我们知道:
// - A 在 B 之前
// - C 在 D 之前
// - B 在 D 之前
- pair_with_alloc(const T &val0, const T &val1)
- p0{ new T(val0) }, p1{ new T(val1) } {
}
// ...
If you look at the rules laid out in the comments, you will see that we could have the operations in the following order, A→B→C→D, but we could also have them ordered as A→C→B→D or C→A→B→D, in which case the two calls to `new T(...)` would occur, followed by the two `unique_ptr<T>` constructors. If this happens, then an exception thrown by the second call to `new` or the associated constructor of `T` would still lead to a resource leak.
Now, that’s a shame. But that’s also the point of `make_unique<T>()` – with a factory function, client code never finds itself with “floating results from calls to `new`”; it either has a complete `unique_ptr<T>` object or not:
include
template
class pair_with_alloc {
std::unique_ptr
public:
- pair_with_alloc(const T &val0, const T &val1)
- p0{ std::make_unique
(val0) },
p1{ std::make_unique
}
// 析构函数隐式正确
// 复制和移动操作隐式工作
// 或留给你的想象
};
include
include
include
class risky {
shared_ptr
std::uniform_int_distribution
public:
risky() = default;
if(full()) {
if(penny(prng)) throw 3; // throws 50% of the time
}
~risky() {
std::cout << “~risky()\n”;
}
};
std::begin(resources), std::end(resources),
pair_with_alloc a{ s0, s1 };
// an exception is thrown
std::weak_ptr
class Cache {
shared_ptr
pair_with_alloc b{ risky{}, risky{} };
} catch(...) {
std::cout << std::format(“*sh == {}\n”, *sh);
}
}
As you can see, `make_unique<T>()` is a security feature, mostly useful to avoid exposing ownerless resources in client code. As a bonus, `make_unique<T>()` allows us to limit how we repeat ourselves in source code. Check the following:
{
auto p1 = unique_ptr<some_type> { new some_type{ args } };
auto p2 = make_unique<some_type>(args);
As you can see, `p0` and `p1` require you to spell the name of the pointed-to type twice whereas `p2` only requires you to write it once. That’s always nice.
Types shared_ptr and weak_ptr
In most cases, `unique_ptr<T>` will be your smart pointer of choice. It’s small, fast, and does what most code requires. There are some specialized but important use cases where `unique_ptr<T>` is not what you need, and these have in common the following:
* The semantics being conveyed is the *shared ownership* of the resource
* The last owner of the resource is not known a priori (which mostly happens in concurrent code)
Note that if the execution is not concurrent, you will, in general, know who the last owner of the resource is – it’s the last object to observe the resource that will be destroyed in the program. This is an important point – you can have concurrent code that shares resources and still uses `unique_ptr` to manage the resource. Non-owning users of the resource, such as raw pointers, can access it without taking ownership (more on that later in this chapter), and this approach is sufficient.
You can, of course, have non-concurrent code where the last owner of a resource is not known a priori. An example might involve a protocol where the provider of the resource still holds on to it after returning it to the client, but they might be asked to release it at a later point while client code retains it, making the client the last owner from that point on, or they might never be asked to release it, in which case the provider might be the last owner of the resource. Such situations are highly specific, obviously, but they show that there might be reasons to use shared ownership semantics as expressed through `std::shared_ptr`, even in non-concurrent code.
Since concurrent code remains the posterchild for situations where the last owner of a shared resource is not known a priori, we will use this as a basis for our investigation. Remember this example from the beginning of this chapter:
std::cout << “Using resource “ << q->id() << ‘\n’;
void f() {
[](auto && a, auto && b) {
);
thread th1{ [p] { /* use *p */ };
// w points to an expired shared_ptr
// ...
t, std::shared_ptr
Here, `p` in `f()` does not own the `X` it points to, being a raw pointer, and both `th0` and `th1` copy that raw pointer, so neither is responsible for the pointee (at least on the basis of the rules enforced by the type system; you could envision acrobatics to make this work, but it’s involved, tricky, and bug-prone).
This example can be amended to have clear ownership semantics by shifting `p` from `X*` to `shared_ptr<X>`. Indeed, let’s consider the following:
thread th0{ [p] { /* use *p */ };
void f() {
std::shared_ptr
}
thread th1{ [p] { /* use *p */ };
th0.detach();
try {
}
In `f()`, the `p` object is initially the sole owner of the `X` it points to. When `p` is copied, as it is in the capture blocks of the lambdas executed by `th0` and `th1`, the mechanics of `shared_ptr` ensure that `p` and its two copies share both `X*` and an integral counter, used to determine how many shared owners there are for the resource.
The key functions of `shared_ptr` are its copy constructor (shares the resource and increments the counter), copy assignment (disconnects from the original resource, decrementing its counter, and then connects to the new resource, incrementing its counter), and the destructor (decrements the counter and destroys the resource if there’s no owner left). Each of these functions is subtle to implement; to help understand what the stakes are, we will provide simplified implementation examples in *Chapter 6*. Move semantics, unsurprisingly, implement transfer of ownership semantics for `shared_ptr`.
Note that `shared_ptr<T>` implements extrusive (non-intrusive) shared ownership semantics. Type `T` could be a fundamental type and does not need to implement a particular interface for this type to work. This differs from the intrusive shared semantics that were mentioned earlier in this chapter, with the `releasable` type an example.
Usefulness and costs
There are intrinsic costs to the `shared_ptr<T>` model. The most obvious one is that `sizeof(shared_ptr<T>)>sizeof(unique_ptr<T>)` for any type `T`, since `shared_ptr<T>` needs to handle both a pointer to the shared resource and a pointer to the shared counter.
Another cost is that copying a `shared_ptr<T>` is not a cheap operation. Remember that `shared_ptr<T>` makes sense mostly in concurrent code, where you do not know a priori the last owner of a resource. For that reason, the increments and decrements of the shared counter require synchronization, meaning that the counter is typically an `atomic` integer, and mutating an `atomic<int>` object (for example) costs more than mutating an `int`.
Another non-negligible cost is the following:
void observe(std::weak_ptr
An instruction such as this one will lead to *two* allocations, not one – there will be one for the `X` object and another one (performed internally by the `shared_ptr`) for the counter. Since these two allocations will be done separately, one by the client code and one by the constructor itself, the two allocated objects might find themselves in distinct cache lines, potentially leading to a loss of efficiency when accessing the `shared_ptr` object.
make_shared()
There is a way to alleviate the latter cost, and that is to make the same entity perform both allocations, instead of letting the client code do one and the constructor do the other. The standard tool to achieve this is the `std::make_shared<T>()` factory function.
Compare the following two instructions:
assert(p != std::end(resources));
auto q = make_shared
When constructing `p`, `shared_ptr<X>` is provided an existing `X*` to manage, so it has no choice but to perform a second, separate allocation for the shared counter. Conversely, the call expressed as `make_shared<X>(args)` specifies the type `X` to construct along with the arguments `args` to forward directly to the constructor. It falls upon that function to create `shared_ptr<X>`, `X`, and the shared counter, which lets us put both `X` and the counter in the same contiguous space (the **control block**), using mechanisms such as a *union* or the *placement new* mechanism, which will be explored in *Chapter 7*.
Clearly, given the same arguments used for construction, the preceding `p` and `q` will be equivalent `shared_ptr<X>` objects, but in general, `q` will perform better than `p`, as its two key components will be organized in a more cache-friendly manner.
What about weak_ptr?
If `shared_ptr<T>` is a type with a narrower (yet essential) niche than `unique_ptr<T>`, `weak_ptr<T>` occupies an even narrower (but still essential) niche. The role of `weak_ptr<T>` is to model the *temporary* ownership of `T`. Type `weak_ptr<T>` is meant to interact with `shared_ptr<T>` in a way that makes the continued existence of the *pointee* testable from client code.
A good example of `weak_ptr` usage, inspired by the excellent `cppreference` website ([`en.cppreference.com/w/cpp/memory/weak_ptr`](https://en.cppreference.com/w/cpp/memory/weak_ptr)), is as follows:
// inspired from a cppreference example
risky(const risky &) {
include
include
return p.second->id() == id;
X *p = new X;
th1.detach();
// ...
std::cout << “w is expired\n”;
}
int main() {
std::weak_ptr
}
auto sh = std::make_shared
thread th0{ [p] { /* use *p */ };
// w points to a live shared_ptr
unique_ptr<some_type> p0 { new some_type{ args } };
resources;
if(auto q = p.lock(); q)
observe(w);
}
As this example shows, you can make `weak_ptr<T>` from `shared_ptr<T>`, but `weak_ptr` does not own the resource until you call `lock()` on it, yielding `shared_ptr<T>`, from which you can safely use the resource after having verified that it does not model an empty pointer.
Another use case for `std::weak_ptr` and `std::shared_ptr` would be a cache of resources such that the following occurs:
* The data in a `Resource` object is sufficiently big or costly to duplicate that it’s preferable to share it than to copy it
* A `Cache` object shares the objects it stores, but it needs to invalidate them before replacing them when its capacity is reached
In such a situation, a `Cache` object could hold `std::shared_ptr<Resource>` objects but provide its client code, `std::weak_ptr<Resource>`, on demand, such that the `Resource` objects can be disposed of when the `Cache` needs to do so, but the client code needs to be able to verify that the objects it points to have not yet been invalidated.
A full (simplified) example would be the following (see the GitHub repository for this book to get the full example):
}
template
for(int i = 0; i != 5; ++i)
else
// a cache of capacity Cap that keeps the
// most recently used Resource objects
std::cerr << “Something was thrown...\n”;
decltype(clock::now()),
std::shared_ptr
cache.add(new Resource{ i + 1 });
bool full() const { return resources.size() == Cap; }
// precondition: !resources.empty()
void expunge_one() {
auto p = std::min_element(
return p->second; // make weak_ptr from shared_ptr
if(std::string s0, s1; std::cin >> s0 >> s1)
return a.first < b.first;
}
th1.detach();
}
p->second.reset(); // relinquish ownership
resources.erase(p);
using clock = std::chrono::system_clock;
public:
void add(Resource *p) {
const auto t = clock::now();
};
expunge_one();
);
resources.emplace_back(
id {
std::vector<std::pair<
}
}
const auto t = clock::now();
auto p = std::find_if(
std::begin(resources),
std::end(resources),
}
// the following objects do not leak even if
// ...
th0.detach();
if(p == std::end(resources))
if (std::shared_ptr
p->first = t;
include
);
std::mt19937 prng{ std::random_device{}() };
int main() {
Cache<5> cache;
return {};
cache.add(new Resource{ i + 1 });
// let’s take a pointer to resource 3
auto p = cache.obtain(3);
w = sh; // weak_ptr made from shared_ptr
observe(w);
// things happen, resources get added, used, etc.
for(int i = 6; i != 15; ++i)
int main() {
if(auto q = p.lock(); q)
std::cout << “使用资源 “ << q->id() << ‘\n’;
else
std::cout << “资源不可用 ...\n”;
}
After a sufficient number of additions to the cache, the object pointed to by `p` in `main()` becomes invalidated and erased from the set of resources, one of our requirements for this example (without that requirement, we could have simply used `std::shared_ptr` objects in this case). Yet, `main()` can test for the validity of the object pointed to by `p` through the construction of `std::shared_ptr` from the `std::weak_ptr` it holds.
In practice, `weak_ptr` is sometimes used to break cycles when `shared_ptr` objects refer to each other in some way. If you have two types whose objects mutually refer to one another (say, `X` and `Y`) and do not know which one will be destroyed first, then consider making one of them the owner (`shared_ptr`) and the other one the non-owner in a verifiable manner (`weak_ptr`), which will ensure that they will not keep each other alive forever. For example, this will conclude, but the `X` and `Y` destructors will never be called:
include
include
struct Y;
struct X {
std::shared_ptr
~X() { std::cout << “~X()\n”; }
};
struct Y {
std::shared_ptr
~Y() { std::cout << “~Y()\n”; }
};
void oops() {
auto x = std::make_shared
auto y = std::make_shared
x->p = y;
y->p = x;
}
int main() {
oops();
std::cout << “完成\n”;
}
If you change either `X::p` or `Y::p` to `weak_ptr`, you will see both the `X` and `Y` destructors being called:
include
include
struct Y;
struct X {
std::weak_ptr
~X() { std::cout << “~X()\n”; }
};
struct Y {
std::shared_ptr
~Y() { std::cout << “~Y()\n”; }
};
void oops() {
auto x = std::make_shared
auto y = std::make_shared
x->p = y;
y->p = x;
}
int main() {
oops();
std::cout << “完成\n”;
}
Of course, the easiest way not to get to the point where you face a cycle of `shared_ptr<T>` objects is to not build such a cycle, but when faced with external libraries and third-party tools, that’s sometimes easier said than done.
When to use raw pointers
We have seen that smart pointer types such as `unique_ptr<T>` and `shared_ptr<T>` shine when there is a need to describe ownership of a type `T` resource through the type system. Does that mean that `T*` has become useless?
No, of course not. The trick is to use it in controlled situations. The first is that for a function, being passed a `T*` as an argument should mean the function is *an observer, not an owner*, of that `T`. If your code base used raw pointers in that sense, you will most probably not run into trouble.
Secondly, you can use a raw pointer inside a class that implements your preferred ownership semantics. It’s fine to implement a container that manipulates objects through raw pointers (for example, a tree-like structure meant for various traversal orders), as long as that container implements clear copy and move semantics. What you don’t want to do is expose pointers to the internal nodes of your container to external code. Pay attention to the container’s interface.
Indeed, consider this single-linked list of (excerpt):
template
class single_linked_list {
struct node {
T value;
node *next = nullptr;
node(const T &val) : value { val } {
};
node *head = nullptr;
// ...
public:
// ...
~single_linked_list() {
for(auto p = head; p;) {
auto q = p->next;
delete p;
p = q;
}
}
};
We will explore this example in greater detail in *Chapter 13*. The destructor works fine and (supposing the rest of the class is reasonably well-written) the class is usable and useful. Now, suppose we decide to use `unique_ptr<node>` instead of `node*` as the `head` data member for `single_linked_list`, and as a replacement for the `next` member of the node. This seems like a good idea, except when you consider the consequences:
template
class single_linked_list {
struct node {
T value;
unique_ptr
node(const T &val) : value { val } {
};
unique_ptr
// ...
public:
// ...
~single_linked_list() = default;
};
This seems like a good idea on the surface, but it does not convey the proper semantics – it’s *not* true that a node *owns* and *is responsible for* the next node. We don’t want to make the removal of a node destroy the node that follows (and so on, recursively) and if that looks like a simplification in the destructor of `single_linked_list`, think about the consequences – this strategy leads to as many destructors recursively called as there are nodes in the list, which is a very good way to achieve a stack overflow!
Use a smart pointer when the use case matches the semantics it models. Of course, when the relationship modeled by your pointers is neither unique ownership nor shared ownership, you probably do not want smart pointer types that provide these semantics, resorting instead to either nonstandard and non-owning smart pointers or, simply, raw pointers.
Finally, you often need raw pointers to use lower-level interfaces – for example, when performing system calls. That does not disqualify higher-level abstractions, such as `vector<T>` or `unique_ptr<T>`, when writing system-level code – you can get access to the underlying array of `vector<T>` through its `data()` member function, just as you can get access to the underlying raw pointer of `unique_ptr<T>` through its `get()` member function. As long as it makes sense, see the called code as borrowing the pointer from the caller code for the duration of the call.
And if you have no other choice, use raw pointers. They exist, after all, and they work. Simply remember to use higher-level abstractions wherever possible – it will make your code simpler, safer, and (more often than you would think) faster. If you cannot define the higher-level semantics, maybe it’s still a bit early to write that part of the code, and you’ll get better results if you spend more time thinking about these semantics.
Summary
In this chapter, we saw how to use standard smart pointers. We discussed the ownership semantics they implement (sole ownership, shared co-ownership, and temporary co-ownership), saw examples of how they can be used, and discussed some ways in which they can be used while acknowledging that other, more appropriate options exist.
In the next chapter, we’ll take this a step further and write our own (usable, if naïve) versions of `unique_ptr<T>` and `shared_ptr<T>`, in order to get an intuitive grasp of what this entails, and we will write some nonstandard but useful smart pointers too. This will help us build a nicer, more interesting resource management toolset.
第六章:编写智能指针
在第五章中,我们考察了可用的标准智能指针,重点介绍了其中最重要的:unique_ptr<T>
和shared_ptr<T>
。这些类型是当代 C++程序员工具箱中的宝贵且重要的工具,在适当的时候使用它们可以使程序比大多数手写替代方案更小、更快、更简单。
本书旨在讨论如何在 C++程序中管理内存。因此,在本章中,我们将编写unique_ptr<T>
和shared_ptr<T>
的简单版本,以展示如果需要,可以如何编写这些类型的简单但可行的版本。我们强烈建议您在实际应用中使用标准版本,而不是本书中的版本(至少在生产代码中是这样):标准版本已经经过彻底测试、优化,并被众多程序员有效地使用。我们在这里编写“自制”版本的原因仅仅是为了培养对如何编写此类类型的直觉:仍然存在一些公司使用 C++11 之前的编译器,有时出于合理的原因,在某些环境中可能存在编写受标准智能指针启发的但略有不同的智能指针的理由。
我们将考察一些标准智能指针未涵盖的领域,这可能是因为它们被认为足够简单,用户可以自行实现,或者它们被认为足够专业,应该通过第三方库来实现,或者还没有明确的标准化路径。
总结来说,在本章中,我们将做以下几件事:
-
简要了解所有权语义,包括标准智能指针以及我们可能——有时会——自己实现的那些。
-
为了掌握可能涉及的一些技术,我们将实现我们自己的简单但可用的
std::unique_ptr
版本。 -
实现我们自己的简单但可用的
std::shared_ptr
版本。请注意,在这里所说的“可用”是指在简单环境中可用,因为std::shared_ptr
的完整实现比本书可以合理涵盖的要复杂得多。 -
实现一个具有单一所有权和复制语义的非标准智能指针,展示实现此目标的不同技术。
-
实现两个不同的非所有权的“智能”指针,这些指针类型非常轻量级,但有助于编写更好、更安全的代码。
在阅读本章之后,我们应该对涉及编写在语法上表现为指针但提供(或仅澄清)所有权的类型的技术有更好的掌握。所使用的技术应该大部分可以用于其他类型的问题,无论是与内存管理相关还是不相关的问题。
这个计划听起来怎么样?那么,让我们开始吧!
技术要求
你可以在这个章节的 GitHub 仓库中找到这个章节的代码文件:github.com/PacktPublishing/C-Plus-Plus-Memory-Management/tree/main/chapter6
。
所有权语义
智能指针全在于明确间接访问资源的所有权。如果我们限制自己使用标准设施,无论是智能的还是非智能的,我们拥有的就是以下内容:
类型 | 领域 |
---|---|
unique_ptr<T> |
所有权语义:单一所有权。显著的特有成员函数:不可复制。析构函数负责销毁指针所指向的对象。 |
shared_ptr<T> |
所有权语义:共享所有权。显著的特有成员函数:复制、赋值和销毁更新共享使用计数。最后一个共同所有者的析构函数负责销毁指针所指向的对象和使用计数。 |
T* |
所有权语义:在类型系统中未定义所有权(所有权规则必须写入用户代码)。显著的特有成员函数:不适用(这是一个基本类型)。 |
表 6.1 – 按指针类型划分的使用类别
考虑到所有这些,这是一个小型的动物园。为了填充这个表格,我们还能设想出哪些其他类型的语义呢?好吧,以下是一些可能的:
-
一种
observer_ptr<T>
类型,它表现得像T*
但使得通过如对指针应用delete
这样的操作意外声明所有权变得更加困难(确实会发生意外) -
一种
non_null_ptr<T>
类型,它表现得像T*
但不会出现null
指针,从而简化了客户端代码 -
一种
remote_ptr<T>
类型,它像一个代理来处理远程指针 -
一种
dup_ptr<T>
类型,它实现了与unique_ptr<T>
相同的单一所有权,但它可复制,并且在复制dup_ptr<T>
时复制指针所指向的对象,依此类推
我们不会实现所有这些(特别是 remote_ptr<T>
的情况,尽管它很有趣,但超出了本书的范围,还有许多其他异类语义我们可以考虑,你可以根据本章中找到的想法来实现它们),但我们将会编写一些。每个案例的重要方面是明确定义预期的语义,确保它们没有被现有类型覆盖,并确保我们适当地实现它们。
让我们从最简单实现开始,这可能是最著名的标准智能指针:unique_ptr
。
编写你自己的(天真)unique_ptr
我们首先尝试一个简单、自制的版本 of std::unique_ptr<T>
。正如本章开头所提到的,我们的目标是培养编写此类类型所需代码的直觉,而不是鼓励你尝试替换标准设施:它们存在,它们工作,它们经过测试,使用它们。哦,而且它们使用了许多我们无法在本书中探索的酷技巧,因为我们想控制本书的大小!
类型签名
如第五章中所述,unique_ptr<T>
实际上并不存在,因为该类型实际上是unique_ptr<T,D>
,其中D
默认为default_deleter<T>
。
我们将涵盖unique_ptr
的两种形式(标量和数组)。这两个特殊化的原因在于,对于T[]
,我们希望unique_ptr
暴露operator[]
,但我们不希望为标量T
类型暴露这一点。
让我们从我们将提供的基删除器类型开始。请注意,如果需要,用户可以提供其他删除器类型,只要它们使用相同的operator()
签名:
namespace managing_memory_book {
// basic deleter types
template <class T>
struct deleter_pointer_wrapper {
void (*pf)(T*);
deleter_pointer_wrapper(void (*pf)(T*)) : pf{ pf } {
}
void operator()(T* p) const { pf(p); }
};
template <class T>
struct default_deleter {
void operator()(T* p) const { delete p; }
};
template <class T>
struct default_deleter<T[]> {
void operator()(T* p) const { delete[] p; }
};
// ...
}
到目前为止,我们有三种可调用的删除器类型,它们都是类类型(原因很快就会变得明显,但要知道有时统一性是有价值的)。其中一个是deleter_pointer_wrapper<T>
,它封装了一个可复制的状态(一个函数指针),但除此之外,它就像其他两个一样:当在T*
上调用时,它将对那个指针应用一些(用户提供的)函数。
下一步将是选择unique_ptr<T,D>
的形式。我们预计大多数删除器将是无状态的,并使用deleter_pointer_wrapper<T>
。为了在这两种选项之间进行选择,我们需要检测D
是否是一个函数指针,我们将通过我们自己的is_deleter_function_candidate<T>
特性来实现这一点。
我们实现中检测删除器函数候选者的部分如下:
#include <type_traits>
namespace managing_memory_book {
// ...
template <class T>
struct is_deleter_function_candidate
: std::false_type {};
template <class T>
struct is_deleter_function_candidate<void (*)(T*)>
: std::true_type {};
template <class T>
constexpr auto is_deleter_function_candidate_v =
is_deleter_function_candidate<T>::value;
// ...
}
这部分可能很直观,但想法是,大多数类型都不是删除器函数的候选者,但void(*)(T*)
类型的函数是。
然后,我们到达了一般的unique_ptr<T>
类型,用于标量。我们将使用我们的删除器函数检测特性来有条件地选择D
类型,并将deleter_pointer_wrapper<T>
作为我们类型的基类,并将它转换为指向该基类的指针,以便在我们的析构函数中释放资源:
namespace managing_memory_book {
// ...
// unique_ptr general template
template <class T, class D = default_deleter<T>>
class unique_ptr : std::conditional_t <
is_deleter_function_candidate_v<D>,
deleter_pointer_wrapper<T>, D
> {
using deleter_type = std::conditional_t <
is_deleter_function_candidate_v<D>,
deleter_pointer_wrapper<T>,
D
>;
T* p = nullptr;
public:
unique_ptr() = default;
unique_ptr(T* p) : p{ p } {
}
unique_ptr(T* p, void (*pf)(T*))
: deleter_type{ pf }, p{ p } {
}
~unique_ptr() {
(*static_cast<deleter_type*>(this))(p);
}
};
// ...
}
对于我们类型的T[]
特殊化,基本上采取相同的方法:
namespace managing_memory_book {
// ...
// unique_ptr specialization for arrays
template <class T, class D>
class unique_ptr<T[], D> : std::conditional_t <
is_deleter_function_candidate_v<D>,
deleter_pointer_wrapper<T>,
D
> {
using deleter_type = std::conditional_t <
is_deleter_function_candidate_v<D>,
deleter_pointer_wrapper<T>,
D
>;
T* p = nullptr;
public:
unique_ptr() = default;
unique_ptr(T* p) : p{ p } {
}
unique_ptr(T* p, void (*pf)(T*))
: deleter_type{ pf }, p{ p } {
}
~unique_ptr() {
(*static_cast<deleter_type*>(this))(p);
}
};
}
注意到默认的unique_ptr
在概念上会像null
指针一样,这对大多数人来说应该不会感到意外。现在我们已经有了基本的概念,让我们探索unique_ptr
特有的语义。
特殊成员函数
特殊成员函数的代码对于unique_ptr
的标量和数组形式将是相同的。我们已经在上一节中看到了析构函数和默认构造函数,所以让我们看看其他四个,成对来看:
-
我们希望类型是不可复制的,因为它代表了指针的唯一所有权(如果它是可复制的,指针的所有权属于原始的还是复制的?)
-
我们希望移动操作实现所有权的转移
通用情况和其数组特殊化的代码如下(请注意,代码使用了std::exchange()
和std::swap()
,这两个都包含在<utility>
头文件中):
// ...
unique_ptr(const unique_ptr&) = delete;
unique_ptr& operator=(const unique_ptr&) = delete;
void swap(unique_ptr &other) noexcept {
using std::swap;
swap(p, other.p);
}
unique_ptr(unique_ptr &&other) noexcept
: p{ std::exchange(other.p, nullptr) } {
}
unique_ptr& operator=(unique_ptr &&other) noexcept {
unique_ptr{ std::move(other) }.swap(*this);
return *this;
}
// ...
到目前为止,大部分内容应该是显而易见的。你可能会注意到 std::exchange()
的使用,它将 other.p
复制到 this->p
,然后将 nullptr
复制到 other.p
,实现了预期的所有权转移。请注意,我们类型的移动操作既简单又不会抛出异常,这两者都是非常理想化的属性。
有些操作将在一般情况和数组情况下都实现,即 operator bool
(只有当对象不表示 null
指针时才为 true
),empty()
(只有当对象表示 null
指针时才为 true
),以及 operator==()
和 operator!=()
。这些实现基本上是微不足道的。我们还想公开的另一个成员函数是 get()
,包括其 const
和非 const
版本,以便于需要与底层函数(如系统调用)交互的客户端代码暴露底层指针:
// ...
bool empty() const noexcept { return !p; }
operator bool() const noexcept { return !empty(); }
bool operator==(const unique_ptr &other)
const noexcept {
return p == other.p;
}
// inferred from operator==() since C++20
bool operator!=(const unique_ptr &other)
const noexcept {
return !(*this == other);
}
T *get() noexcept { return p; }
const T *get() const noexcept { return p; }
// ...
如前述代码注释中所述,从 C++20 开始,只要 operator==()
提供了预期的签名,就不需要显式实现 operator!=()
。编译器将简单地从 operator==()
合成 operator!=()
。
现在,让我们看看 operator*()
、operator->()
和 operator[]()
这些类似指针的函数是如何实现的。
类似指针的函数
类似指针的函数在标量情况和数组情况中是不同的。对于指向标量的,我们希望实现 operator*()
和 operator->()
:
// ...
T& operator*() noexcept { return *p; }
const T& operator*() const noexcept { return *p; }
T* operator->() noexcept { return p; }
const T* operator->() const noexcept { return p; }
// ...
operator->()
成员函数是一个奇怪的生物:当用于对象时,它将在返回的对象上重新调用(并在返回的对象上再次调用,依此类推),直到返回一个原始指针,此时编译器将知道如何处理。这是一个非常强大的机制。
对于指向数组的(即 unique_ptr<T[]>
特化),我们希望实现 operator[]
,这将比 operator*()
或 operator->()
更有意义:
// ...
T& operator[](std::size_t n) noexcept {
return p[n];
}
const T& operator[](std::size_t n) const noexcept {
return p[n];
}
// ...
你可能会注意到这些成员函数的明显重复,因为每个函数都以 const
和非 const
的形式暴露出来,这种“趋势”是由稍早前的 get()
成员函数开始的。这是一种 语法 上的相似性,但它们在 语义 上是不同的:特别是,只有 const
形式可以通过 const
unique_ptr<T>
对象访问。
如果你有一个 C++23 编译器,你可以让它根据正确编写的模板成员函数集合成你在实践中使用的形式:
// the following is for both the array and non-array cases
template <class U>
decltype(auto) get(this U && self) noexcept {
return self.p;
}
// the following two are only for the non-array case
template <class U>
decltype(auto) operator*(this U && self) noexcept {
return *(self.p);
}
template <class U>
decltype(auto) operator->(this U && self) noexcept {
return self.p;
}
// the following is only for the array case
template <class U>
decltype(auto) operator[](this U && self,
std::size_t n) noexcept {
return self.p[n];
}
这将我们需要编写的成员函数数量减少了一半。这是怎么做到的?嗯,C++23 引入了“推导的 this
”机制,允许显式地将成员函数的第一个参数标记为 this
关键字。这样做并结合前向引用(U&&
类型)可以让编译器推导出 this
的 const
-性(或缺乏 const
-性),实际上是在一个函数中表达 const
和非 const
两个版本。注意这些函数伴随的 decltype(auto)
返回类型,它们推断出 return
语句。
就这样!我们现在有一个简单但功能齐全的 unique_ptr<T>
实现,适用于大多数用例。
当然,unique_ptr<T>
虽然很好,但并不是万能的,在实际程序中还有其他需要解决的问题。让我们继续探讨 shared_ptr<T>
的简化实现,看看我们如何实现共享所有权的语义。
使用我们自制的 unique_ptr<T>
和默认删除器的一个简单程序如下:
// ... (our own unique_ptr<T> goes here...)
struct X {};
int main() {
unique_ptr<X> p{ new X };
} // X::~X() called here
使用自定义删除器的一个程序如下:
// ... (our own unique_ptr<T> goes here...)
class X {
~X(){}
public:
static void destroy(X *p) { delete p; }
};
int main() {
unique_ptr<X, &X::destroy> p{ new X };
} // X::destroy(p.get()) called here
编写自己的(原始)shared_ptr
shared_ptr<T>
类型实现起来是一个难题,优化起来更是难上加难。在这种情况下,使用现有智能指针的标准版本的邀请比 unique_ptr<T>
更加强烈:这种类型很难做到正确,而标准版本则受益于多年的经验和测试。在本节中,仅为了实验目的使用原始版本(它适用于简单情况,但编写一个工业级实现是职业级的工作)。
编写 shared_ptr
的主要困难在于它是一个具有两个职责的类型:它共同拥有被指点和使用计数器,需要一定的谨慎,尤其是在异常安全性方面。经典面向对象编程的单职责原则是一个正确的原则:具有单一职责的类型比具有两个或更多职责的类型更容易做到正确。
为了使我们的论点简单,我们将避免标准 shared_ptr
协议的许多细节,仅限于管理一个标量 T
。让我们一步一步地来看这个类型:
#include <atomic>
#include <utility>
namespace managing_memory_book {
// naïve shared_ptr
template <class T>
class shared_ptr {
T* p = nullptr;
std::atomic<long long> *ctr = nullptr;
// ...
如前所述,shared_ptr<T>
负责管理 T*
和指向客户端计数器的指针,这两者都需要被管理并在共同所有者之间共享。请注意,我们的共享计数器是一个指向原子整数的指针,因为 shared_ptr<T>
在多线程情况下特别相关,在这些情况下,不知道哪个线程将是对象的最后一个用户。因此,像增加和减少计数器这样的操作需要同步,以避免发生 数据竞争。
避免数据竞争
如果一个程序遇到一个给定对象(a)被至少两个线程并发访问,(b)至少有一个访问是写操作,并且(c)没有同步,那么这个程序就出现了我们所说的数据竞争,我们基本上失去了从源代码中推理它的能力。这是一个非常糟糕的情况。
在我们的情况下,对共享计数器的操作很可能会并发进行,因此它们必须进行同步。这就解释了我们使用低级同步对象作为计数器的原子整数。
构造一个shared_ptr<T>
对象可能很棘手:
-
默认情况下,我们将定义
shared_ptr<T>
为空,从概念上等同于一个null
指针。 -
shared_ptr<T>
的构造函数接受T*
作为参数,代表对所指向对象的所有权获取行为。因此,如果在分配计数器时抛出异常,那么这个指针将被销毁。 -
复制构造函数将代表对所指向对象的共享所有权,并确保考虑到源对象可能表示一个
null
指针的情况。 -
移动构造函数模拟了所有权转移。正如移动操作通常那样,它非常快,并且表现出高度可预测的行为。
如以下代码片段所示,对于具有多个职责的类型,即使是构造也是一个微妙的过程。在接收T*
参数的构造函数中,我们可能需要分配共享计数器,这可能会抛出异常,这种情况我们需要处理。在复制构造函数中,我们需要考虑到参数可能表示一个空的shared_ptr<T>
,在这种情况下,共享计数器将是null
:
// ...
public:
shared_ptr() = default;
shared_ptr(T* p) : p{ p } {
if(p) try {
ctr = new std::atomic<long long>{ 1LL };
} catch(...) {
delete p;
throw;
}
}
shared_ptr(const shared_ptr &other)
: p{ other.p }, ctr{ other.ctr } {
if(ctr) ++(*ctr);
}
shared_ptr(shared_ptr &&other) noexcept
: p{ std::exchange(other.p, nullptr) },
ctr{ std::exchange(other.ctr, nullptr) } {
}
bool empty() const noexcept { return !p; }
operator bool() const noexcept { return !empty(); }
// ...
empty()
和operator bool()
成员函数已被包含在该片段中,因为这些函数直接与默认构造函数(此类型的空状态)的表达方式相关联。
赋值运算符并不令人意外:复制赋值模拟了释放当前持有的资源控制权并共享其参数的资源的行为,而移动赋值模拟了释放当前持有的资源控制权并将参数持有的资源控制权转移到被赋值对象的行为:
// ...
void swap(shared_ptr &other) noexcept {
using std::swap;
swap(p, other.p);
swap(ctr, other.ctr);
}
shared_ptr& operator=(const shared_ptr &other) {
shared_ptr{ other }.swap(*this);
return *this;
}
shared_ptr& operator=(shared_ptr &&other) noexcept {
shared_ptr{ std::move(other) }.swap(*this);
return *this;
}
// ...
销毁可能是此类中最棘手的部分。我们想要确保最后一个所有者销毁它,以避免不朽对象。关键点是shared_ptr<T>
只有在它是该对象的最后一个用户时才应该销毁指向的T
对象。
至少有两种“显而易见”的简单算法是无效的。一个是如果ctr
不为空,那么如果*ctr==1
,则删除p
和删除ctr
**。这个算法允许两个线程在*ctr==2
时同时进入析构函数。在这种情况下,可能两个线程都没有看到*ctr==1
,并且指针永远不会被销毁:
图 6.1 – 导致对象永生的竞态条件
另一个是 如果 ctr
不为空,则递减 *ctr
。如果 *ctr==0
*,删除 p 和删除 ctr。此算法允许两个线程在 *ctr==2
时同时进入析构函数,然后两个线程同时递减 *ctr
,导致两个线程都可能看到 *ctr==0
,从而可能导致指针的重复删除:
图 6.2 – 对象重复删除的竞态条件
这两种情况都不好,尽管原因不同,所以我们需要做得更好。过程的难点在于确保执行线程可以知道它是使 *ctr
成为零的那个线程。解决此类问题的通用方法需要将两个步骤(仅在变量具有事先已知的值时更改其值,并被告知此写入是否发生)封装在单个操作中,这至少需要一个多核机器上的硬件操作支持。
C++ 通过原子操作提供了对这些基本硬件操作的抽象。其中一个原子操作被命名为 compare_exchange_weak()
,它接受 expected
值(被认为是变量中的值)和 desired
值(希望写入该变量的值,但只有当它保持 expected
时),并且只有在实际发生写入时才返回 true
。为了方便,expected
通过引用传递,并使用当时对象实际持有的值进行更新,因为此函数通常在循环中调用,直到成功写入 desired
实际发生,这涉及到每次重新读取 expected
以更新函数对变量当前状态的看法。
与图片共舞
这种 expected
和 desired
的舞蹈可以看作是拍照。一个线程想要递减 *ctr
,但 *ctr
保持可变状态且被并发访问,这意味着其值可以随时改变。因此,我们在我们控制的局部变量中拍下照片(expected
)。我们基于我们想要写入的值(desired
)基于我们知道的那个局部照片,我们知道它没有改变。然后,我们尝试根据这个(可能过时的)知识采取行动,看看我们的假设(*ctr
保持 expected
)是否成立。这让我们知道,是我们将 desired
写入 *ctr
的。
基于这个知识,析构函数的一个可能的实现如下:
// ...
~shared_ptr() {
if(ctr) {
auto expected = ctr->load();
auto desired = expected - 1;
while(ctr->compare_exchange_weak(expected,
desired))
desired = expected - 1;
if(desired == 0) { // I was the last user of *p
delete p;
delete ctr;
}
}
}
// ...
在循环之后,我们知道我们在 *ctr
保持 expected
时写入了 desired
,因此如果 desired
是 0
(意味着 expected
是 1
),我们知道我们是最后一个使用那个指针的用户。是的,这很微妙。而且这只是一个 shared_ptr<T>
的玩具版本。我们可以以许多方式对其进行优化,但这超出了本书的范围。
一个更简单的解决方案
这里展示的 compare_exchange_weak()
解决方案是我们可用的许多选项之一。它被选为这本书的原因是,它为并发更新问题提供了一个有趣的通用解决方案,并且如果你对内存顺序约束感到舒适(我们在这里不会深入讨论),它还提供了优化机会。在这个特定的情况下,我们可以用类似 if((*ctr)-- == 1)
的东西替换循环,就像原子地递减 *ctr
并且之前持有的值是 1
一样,那么我们可以确信 *ctr
现在是 0
。
我们 shared_ptr<T>
实现的另一个重要成员函数涉及比较(operator==
和 operator!=
),允许获取底层原始 T*
的 get()
成员函数,以及 operator*()
和 operator->()
这样的间接操作符:
// ...
bool operator==(const shared_ptr &other)
const noexcept { return p == other.p; }
// inferred from operator==() since C++20
bool operator!=(const shared_ptr &other)
const noexcept { return !(*this == other); }
T *get() noexcept { return p; }
const T *get() const noexcept { return p; }
T& operator*() noexcept { return *p; }
const T& operator*() const noexcept { return *p; }
T* operator->() noexcept { return p; }
const T* operator->() const noexcept { return p; }
};
}
如果你愿意,可以自由地应用前面在 unique_ptr
部分展示的 “推导 this
” C++23 功能来简化这段代码。还请记住,在 C++20 中,operator!=()
将从 operator==()
推导出来,并且不需要在源代码中显式编写。
这个智能指针的客户代码的一个非常简单的例子如下:
#include <thread>
#include <chrono>
#include <random>
#include <iostream>
using namespace std::literals;
struct X {
int n;
X(int n) : n{ n } {}
~X() { std::cout << "X::~X()\n"; }
};
int main() {
using managing_memory_book::shared_ptr;
std::mt19937 prng{ std::random_device{}() };
std::uniform_int_distribution<int> die{ 200, 300 };
shared_ptr<X> p{ new X{ 3 } };
using std::chrono::milliseconds; // shortcut
std::thread th0{ [p, dt = die(prng)] {
std::this_thread::sleep_for(milliseconds{dt});
std::cout << "end of th0, p->n : " << p->n << '\n';
} };
std::thread th1{ [p, dt = die(prng)] {
std::this_thread::sleep_for(milliseconds{dt});
std::cout << "end of th1, p->n : " << p->n << '\n';
} };
th1.detach();
th0.detach();
std::this_thread::sleep_for(350ms);
std::cout << "end main()\n";
}
在这个例子中,th0
和 th1
都会暂停一个伪随机的毫秒数,然后显示一些内容并结束执行,因此我们无法预先知道 th0
和 th1
中哪一个会先结束;两个线程都是分离的,这意味着我们不会在它们上面调用 join()
,因此我们不能假设 main()
是最后一个使用共享资源的用户。
例子是为了保持简单而设计的,并且需要重复的是,由于 shared_ptr<T>
的使用成本显著高于 unique_ptr<T>
,当资源有一个明确的最后一个所有者时,人们通常会优先选择后者。
关于 make_shared()
的一些话
在阅读关于 C++ 以及特别是 shared_ptr<T>
的内容时,你可能会读到,在可能的情况下,推荐的做法是替换以下内容:
std::shared_ptr<X> p{ new X { /* ... args ... */ };
用以下内容替换它:
auto p= std::make_shared<X>( /* ... args ... */ );
如果是这样的话,你可能想知道(a)为什么这是推荐的做法,以及(b)为什么我们还没有解决这个问题。对于(a)的答案我们现在可以提供,但对于(b)的答案是我们需要等待直到我们达到 第七章 才有实现这种功能所需的工具和知识。
要理解为什么我们推荐优先使用 make_shared<T>()
工厂函数而不是直接调用 shared_ptr<T>
构造函数,关键思想是,使用 shared_ptr<T>
构造函数时,T
对象是由客户端代码分配的,并交给正在构建的 shared_ptr<T>
,它接管这个指针并单独分配一个共享计数器。然后我们最终会有两个分配(T
对象和计数器),可能位于不同的缓存行上。
现在,如果我们来看一下 make_shared<T>()
,这个工厂函数负责分配 T
对象和计数器,并将函数接收到的参数完美转发给 T
构造函数。由于同一个函数执行了两次分配,它可以将它们融合在一个包含 T
对象和计数器的内存块的单次分配中,将它们都放在相同的缓存行上。如果在短时间内单个线程倾向于从两个指针(T*
和计数器)中读取,这可能会提高性能特性,但(有时可能会发生)如果另一个线程观察到计数器值的频繁变化,可能会造成伤害。正如在优化相关情况下经常发生的那样,测量并确保在一般情况下效果良好的方法也适用于你自己的特定用例。
显然,为了实现这种优化,我们需要能够创建这样的块(概念上,一个包含 T
和一个原子整数的结构体)并确保 shared_ptr<T>
可以包含这两种表示(两个单独的指针或指向包含两个对象的块的指针),同时保持可用性和效率。在到达那里时,控制使用在 第二章 和 第三章 中看到的技巧将是有帮助的。
编写基于策略的复制指针
让我们暂时抛开标准智能指针。假设我们想要编写一种智能指针类型,其语义既不符合 std::unique_ptr<T>
的单一所有者模式,也不符合 std::shared_ptr<T>
的共享所有者模式。为了这个例子,更具体地说,我们想要单一所有者语义,但与 std::unique_ptr<T>
不同,它是可移动的但不可复制的,我们希望指针的复制导致指向的对象的复制。我们能做什么?
好吧,这是 C++,所以我们当然可以自己编写。让我们称我们自己的这种新的智能指针类型为 dup_ptr<T>
(表示“复制指针”,或“复制指向的对象的指针”)。由于我们在本章前面已经探讨了如何通过我们自制的 unique_ptr<T>
实现单一所有者,本节将主要关注复制指向对象的问题。
我们所说的复制是什么意思?嗯,有两种预期情况:复制一个非多态类型的对象和一个多态类型的对象,其中多态在这里的意思是“至少有一个 virtual
成员函数”。当然,程序员是极具创造力的生物,知道有人最终会遇到更奇特的情况,所以我们将尽力处理上述“预期情况”,并为那些有特殊应用的人留下一条门路。
为什么多态类型和非多态类型之间有差异?考虑以下程序:
struct X { int n; };
struct B {
int n;
B(int n) : n{ n } {}
virtual ~B() = default;
};
struct D0 : B {
D0(int n) : B{ n } { /* ... */ }
// ...
};
struct D1 : B {
D1(int n) : B{ n } { /* ... */ }
// ...
};
// precondition: p != nullptr (to keep things simple)
X* duplicate(X *p) {
return new X{ *p }; // Ok
}
// precondition: p != nullptr (to keep things simple)
B* duplicate(B *p) {
return new B{ *p }; // Bad idea!
}
#include <memory>
int main() {
using std::unique_ptr;
X x{ 3 };
unique_ptr<X> px { duplicate(&x) };
D0 d0{ 4 };
unique_ptr<B> pb{ duplicate(&d0) }; // trouble ahead
}
我们可以假设duplicate(X*)
函数可以安全地创建一个X
类型的对象,因为X
没有virtual
成员函数,因此很可能不是作为公共基类而设计的。然而,duplicate(B*)
通过调用B
的构造函数做错事的可能性很高,因为作为参数传递的B*
可能是B
或指向任何从B
派生出的类(这里,D0*
)的对象的指针。因此,调用new B{ *p };
仅构建基部分,切除了指向的对象的任何状态,导致程序可能不正确。
在面向对象编程领域,众所周知,复制多态类型对象的常用方法是主观复制,也称为virtual
成员函数,唯一真正能声称知道指针所指类型的是…指针所指的对象本身。
那么dup_ptr<T>
将要做什么呢?它将根据T
的特性选择一个复制策略:默认情况下,如果T
是多态的,我们将通过克隆来复制;否则,我们将通过复制来复制。当然,如果需要,我们将允许客户端代码指定一个自定义的复制机制。
我们将探讨三种选择默认复制策略的方法:基于接口的侵入式方法,基于特性和使用 C++17 特性的编译时检测克隆成员函数的非侵入式方法,以及另一种基于 C++20 概念的非侵入式方法。
通过接口进行检测
用户代码中可以做的事情之一是要求可克隆类型实现一个特定的接口,如下例所示:
struct cloneable {
virtual cloneable * clone() const = 0;
virtual ~cloneable() = default;
};
这样的解决方案可能不值得标准化:它是侵入式的,增加了一些开销(我们假设可复制的类型将是多态类型,这是可能的但不是强制的),等等。当然,它可以成为你自己的代码库的解决方案。将这个想法应用于之前错误处理多态类型复制的示例重访,我们得到以下结果:
// ... type cloneable
struct X { int n; };
struct B : cloneable { // every B is cloneable
int n;
B(int n) : n{ n } {}
virtual ~B() = default;
B * clone()
protected: // cloneable types are meaningfully copied
// in a subjective manner
B(const B&) = default;
};
struct D0 : B {
D0(int n) : B{ n } { /* ... */ }
D0* clone() const override { return new D0{ *this }; }
// ...
};
struct D1 : B {
D1(int n) : B{ n } { /* ... */ }
D1* clone() const override { return new D1{ *this }; }
// ...
};
现在,假设我们想要开发一个dup_ptr<T>
的骨架,它复制非cloneable
派生类型,克隆cloneable
类型。为此,我们可以使用std::conditional
类型特性和在两个函数对象类型之间进行选择,一个Copier
类型用于复制,一个Cloner
类型用于克隆:
// ... type cloneable
struct Copier {
template <class T> T* operator()(const T *p) const {
return new T{ *p };
}
};
struct Cloner {
template <class T> T* operator()(const T *p) const {
return p->clone();
}
};
#include <type_traits>
template <class T,
class Dup = std::conditional_t<
std::is_base_of_v<cloneable, T>,
Cloner, Copier
>>
class dup_ptr {
T *p{};
// use an object of type Dup when duplication is
// required: copy constructor and copy assignment
// ...
public:
dup_ptr(const dup_ptr &other)
: p{ other.empty()? nullptr : Dup{}(other.p) } {
}
// ...
};
这个实现假设了一个无状态的(没有成员变量)Dup
类型,这是高度可能的,但在实践中应该进行文档记录(如果我们接受有状态的Dup
类型,我们需要实例化一个Dup
对象并编写复制和移动该对象的代码,这将导致一个更复杂的实现)。使用这个实现,任何从cloneable
派生的类型都将被克隆,其他类型将被复制,除非用户代码提供了Dup
类型的异构实现。
通过特性进行检测
如果我们不希望对我们的 cloneable
类型施加基类,我们可以使用类型特质来检测存在一个 const
修饰的 clone()
成员函数,并假设这是一个合理的声明,即克隆比复制更好。请注意,这种非侵入性假设了一个不言自明的关于 clone()
意义的协议。
我们可以通过许多方式实现这一点,但最干净、最通用的方法可能是使用自 C++17 以来在 <type_traits>
中找到的沃尔特·布朗博士的 std::void_t
类型:
// types Cloner and Copier (see above)
template <class, class = void>
struct has_clone : std::false_type { };
template <class T>
struct has_clone <T, std::void_t<
decltype(std::declval<const T*>()->clone())
>> : std::true_type { };
template <class T>
constexpr bool has_clone_v = has_clone<T>::value;
template <class T, class Dup = std::conditional_t<
has_clone_v<T>, Cloner, Copier
>> class dup_ptr {
T *p{};
public:
// ...
dup_ptr(const dup_ptr &other)
: p{ other.empty()? nullptr : Dup{}(other.p) } {
}
// ...
};
std::void_t
类型是一项杰出的工作,它允许有知识的人以有限的方式模拟 C++20 以来 requires
允许的通用表达式。阅读这个示例的方法如下:
-
通常,
has_clone<T>::value
是false
-
对于任何
T
类型,对于某些const T*
对象p
的p->clone()
,has_clone<T>::value
是true
一旦选择了 Dup
类型,正常操作继续。这种实现相对于之前的一个优点是,它检查是否存在一个适当编写的 clone()
成员函数,而之前的实现检查是否存在特定的基类。实现一个函数比从特定的基类派生更轻量级的合同。
关于 std::void_t
的一些话
std::void_t
类型是一项杰出的工作。使用它依赖于 has_clone<T>
对于大多数类型是 false
,但当表达式 p->clone()
对于某些 const T*
对象 p
是有效的时候是 true
。即使在概念真正发挥其作用之前,我们也能轻松地测试任何表达式的有效性,这真是太美了,我们非常感谢沃尔特·布朗博士(Dr. Walter Brown)为我们带来了这个宝石(以及其他许多宝石)。
通过概念进行检测
自从 C++20 以来,像 std::void_t
这样的技巧不如以前有用,因为概念现在是语言类型系统的一部分。通过概念,我们可以定义一个 cloneable
类型 T
,使其在 const T*
上调用 clone()
是良好形成的,并且得到可以转换为 T*
的结果。
因此,我们有以下内容:
template <class T>
concept cloneable = requires(const T *p) {
{ p->clone() } -> std::convertible_to<T*>;
};
template <class T, class Dup = std::conditional_t<
cloneable<T>, Cloner, Copier
>> class dup_ptr {
T *p{};
public:
// ...
dup_ptr(const dup_ptr &other)
: p{ other.empty()? nullptr : Dup{}(other.p) } {
}
// ...
};
概念,就像特质一样,是解决这个问题的非侵入式解决方案。然而,特质是一种编程技术,它内嵌在类型系统中,我们可以(例如)编写针对 cloneable<T>
专门化的代码,以及不针对 cloneable<T>
编写的代码。在我们的情况下,我们希望为既不使用复制构造函数也不使用 clone()
成员函数的类型留下开放的大门,这表明当前设置,允许客户端代码提供其他复制机制,可能是更可取的。
C++26
C++26 将包含两个名为 std::indirect
和 std::polymorphic
的标准类型,它们将覆盖一个接近于本 dup_ptr
描述的利基市场。它于 2025 年 2 月 15 日获得投票通过。
一些不太聪明但有用的智能指针
因此,我们有标准的智能指针,如 unique_ptr<T>
(单一所有者)和 shared_ptr<T>
(共享所有者),我们可以为更特殊的情况编写自己的(我们检查了 dup_ptr<T>
,其中在指针被复制时,我们有单一所有者,但指向的对象被复制)。我们的程序类型系统中可能还有其他常见的语义我们想要封装?
好吧,至少有两个“简单”的可以考虑:实现一个“永不为空”的语义和实现一个“仅观察”的语义。
一个 non_null_ptr 类型
让我们回到一个早期的例子,我们写了以下内容:
// ...
// precondition: p != nullptr (to keep things simple)
X* duplicate(X *p) {
return new X{ *p }; // Ok
}
// ...
注意注释,它将不提供空指针的责任放在用户代码上。我们可以用许多其他方式来处理这个约束,包括以下内容:
-
断言
!p
-
如果
!p
,调用std::abort()
-
如果
!p
,调用std::terminate()
-
抛出
!p
,等等
重要的是,如果我们关心指针不为空,并且我们在运行时代码中注入 if(!p)
测试,那么我们可能正在做错事,因为这可能是(或应该是)类型系统的一部分:这个函数只接受非空指针。代码比注释更有说服力。
这个想法出现在一些商业库中(例如,一些主要编译器供应商提供的指南支持库中的 gsl::non_null<T>
),只要有一个明确的方式报告错误,就很容易实现。为了举例,我们将假设这个明确的方式是抛出异常:
class invalid_pointer {};
template <class T>
class non_null_ptr {
T *p;
public:
non_null_ptr(T *p) : p{ p } {
if (!p) throw invalid_pointer{};
}
T* get() const { return p; }
constexpr operator bool() const noexcept {
return true;
}
// ...
使用这种类型,任何接受 non_null_ptr<T>
参数的函数都知道其中的 T*
指针将非空,从而减轻客户端代码验证的负担。这使得 non_null_ptr<T>
成为期望非空 T*
的函数接口的一个美丽的类型。
到目前为止,这个类的其余部分主要是简单易写的。关键特性是 non_null_ptr<T>
不会暴露默认构造函数,因为那个构造函数必须将 p
数据成员初始化为某个默认值(可能是 nullptr
),但 non_null_ptr<T>
类型模拟了一个非空指针,这会导致不合理的代码。
在使用方面,看看这个:
struct X { int n; };
class invalid {};
int extract_value(const X *p) {
if(!p) throw invalid{};
return p->n;
}
#include <iostream>
int main() try {
X x{ 3 };
std::cout << extract_value(&x) << '\n'
<< extract_value(nullptr) << '\n';
} catch(invalid) {
std::cerr << "oops\n";
}
现在,比较一下这个,假设 non_null_ptr<T>
在用空指针构造时抛出异常:
// definition of the non_null_ptr type (omitted)
struct X { int n; };
int extract_value(const non_null_ptr<X> &p) {
return p->n; // no need for validation as it stems
// from the type system itself
}
#include <iostream>
int main() try {
X x{ 3 };
std::cout << extract_value(&x) << '\n'
<< extract_value(nullptr) << '\n';
} catch(...) {
std::cerr << "oops\n";
}
在这种情况下,non_null_ptr<T>
比 T*
的两个主要优点是类型系统通过 non_null_ptr<T>
更好地记录了意图(使用 T*
,空指针可能没问题,但使用 non_null_ptr<T>
,则显然不是),并且被调用的函数可以在不进行验证的情况下继续执行,验证(再次)内置于类型系统中。使用比 T*
更丰富的类型使调用代码和被调用代码都变得更好。
如果被调用的函数需要 T*
?这种情况可能发生,例如,当它需要调用一个 C 函数时。那么,使用 non_null_ptr<T>
对象的 get()
成员函数。C++ 如果不是实用主义,那就什么都不是了。
一个 observer_ptr 类型
有没有一个非常愚蠢的智能指针类型名为observer_ptr<T>
,它只关心表达这样一个观点:“智能”指针实际上确实不是指针,因为在那种类型上,应用于原始指针的操作被限制。典型的问题是,对T*
应用delete
会起作用,但对observer_ptr<T>
应用delete
则不会,因为observer_ptr<T>
不是指针。确实,考虑以下:
class X { /* ... */ };
void f(X *p) {
// use *p
// we passed a raw pointer to f(), so f() should
// observe it, not own it
delete p; // wait! You're not supposed to do that!
}
你可能会说,正如评论中所说的,“但是那个函数不应该做那样的事情!它不拥有*p
!”但是,嗯,错误是会发生的,误解也是如此。在这种情况下,误解的影响由于论证的类型中没有任何内容表明将operator delete
应用于p
是不正确的而被加剧了!
现在,让我们稍微改变一下签名:
class X { /* ... */ };
void f(observer_ptr<X> p) {
// use *p
// delete p; // nope, does not compile
}
“使用*p
”的注释在这两个版本中保持不变。observer_ptr<T>
类型提供了几乎所有合理运算符和成员函数(get()
、operator*()
、operator->()
、empty()
等等)的几乎平凡的版本,因此T*
和observer_ptr<T>
在用户代码中的使用应该大致相同;唯一的区别在于像应用delete
或执行指针运算这样的错误使用。
有时,仅仅在函数接口中明确意图就能使代码变得更好。
摘要
在第五章中,我们花了一些时间讨论标准智能指针的正确用法。在当前章节中,我们“弄脏了我们的手”,也就是说,我们编写了自制的(以及简化的)unique_ptr<T>
和shared_ptr<T>
版本。正如多次提到的,这旨在作为一种教育性的探索,因为你的库供应商无疑在两种情况下都提供了显著更好的(更完整、更高效、更好测试等)实现。
在本章中,我们还探讨了提供自制智能指针类型的可能性,有一个基于三种不同选择复制算法的策略dup_ptr<T>
。意图是表明这是可以做到的,如何做到,以及我们如何提供合理、可用的默认值,而不会因为用户代码中更奇特的要求而阻碍它们。
在本章的结尾,我们考察了一些相对简单(但有用)的智能(或者说轻度智能)指针,这些指针可以在函数的边缘(通常是作为参数类型)使用,通过类型系统隐式地表达语义要求,而不是强迫用户代码明确地强制执行这些要求……有时甚至无法做到。
令人意外的是,内存管理不仅限于智能指针。在下一章中,我们将探讨new
、new[]
、delete
和delete[]
运算符的工作原理,我们如何自己实现它们,以及为什么我们有时想这样做。
第三部分:掌握(内存管理机制)
在本部分,我们将更深入地探讨,并检查您如何接管 C++语言中的一些核心内存分配机制,并按需定制它们。我们将看到您如何控制诸如 new 和 delete 之类的运算符的行为,如何使用专业知识来获得特定的执行属性,以及这些运算符如何以创新的方式使用。我们还将利用这些知识应用于一些实际应用,以实现快速、有时甚至非常快速的内存管理操作。
本部分包含以下章节:
-
第七章, 重载内存分配运算符
-
第八章, 编写一个简单的内存泄漏检测器
-
第九章, 非典型分配机制
-
第十章, 基于区域的内存管理和其他优化
-
第十一章, 延迟回收
第七章:重载内存分配运算符
到目前为止,你过得愉快吗?我希望你是!我们现在已经掌握了所有的钥匙,可以开始做这本书所宣传的事情,更详细地看看 C++中内存管理是如何工作的。这不是一个简单的话题,也不是一件微不足道的事情,所以我们需要确保我们已经准备好了……但现在我们已经准备好了,让我们开始吧!
第五章和第六章探讨了可以使用标准工具将动态分配资源的责任封装到 C++类型系统中的方法,这些工具包括标准提供的以及我们可以编写的以填补其他空白。使用智能指针而不是原始指针作为数据成员和函数返回类型,往往可以简化(并阐明)C++程序中大量内存管理任务。
有时候,我们希望在这个级别以下工作,并控制当有人编写new X
时会发生什么。想要这种控制的原因有很多,在这本书中我们将探讨其中的一些,但在这章中,我们将专注于内存管理函数的基本知识以及如何在 C++中控制这些机制。
在这些基础知识被覆盖之后,我们将进行以下操作:
-
看看我们对 C++内存分配机制的了解如何让我们在第八章中编写一个简单的(但有效的)泄漏检测器
-
在第九章中检查如何在 C++中管理典型(持久、共享等)内存
-
在第十章中编写基于竞技场的内存分配,以确保确定性的时间分配和释放,当上下文允许时,这将导致
new
和delete
的快速实现
后续章节将使用本章以及后续章节中获得的知识来编写高效的容器和延迟回收机制,这些机制类似于垃圾回收器。超过这一点,我们将探讨容器如何使用这些设施,包括和不包括分配器的情况。
为什么会重载分配函数?
在我们开始讨论如何重载内存分配机制之前,让我们退一步,看看为什么有人想要这样做。确实,大多数程序员(即使是经验丰富的程序员)最终都没有做过这样的事情,我们可以打赌,大多数程序员从未想过他们有理由这样做。然而,我们将分配(!)几个章节来讨论这个话题。肯定有一个原因……
关于内存分配的事情是,在一般情况下,没有完美的解决方案来解决这个问题;平均来说,有许多好的解决方案,对于更专业的问题版本,也有非常好的解决方案。在编程语言 A 中构成良好解决方案的某个特定用例,可能不适合另一个用例或在编程语言 B 中。
以例如 Java 或 C#中动态分配大量小对象为习惯的语言为例。在这样的语言中,人们可以期望分配策略针对这种使用模式进行了优化。在 C 这样的语言中,人们可能会在对象太大而无法放在栈上或使用基于节点的数据结构(例如)时进行分配,最佳的动态内存分配策略可能完全不同。在第第十章中,我们将看到一个分配过程从分配的对象都是相同大小和对齐的事实中受益的例子,另一个有趣的用例。
C++强调控制并提供给程序员复杂且多功能的工具。当我们知道分配将在何种上下文中执行时,我们有时可以使用这些工具做得更好(甚至好得多,正如我们将在第十一章中看到的那样!)并且对于许多指标:更好的执行时间、更确定的执行时间、减少内存碎片等等。
C 语言分配函数的简要概述
在我们了解 C++的内存分配机制之前,让我们先简要地看一下 C 系列内存分配函数,通过其最杰出的代表:malloc()
和free()
。当然,还有许多其他与内存分配相关的函数,如calloc()
、realloc()
和aligned_alloc()
,不计操作系统特定的服务,这些服务为特定的用例执行类似任务,但这些都很好地服务于我们的讨论。
注意,由于这是一本关于 C++内存管理的书,我将使用这些函数的 C++版本(从<cstdlib>
而不是<stdlib.h>
),这实际上对我们的代码没有任何影响,除了在 C++中,这些函数位于std
命名空间的事实。
这两个函数的签名如下:
void* malloc(size_t n);
void free(void *p);
malloc(n)
的作用是在至少有n
个连续字节可用的位置找到位置,可能将该位置标记为“已占用”,并返回指向该内存块开始的抽象指针(void*
)。请注意,返回的指针必须适合给定机器最坏的自然情况,这意味着它必须满足std::max_align_t
的对齐要求。在大多数机器上,这种类型是double
的别名。
有趣的是,调用malloc()
时n==0
是合法的,但此类调用的结果由实现定义:对malloc(0)
的调用可能返回nullptr
,也可能返回非空指针。请注意,无论指针是否为空,都不应取消引用malloc(0)
返回的指针。
如果 malloc()
无法分配内存,它返回 nullptr
,因为 C 语言不支持 C++意义上的异常。在当代 C(自 C11 起),malloc()
实现必须是线程安全的,并且如果它们被并发调用,包括与 free()
一起调用,必须适当地与其他 C 分配函数同步。
free(p)
的作用是确保由 p
指向的内存变为可用,以便进一步分配请求,只要 p
指向的是通过 malloc()
等内存分配函数分配的块,并且尚未释放。不要对通过这种分配函数未分配的地址调用 free()
… 不要这样做!另外,要知道一旦内存被释放,它就不再被视为已分配,因此以下代码会导致未定义行为(UB):
#include <cstdlib>
int main() {
using std::malloc, std::free;
int *p = static_cast<int*>(malloc(sizeof(int)));
free(p); // fine since it comes from malloc()
free(p); // NOOOOOO unless (stroke of luck?) p is null
}
如前例所述,free(nullptr)
不会做任何事情,并且自本文写作以来已经定义为不做任何事情几十年了。如果你的代码库中有在调用 free()
之前验证 p!=nullptr
的代码 – 例如,if(p) free(p)
– 你可以安全地移除那个测试。
我们有时(不一定总是)会使用这些 C 函数来实现我们自制的 C++分配函数。它们是有效的,它们被很好地理解,并且是我们可以利用来构建高级抽象的低级抽象。
C++分配运算符概述
在 C++中,内存分配运算符有许多(无限多!)版本,但在编写自己的版本时必须遵循规则。当前章节主要关于这些规则;接下来的章节将探讨利用 C++赋予我们的这种自由的方法:
-
C++允许我们重载
new int
将使用我们自制的版本。在这里必须小心,因为小小的错误可能会对代码执行产生重大影响:如果你的operator new()
实现很慢,你将减慢程序中大多数内存分配的速度!我们将在第八章中编写一个简单但有效的内存泄漏检测器时使用这种方法。 -
C++允许我们重载内存分配运算符的成员函数版本。如果我们这样做,那么全局版本(重载与否)通常适用,但成员函数版本适用于特定类型。这在我们对某些类型的用法模式有特定知识但不是对其他类型时很有用。我们将在第十章中利用这一点。
-
C++允许我们重载
nothrow
版本和(极其重要的)placement new 相关的版本。我们还可以利用这个特性来利用“奇异”内存,例如共享内存或持久内存,正如我们将在第九章中看到的那样。
在每种情况下,内存分配函数都分为四组:operator new()
、operator new[]()
、operator delete()
和 operator delete[]()
。虽然有一些例外,但这个规则通常成立。如果我们至少重载这些函数中的一个,那么重载所有四个以保持程序行为一致是很重要的。当与这种低级设施(如本例所示)玩耍时,错误往往会更加严重,这也解释了为什么我们在 第二章 和 第三章 中如此小心地解释了我们可能会遇到麻烦的方式……以及如何同时遵守规则。
内存分配与对象模型(参见 第一章 中的基础知识)和异常安全性(本书中无处不在的主题)密切相关,所以请确保在接下来的页面和章节中掌握这些交互。它们将帮助您充分利用您在这里阅读的内容。
关于堆分配优化(HALO)的一个说明
了解这一点很重要,即不重载内存分配运算符也有好处。其中之一是您的库供应商默认提供了非常好的实现;另一个好处是,如果您不重载内存分配运算符,编译器可以假设您所做的分配数量是不可观察的。这意味着可以替换 n 次对 new
的调用,用一个一次性分配所有内容的调用,然后像执行了许多分配一样管理结果。这在实践中可能导致一些惊人的优化,包括从生成的代码中完全移除 new
和 delete
调用,即使它们出现在源代码中!如果有疑问,请确保在将优化提交并用于生产代码之前,它们提供了可衡量的好处。
注意,在本章中我们将看到的分配运算符重载,您需要包含 <new>
头文件,因为这是 std::bad_alloc
被声明的位置,以及其他一些内容,并且这是分配函数通常用来报告分配失败的类型。
全局分配运算符
假设我们想要控制 C++ 中的全局分配运算符版本。为了展示这是如何工作的,我们将简单地使用它们来委托给 malloc()
和 free()
,现在,并在 第八章 中展示一个更详细的例子。
如果我们坚持这些运算符的基本形式,我们想要重载……嗯,在 C++11 之前是四个函数,从那时起是六个函数。当然,这本书假设我们已经超过十年没有使用 C++14,所以我们将相应地进行。
我们想要重载的签名如下:
void *operator new(std::size_t);
void *operator new[](std::size_t);
void operator delete(void *) noexcept;
void operator delete[](void *) noexcept;
// since C++14
void operator delete(void *, std::size_t) noexcept;
void operator delete[](void *, std::size_t) noexcept;
我同意,这确实很多,但掌握内存管理工具是专业的工作。一旦你编写了这些函数之一,你就正式替换了为你提供的标准库中的那些函数,并且该函数将负责通过该渠道传入的分配(或释放)请求。替换分配函数需要你使用与原始函数完全相同的签名。
如果你至少重载了一个函数,那么重载整个函数集之所以重要,是因为这些函数形成了一个一致的整体。例如,如果你改变了new
的行为方式,但忽略了标准库提供的delete
执行其任务的方式,那么预测你的程序将遭受多少损害基本上是不可能的。正如一位著名的流行漫画书英雄多次所说的,“权力越大,责任越大。”要小心,要严谨,并遵循规则。
注意这些函数的签名,因为它们提供了有趣的信息...
关于 new 和 new[]操作符
函数operator new()
和operator new[]()
都接受一个std::size_t
对象作为参数,并且都返回void*
。在两种情况下,参数都是要分配的最小连续字节数。因此,它们的签名类似于std::malloc()
。这常常让人惊讶;如果new
不是一个模板
并且不知道要创建什么,那么new X
表达式是如何创建X
对象的呢?
事情是这样的:new
并不创建对象。new
所做的就是找到将要构造对象的位置。是构造函数将new
找到的原始内存转换成对象。在实践中,你可以编写如下内容:
X *p = new X{ /* ... args ... */ };
你所写的是一个两步操作:
// allocate enough space to put an X object
void * buf = operator new(sizeof(X));
// construct an X object at that location
X *p = ... // apply X::X( /* ... args ... */ ) on buf
这意味着构造函数就像是一层涂在内存块上的油漆,将那块内存转换成对象。这也意味着,例如new X
这样的表达式可能会在operator new()
失败时失败,如果分配请求无法成功,或者在X::X()
失败,因为构造函数以某种方式失败了。只有当这两个步骤都成功时,客户端代码才对指向的对象负责。
关于这些操作符的命名
你可能已经注意到在前面的例子中,我们有时写new X
,有时写operator new(sizeof(X))
。第一种形式——操作符形式——将执行分配后跟构造的两个步骤,而第二种形式——函数形式——直接调用分配函数而不调用构造函数。这种区别也适用于operator delete()
。
与operator new[]
的情况类似:传递给函数的字节数是数组的总字节数,因此分配函数本身并不知道将要创建的对象的类型、元素的数量或对象的单个大小。实际上,对new X[N]
的调用将调用operator new[](N*sizeof(X))
以找到放置将要构造的数组的空间,然后对数组中每个大小为sizeof(X)
的N
个块调用X::X()
。只有当整个序列成功完成时,客户端代码才负责结果数组。
通过operator new
无法分配标量应该导致抛出与std::bad_alloc
匹配的东西。对于operator new[]()
,如果请求的大小有问题,也可以抛出std::bad_array_new_length
(从std::bad_alloc
派生),通常是因为它超过了实现定义的限制。
关于delete
和delete[]
运算符
与 C 语言的free()
函数类似,delete()
和delete[]()
运算符都接受一个void*
作为参数。这意味着它们不能销毁你的对象…当它们被调用时,对象已经被销毁了!实际上,你可以写出以下内容:
delete p; // suppose that p is of type X*
这实际上是一个两步操作,相当于以下操作:
p->~X(); // destroy the pointed-to object
operator delete(p); // free the associated memory
在 C++中,你的析构函数和operator delete()
都不应该抛出异常。如果它们抛出异常,程序基本上会被终止,原因将在第十二章中变得显而易见。
operator delete()
和operator delete[]()
的大小感知版本是在 C++14 中引入的,并且现在通常除了这些函数的经典版本之外,还会实现它们。其想法是operator new()
知道要分配的块的大小,但operator delete()
不知道,这要求实现方面进行不必要的杂技表演,例如用某个值填充内存块以试图隐藏该位置存储的内容。这些函数的现代实现要求我们编写一个版本,它除了经典版本外还接受指向对象的尺寸;如果实现不需要该尺寸,可以直接从大小感知版本调用经典版本,然后完成。
关于大小感知版本的operator delete[]()
重载的说明
如果你追踪你重载的执行过程,你可能会惊讶地发现,对于某些类型,operator delete[]()
的大小版本并不一定被调用。确实,如果你有一个由平凡可销毁类型对象组成的数组arr
,标准并未指定在编写delete [] arr
时,将使用operator delete[]()
的大小版本还是非大小版本。请放心,这并不是一个错误。
这些函数的一个完整但简单的实现是将工作委托给 C 分配函数,如下所示:
#include <iostream>
#include <cstdlib>
#include <new>
void *operator new(std::size_t n) {
std::cout << "operator new(" << n << ")\n";
auto p = std::malloc(n);
if(!p) throw std::bad_alloc{};
return p;
}
void operator delete(void *p) noexcept {
std::cout << "operator delete(...)\n";
std::free(p);
}
void operator delete(void *p, std::size_t n) noexcept {
std::cout << "operator delete(..., " << n << ")\n";
::operator delete(p);
}
void *operator new[](std::size_t n) {
std::cout << "operator new[](" << n << ")\n";
auto p = std::malloc(n);
if(!p) throw std::bad_alloc{};
return p;
}
void operator delete[](void *p) noexcept {
std::cout << "operator delete[](...)\n";
std::free(p);
}
void operator delete[](void *p, std::size_t n) noexcept {
std::cout << "operator delete[](..., " << n << ")\n";
::operator delete[](p);
}
int main() {
auto p = new int{ 3 };
delete p;
p = new int[10];
delete []p;
}
如此看来,当 operator new()
和 operator new[]()
无法满足其后置条件并且实际上分配了请求的内存量时,默认行为是抛出 std::bad_alloc
或者在适当的情况下抛出 std::bad_array_new_length
。由于分配之后是构造,客户端代码也可能面临构造函数抛出的任何异常。我们将在编写自定义容器时探讨如何处理这些情况,见第十二章。
在某些应用领域,异常处理不是一个可选项。这可能是由于内存限制;大多数异常处理器会使程序略微增大,这在嵌入式系统等领域的应用中可能是不被接受的。也可能是由于速度限制;try
块中的代码通常运行得很快,因为这些块代表“正常”的执行路径,但catch
块中的代码通常被视为罕见的(“异常”)路径,执行速度可能会显著减慢。当然,有些人可能仅仅出于哲学原因而避免使用异常,这也是可以的。
幸运的是,有一种方法可以在不使用异常来指示失败的情况下执行动态内存分配。
非抛出异常的分配操作符版本
也有不抛出异常的分配操作符版本。这些函数的签名如下:
void *operator new(std::size_t, const std::nothrow_t&);
void *operator new[](std::size_t, const std::nothrow_t&);
void operator delete(void *, const std::nothrow_t&)
noexcept;
void operator delete[](void *, const std::nothrow_t&)
noexcept;
// since C++14
void operator delete
(void *, std::size_t, const std::nothrow_t&) noexcept;
void operator delete[]
(void *, std::size_t, nullptr than to just write it as if no failure occurred! The fact is that there are costs to using exceptions in one’s programs: it can make binaries slightly bigger, and it can slow down code execution, particularly when exceptions are caught (there are also issues of style involved; some people would not use exceptions even if they led to faster code, and that’s just part of life). For that reason, application domains such as games or embedded systems often shun exceptions and go to some lengths to write code that does not depend on them. The non-throwing versions of the allocation functions target these domains.
Type `std::nothrow_t` is what is called a `std::nothrow` object) can be used to guide the compiler when generating code. Note that these function signatures require the `std::nothrow_t` arguments to be passed by `const` reference, not by value, so make sure you respect this signature if you seek to replace them.
An example usage of these functions would be as follows:
X p = new (nothrow) X{ / ... args ... */ };
if(p) {
// ... 使用 *p
// note: 这不是 delete 的 nothrow 版本
delete p; // 即使 !p 也会是正确的
}
You might be surprised about the position of `nothrow` in the `new` expression, but if you think about it, it’s essentially the only syntactic space for additional arguments passed to `operator new()`; the first argument passed to the function is the number of contiguous bytes to allocate (here: `sizeof(X)`), and in expression `new X { ...args... }`, what follows the type of object to construct is the list of arguments passed to its constructor. Thus, the place to specify the additional arguments to `operator new()` itself is between `new` and the type of the object to construct, between parentheses.
A word on the position of additional arguments to operator new()
To illustrate this better with an artificially crafted example, one could write the following `operator` `new()` overload:
`void* operator new(std::size_t,` `);`
Then, a possible call to that hypothetical operator would be as follows:
`X *p = new (3, 1.5) X{ /* ... */ };`
Here, we can see how two additional arguments, an `int` argument and a `double` argument, are passed by client code.
Returning to the `nothrow` version of `operator new()` and `operator new[]()`, one thing that is subtle and needs to be understood is why one needs to write overloads of `operator delete()` and `operator delete[]()`. After all, even with client code that uses the `nothrow` version of `new`, as was the case in our example, it’s highly probable that the “normal” version of `operator delete()` will be used to end the life of that object. Why, then, write a `nothrow` version of `operator delete()`?
The reason is `operator new()`? Well, remember that memory allocation through `operator new()` is a two-step operation: find the location to place the object, then construct the object at that location. Thus, even if `operator new()` does not throw, we do not know whether the constructor that will be called will throw. Our code will obtain the pointer only after both the allocation *and* the construction that follows have successfully completed execution; as such, client code cannot manage exceptions that occur after allocation succeeded but during the construction of the object, at least not in such a way as to deallocate the memory… It’s difficult to deallocate a pointer your code has not yet seen!
For that reason, it falls on the C++ runtime to perform the deallocation if an exception is thrown by the constructor, and this is true for all versions of `operator new()`, not just the `nothrow` ones. The algorithm (informally) is as follows:
// 第 1 步,尝试为某些 T 对象执行分配
p = operator new(n, ... maybe additional arguments ...)
// 以下行仅用于 nothrow new
if(!p) return p
try {
// 第 2 步,在地址 p 处构造对象
在地址 p 处应用 T 的构造函数 // 可能会抛出
} catch(...) { // 构造函数抛出了异常
deallocate p // 这是我们这里关心的问题
re-throw the exception, whatever it was
}
return p // p 指向一个完全构造的对象
// 只有在这一点之后,客户端代码才会看到 p
As this algorithm shows, the C++ runtime has to deallocate the memory for us when the constructor throws an exception. But how does it do so? Well, it will use the `operator delete()` (or `operator delete[]()`) whose signature matches that of the version of `new` or `new[]` that was used to perform the allocation. For example, if we use `operator new(size_t,``)` to allocate and the constructor fails, it will use `operator delete(void*,``)` to perform the implicit deallocation.
That is the reason why, if we overload the `nothrow` versions of `new` and `new[]`, we have to overload the `nothrow` versions of `delete` and `delete[]` (they will be used for deallocation if a constructor throws), and why we also have to overload the “normal” throwing versions of `new`, `new[]`, `delete`, and `delete[]`. Expressed informally, code that uses `X *p = new(nothrow)X;` will usually call `delete p;` to end the life of the pointee, and as such, the `nothrow` and throwing versions of the allocation functions have to be coherent with one another.
Here is a full, yet naïve implementation where the throwing versions delegate to the non-throwing ones to reduce repetition:
include
include
include
void* operator new(std::size_t n, const std::nothrow_t&) noexcept {
return std::malloc(n);
}
void* operator new(std::size_t n) {
auto p = operator new(n, std::nothrow);
if (!p) throw std::bad_alloc{};
return p;
}
void operator delete(void* p, const std::nothrow_t&)
noexcept {
std::free(p);
}
void operator delete(void* p) noexcept {
operator delete(p, std::nothrow);
}
void operator delete(void* p, std::size_t) noexcept {
operator delete (p, std::nothrow);
}
void* operator new[](std::size_t n,
const std::nothrow_t&) noexcept {
return std::malloc(n);
}
void* operator new[](std::size_t n) {
auto p = operator new[](n, std::nothrow);
if (!p) throw std::bad_alloc{};
return p;
}
void operator delete[](void* p, const std::nothrow_t&)
noexcept {
std::free(p);
}
void operator delete[](void* p) noexcept {
operator delete[](p, std::nothrow);
}
void operator delete[](void* p, std::size_t) noexcept {
operator delete[](p, std::nothrow);
}
int main() {
using std::nothrow;
auto p = new (nothrow) int{ 3 };
delete p;
p = new (nothrow) int[10];
delete[]p;
}
As you can see, there are quite a few functions to write to get a full, cohesive set of allocation operators if we want to cover both the throwing and the non-throwing versions of this mechanism.
We still have a lot to cover. For example, we mentioned a few times already the idea of placing an object at a specific memory location, in particular at the second of the two-step process modeled by calls to `new`. Let’s see how this is done.
The most important operator new: placement new
The most important version of `operator new()` and friends is not one you can replace, but even if you could… well, let’s just state that it would be difficult to achieve something more efficient:
// note: 这些存在,你可以使用它们,但你不能
// 替换它们
void *operator new(std::size_t, void *p) { return p; }
void *operator new[](std::size_t, void *p) { return p; }
void operator delete(void, void) noexcept { }
void operator delete[](void, void) noexcept { }
We call these the placement allocation functions, mostly known as **placement new** by the programming community.
What is the purpose of these functions? You might remember, at the beginning of our discussion of the global versions of the allocation operators, that we stated: “What `new` does is find the location where an object will be constructed.” This does not necessarily mean that `new` will allocate memory, and indeed, placement `new` does not allocate; it simply yields back the address it has been given as argument. *This allows us to place an object wherever we want in memory*… as long as we have the right to write the memory at that location.
Placement `new` serves many purposes:
* If we have sufficient rights, it can let us map an object onto a piece of memory-mapped hardware, giving us an *extremely* thin layer of abstraction over that device.
* It enables us to decouple allocation from construction, leading to significant speed improvements when writing containers.
* It opens up options to implement important facilities such as types `optional<T>` (that might or might not store a `T` object) and `variant<T0,T1,...,Tn>` (that stores an object of one of types `T0`,`T1`,...,`Tn`), or even `std::string` and `std::function` that sometimes allocate external memory, but sometimes use their internal data structures and avoid allocation altogether. Placement `new` is not the only way to do this, but it is one of the options in our toolbox.
One important benefit of placement `new` is most probably in the implementation of containers and the interaction between containers and allocators, themes we will explore from *Chapter 12* to *Chapter 14* of this book. For now, we will limit ourselves to a simple, artificial example that’s meant as an illustration of how placement `new` works its magic, not as an example of something you should do (indeed, you should *not* do what the following example does!).
Suppose that you want to compute the length of a null-delimited character string and cannot remember the name of the C function that efficiently computes its length (better known as `std::strlen()`). One way to achieve similar results but *much* less efficiently would be to write the following:
auto string_length(const char *p) {
return std::string{ p }.size(); // 啊!但它有效...
}
That’s inefficient because the `std::string` constructor might allocate memory. We just wanted to count the characters until the first occurrence of a zero in the sequence, but it works (note: if you do the same maneuver with a `std::string_view` instead of with a `std::string`, its performance will actually be quite reasonable!). Now, suppose you want to show off to your friends the fact that you can place an object where you want in memory, and then use that object’s data members to do what you set out to do. You can (but should not) write the following:
auto string_length(const char *p) {
using std::string;
// A) 制作正确大小的局部缓冲区
// 字符串对象的对齐
alignas(string) char buf[sizeof(string)];
// B) 在该缓冲区中“绘制”字符串对象
// (注意:那个对象可能会分配其
// 使用外部数据,但那不是
// 我们的关注点在这里)
string s = new (static_cast<void>(buf)) string{ p };
// C) 使用该对象来计算大小
const auto sz = s->size();
// D) 销毁对象而不释放内存
// 对于缓冲区(它不是动态分配的,
// 它只是局部存储)
s->~string(); // 是的,你可以这样做
return sz;
}
What are the benefits of the complicated version in comparison to the simple one? None whatsoever, but it shows the intricacies of doing this sort of low-level memory management maneuver. From the comments in the code example, the steps work as follows:
* Step `A)` makes sure that the location where the object will be constructed is of the right size and shape: it’s a buffer of bytes (type `char`), aligned in memory as a `std::string` object should be, and of sufficient size to hold a `std::string` object.
* Step `B)` paints a `std::string` object in that buffer. That’s what a constructor does, really: it (conceptually) transforms raw memory into an object and initializes the state of that object. If the `std::string` constructor throws an exception, then the object has never been constructed and our `string_length()` function concludes without satisfying its postconditions. There is no memory allocation involved here unless the constructor itself allocates, but that’s fair (the object does what it has to do).
* Step `C)` uses the newly constructed object; in our case, it’s just a matter of querying the size of that character string, but we could do whatever we want here. Do note, however, that (a) the object’s lifetime is tied to the buffer in which it is located, and (b) since we explicitly called the constructor, we will need to explicitly destroy it, which means that if an exception is thrown when we use the object, we will need to make sure the object’s destructor is called somehow.
* Step `D)` destroys the object before we leave the function, as not doing so would lead to a possible leak of resources. If the buffer’s lifetime ends at a point where the object is not yet destroyed, things will be very wrong: either the destructor of the object we put in that buffer will never be called and code will leak, or someone might try to use the object even though the storage for that object is not ours anymore, leading to UB. Note the syntax, `s->~string()`, which calls the destructor but does not deallocate the storage for `*s`.
This is a bad example of placement `new` usage, but it is explicit and (hopefully) instructive. We will use this feature in much more reasonable ways in order to gain significant speed advantages when we write containers with explicit memory management in *Chapter 12*.
A note on make_shared<T>(args...)
We mentioned in *Chapter 6* that `make_shared<T>(args...)` usually leads to a better memory layout than `shared_ptr<T>{ new T(args...) }` would, at least with respect to cache usage. We can start to see why that is so.
Calling `shared_ptr<T>::shared_ptr(T*)` makes the object responsible for a preexisting pointee, the one whose address is passed as argument. Since that object has been constructed, the `shared_ptr<T>` object has to allocate a reference counter separately, ending up with two separate allocations, probably on different cache lines. In most programs, this worsened locality may induce slowdowns at runtime.
On the other hand, calling `make_shared<T>(args...)` makes this factory function responsible for creating a block of memory whose layout accommodates the `T` object and the reference counter, respecting the size and alignment constraints of both. There’s more than one way to do this, of course, including (a) resorting to a `union` where “coexist” a pair of pointers and a single pointer to a block that contains a counter and a `T` object, and (b) resorting to a byte buffer of appropriate size and alignment, then performing placement `new` for both objects in the appropriate locations within that buffer. In the latter case, we end up with a single allocation for a contiguous block of memory able to host both objects and two placement `new` calls.
Member versions of the allocation operators
Sometimes, we have special knowledge of the needs and requirements of specific types with respect to dynamic memory allocation. A full example that goes into detail about a real-life (but simplified) use case of such type-specific knowledge is given in *Chapter 10*, where we discuss arena-based allocation.
For now, we will limit ourselves to covering the syntax and the effect of a member function overload of the allocation operators. In the example that follows, we suppose class `X` would somehow benefit from a per-class specialization of these mechanisms, and show that client code will call these specializations when we call `new X` but not when we call `new int`:
include
include
class X {
// ...
public:
X() { std::cout << "X::X()\n"; }
~X() { std::cout << "X::~X()\n"; }
void *operator new(std::size_t);
void operator delete(void*);
// ...
};
// ...
void* X::operator new(std::size_t n) {
std::cout << "Some X::operator new() magic\n";
return ::operator new(n);
}
void* X::operator new[](std::size_t n) {
std::cout << "Some X::operator new magic\n";
}
void X::operator delete(void *p) {
std::cout << "Some X::operator delete() magic\n";
return ::operator delete(p);
}
void X::operator delete[](void *p) {
std::cout << "Some X::operator delete magic\n";
}
int main() {
std::cout << "p = new int{3}\n";
int *p = new int{ 3 }; // 全局操作符 new
std::cout << "q = new X\n";
X *q = new X; // X::operator new
std::cout << "delete p\n";
delete p; // 全局操作符 delete
std::cout << "delete q\n";
delete q; // X::operator delete
}
One important detail to mention is that these overloaded operators will be inherited by derived classes, which means that if the implementation of these operators somehow depends on details specific to that class – for example, its size of alignment or anything else that might be invalidated in derived classes through such seemingly inconspicuous details as adding a data member – consider marking the class that overloads these operators as `final`.
Alignment-aware versions of the allocation operators
When designing C++17, a fundamental problem with the memory allocation process was fixed with respect to what we call `std::max_align_t`.
There are many reasons for this, but a simple example would be when communicating with specialized hardware with requirements that differ from the ones on our computer. Suppose the following `Float4` type is such a type. Its size is `4*sizeof(float)`, and we require a `Float4` to be aligned on a 16-byte boundary:
struct alignas(16) Float4 { float vals[4]; };
In this example, if we remove `alignas(16)` from the type declaration, the natural alignment of type `Float4` would be `alignof(float)`, which is probably 4 on most platforms.
The problem with such types before C++17 is that variables generated by the compiler would respect our alignment requirements, but those located in dynamically allocated storage would, by default, end up with an alignment of `std::max_align_t`, which would be incorrect. That makes sense, of course; functions such as `malloc()` and `operator new()` will, by default, cover the “worst-case scenario” of the platform, not knowing what will be constructed in the allocated storage, but they cannot be assumed to implicitly cover even worse scenarios than this.
Since C++17, we can specify `operator new()` or `operator new[]()` by passing an additional argument of type `std::align_val_t`, an integral type. This has to be done explicitly at the call site, as the following example shows:
include
include
include
include <type_traits>
void* operator new(std::size_t n, std::align_val_t al) {
std::cout << "new(" << n << ", align: "
<< static_cast<std::underlying_type_t<
std::align_val_t
(al) << ")\n";
return std::aligned_alloc(
static_caststd::size_t(al), n
);
}
// (其他省略以节省篇幅)
struct alignas(16) Float4 { float vals[4]; };
int main() {
auto p = new Float4; // 调用 operator new(size_t)
// 调用 operator new(size_t, align_val_t)
auto q = new(std::align_val_t{ 16 }) Float4;
// 泄露,当然,但这不是重点
}
The memory block allocated for `p` in this example will be aligned on a boundary of `std::max_align_t`, whereas the memory block allocated for `q` will be aligned on a 16-byte boundary. The former might satisfy the requirements of our type if we’re lucky and cause chaos otherwise; the latter will respect our constraints if the allocation operator overload is implemented correctly.
Destroying delete
C++20 brings a novel and highly specialized feature called destroying `delete`. The use case targeted here is a member function overload that benefits from specific knowledge of the type of object being destroyed in order to better perform the destruction process. When that member function is defined for some type `T`, it is preferred over other options when `delete` is invoked on a `T*`, even if `T` exposes another overload of `operator delete()`. To use destroying `delete` for some type `X`, one must implement the following member function:
class X {
// ...
public:
void operator delete(X*, std::destroying_delete_t);
// ...
};
Here, `std::destroying_delete_t` is a tag type like `std::nothrow_t`, which we saw earlier in this chapter. Note that the first argument of the destroying `delete` for class `X` is an `X*`, not a `void*`, as the destroying `delete` has the double role of destroying the object and deallocating memory… hence its name!
How does that work, and why is that useful? Let’s look at a concrete example with the following `Wrapper` class. In this example, an object of type `Wrapper` hides one of two implementations, modeled by `Wrapper::ImplA` and `Wrapper::ImplB`. The implementation is selected at construction time based on an enumerated value of type `Wrapper::Kind`. The intent is to remove the need for `virtual` functions from this class, replacing them with `if` statements based on the kind of implementation that was chosen. Of course, in this (admittedly) small example, there’s still only one `virtual` function (`Impl::f()`) as we aim to minimize the example’s complexity. There is also a wish to keep the destructor of class `Wrapper` trivial, a property that can be useful on occasion.
We will look at this example step by step as it is a bit more elaborate than the previous ones. First, let’s examine the basic structure of `Wrapper` including `Wrapper::Kind`, `Wrapper::Impl`, and its derived classes:
include
include
class Wrapper {
public:
enum class Kind { A, B };
private:
struct Impl {
virtual int f() const = 0;
};
struct ImplA final : Impl {
int f() const override { return 3; }
~ImplA() { std::cout << "Kind A\n"; }
};
struct ImplB final : Impl {
int f() const override { return 4; }
~ImplB() { std::cout << "Kind B\n"; }
};
Impl *p;
Kind kind;
// ...
Visibly, `Wrapper::Impl` does not have a `virtual` destructor, yet `Wrapper` keeps as a data member an `Impl*` named `p`, which means that simply calling `delete p` might not call the appropriate destructor for the pointed-to object.
The `Wrapper` class exposes a constructor that takes a `Kind` as argument, then calls `Wrapper::create()` to construct the appropriate implementation, modeled by a type derived from `Impl`:
// ...
static Impl *create(Kind kind) {
switch(kind) {
using enum Kind;
case A: return new ImplA;
case B: return new ImplB;
}
throw 0;
}
public:
- Wrapper(Kind kind)
- p{ create(kind) }, kind{ kind } {
}
// ...
Now comes the destroying `delete`. Since we know by construction that the only possible implementations would be `ImplA` and `ImplB`, we test `p->kind` to know which one was chosen for `p`, then directly call the appropriate destructor. Once that is done, the `Wrapper` object itself is finalized and memory is freed through a direct call to `operator delete()`:
// ...
void operator delete(Wrapper *p,
std::destroying_delete_t) {
if(p->kind == Kind::A) {
delete static_cast<ImplA*>(p->p);
} else {
delete static_cast<ImplB*>(p->p);
}
p->~Wrapper();
::operator delete(p);
}
int f() const { return p->f(); }
};
For client code, the fact that we decided to use a destroying `delete` is completely transparent:
int main() {
using namespace std;
auto p = new Wrapper{ Wrapper::Kind::A };
cout << p->f() << endl;
删除 p;
p = new Wrapper{ Wrapper::Kind::B };
cout << p->f() << endl;
删除 p;
}
The destroying `delete` is a recent C++ facility as of this writing, but it is a tool that can let us get more control over the destruction process of our objects. Most of your types probably do not need this feature, but it’s good to know it exists for those cases where you need that extra bit of control over execution speed and program size. As always, measure the results of your efforts to ensure that they bring the desired benefits.
Summary
Whew, that was quite the ride! Now that we have the basics of memory allocation operator overloading handy, we will start to use them to our advantage. Our first application will be a leak detector (*Chapter 8*) using the global forms of these operators, followed by simplified examples of exotic memory management (*Chapter 9*) using specialized, custom forms of the global operators, and arena-based memory management (*Chapter 10*) with member versions of the operators that will perform very satisfying optimizations.
第八章:编写一个简单的内存泄漏检测器
在第七章中,我们探讨了各种方法来重载内存分配操作符,即 new
、new[]
、delete
和 delete[]
,以便掌握编写这些操作符所涉及的语法以及它们如何在客户端代码中使用。我们讨论了这些操作符如何与异常交互(甚至在 nothrow
版本的情况下),并看到了为什么它们在大多数情况下应该以四组或其倍数的形式编写。例如,调用 nothrow
版本的 operator new()
来获取一些指针 pV
,然后在稍后调用 delete p
的代码,如果只重载了 nothrow
版本而没有重载“常规”版本,那么两者可能最终无法相互兼容,这会导致问题迅速出现。
我们实际上还没有讨论的是,我们的代码如何通过控制这些操作符来受益。确实,这有多种用途:追踪内存是如何或在哪里被分配的,测量进程中的内存碎片,实现一种专门的战略来控制分配或释放过程的性能特性,等等。由于这本书的篇幅有限,我们无法希望涵盖所有可能的选项,因此我们将选择一个,希望这个例子足够启发你,让你能够自己探索其他途径。
本章我们将探讨的例子是一个简单但实用的内存泄漏检测器。更详细地说,我们将做以下几件事:
-
我们首先将详细阐述计划,概述我们的泄漏检测器将如何工作以及我们将使用哪些技巧来实现我们的目标。
-
然后,我们将实现我们工具的第一个版本,这个版本表面上看起来似乎是可行的。我们将逐步分析对
operator new()
的调用以及相应的operator delete()
,以了解在整个过程中内存中发生了什么。 -
在这一点上,我们将利用前几章学到的知识来识别我们第一个解决方案中的缺陷以及我们可以如何修复它们。
-
最后,我们将重新审视我们的初始实现,并最终得到一个既简单又能在实际代码中使用的解决方案。
由于这将是一个非常具体的章节,你可以期待在我们前进的过程中(或进一步)发展(或完善)一些有用的技能:
-
第一件事是在编码前进行规划。在本章中,我们将编写非常底层的代码,这使得我们有一个清晰的方向尤为重要。毕竟,当我们“接近机器”编码并处理原始内存时,编译器提供的类型系统这一安全网往往会变得较薄,如果我们不小心,就更容易出错(代价高昂的错误)。
-
第二个任务是安全地使用共享可变资源。我们的泄漏检测器将使用内存分配操作符的全局版本,以便覆盖所有类型的分配请求,至少除非用户决定使用这些操作符的专用版本,因此我们需要管理程序的全局状态。此外,我们知道用户代码可能是多线程的,因此我们分配的内存的会计需要一种同步形式,以避免数据竞争。
-
第三个任务是承认在绕过类型系统时对对齐的影响。由于我们将处理原始内存以满足客户端代码事先未知的需求,我们将学会做出适用于所有“自然”(在“非对齐”的意义上)内存分配用例的选择。
-
最后,我们将检查如何根据原始内存的内容调试我们的代码。由于我们旨在使本书不受工具的限制,我们将采用一种图表方法来解决这个问题,但在实践中,您应该将本章中我们所做的事情适应到您最喜欢的调试工具的隐喻中。所有合理的调试器都会让您检查特定内存地址的内容,您肯定会在某些时候想要这样做。
让我们深入探讨吧!
技术要求
您可以在本书的 GitHub 仓库中找到本章的代码文件:github.com/PacktPublishing/C-Plus-Plus-Memory-Management/tree/main/chapter8
。
计划
我们计划编写一个内存泄漏检测器,这个任务一开始可能看起来很奇怪和抽象。我们如何开始呢?好吧,一种澄清我们需要做什么的方法是编写一个小型测试程序,同时展示我们期望我们的工具如何被使用,并突出从用户代码的角度看我们的工具的关键方面:
#include <iostream>
// this is incomplete (for now)
int main() {
auto pre = // current amount of allocated memory
{ // BEGIN
int *p = new int{ 3 };
int *q = new int[10]{ }; // initialized to zero
delete p;
// oops! Forgot to delete[] q
} // END
auto post = // current amount of allocated memory
// with this code, supposing sizeof(int)==4, we
// expect to see "Leaked 40 bytes" printed
if(post != pre)
std::cout << "Leaked " << (post - pre) << " bytes\n";
}
如您所见,这个“故意泄漏”的程序执行了两次分配,但只有一次释放,"忘记"(对我们来说很方便)释放一个包含十个int
对象的数组。假设sizeof(int)==4
,我们的泄漏检测器应该允许程序报告 40 字节的泄漏。
这个程序并没有告诉我们如何在特定时间(portably)获取动态分配的内存量(我们将在本章后面编写这个服务),但它确实显示了分配和释放操作位于一对大括号之间(参见示例程序注释中的BEGIN
和END
)。正如您所知,C++中,匹配的大括号定义了一个作用域,作用域确保了在其中定义的自动变量的销毁。这里的想法是,即使存在 RAII 对象(参见第四章),我们也要检测到泄漏,因为它们也可能有错误,所以我们要确保在尝试发出诊断之前它们被销毁。
如本章引言中所述,我们将通过重载内存分配操作符的全局形式来实现我们的泄漏检测器。你可能已经猜到了,这些操作符需要共享一些状态:至少,它们需要共享在特定时刻分配的内存数量的知识,因为new
和new[]
操作符会增加这个数量,而delete
和delete[]
操作符会减少它。
注意,对于我们的泄漏检测器,这些操作符的数组和非数组形式将是相同的,但这并不总是如此:可以设想不同的策略来分配标量和数组,例如,就像在程序中分别跟踪这两种形式所做的事情一样。为了简单起见,在本章中,我们通常会简单地提到new
来描述new
和new[]
,并且对于delete
也会使用相同的方法。
由于这些是自由函数,而不是某个对象的成员函数,我们需要求助于一个全局变量来存储这个状态。我知道全局变量通常是不受欢迎的,大多数情况下有很好的理由,但它们确实存在于像这种情况。
全局变量,哦我的天!
不喜欢全局变量的理由有很多:它们使局部推理变得困难(谁知道它们在哪里和何时被访问?),它们往往成为缓存访问的瓶颈并减慢程序的速度,它们在当代(可能是多线程的)程序中往往需要同步,等等。我们之所以求助于这种机制,是因为我们需要这样做:C++为我们提供了各种各样的工具,因为它是一种用于解决各种问题的语言,所以当这些工具是手头任务的正确工具时,使用它们并不丢人。只是确保你做出明智的选择,并且能够为之辩护!
为了稍微减少全局变量给许多人带来的明显厌恶感,我们将把这个状态封装在一个对象中,但当然,这个对象也将是全局的。
我们将应用Accountant
,因为它的职责将是帮助内存分配操作符在程序执行期间跟踪分配和释放的字节数。
单例,哦我的天!
就设计模式而言,单例模式可能是最不受欢迎的之一,原因与人们不喜欢全局变量的原因类似:难以测试或模拟,需要同步,容易成为性能瓶颈,等等。坦白说,这里的真正罪魁祸首是共享可变状态,由于这种状态在整个程序中都是全局可访问的,这使问题变得更糟。你可能已经猜到了,由于共享可变状态正是我们需要用来跟踪在特定时间分配的内存数量的,嗯……这正是我们将要使用的!
现在,对于实际的实现,我们需要制定一个策略来跟踪分配和释放的字节数。总体思路是 operator new()
将告诉 Accountant
对象已经分配了字节,而 operator delete()
将告诉 Accountant
对象已经释放了字节。现在,为了这个活动的目的,我们将使用传统的(直到包括 C++11)这些操作符的形式。你可能还记得从 第七章,它们的签名如下:
void *operator new(std::size_t n);
void *operator new[](std::size_t n);
void operator delete(void*p) noexcept;
void operator delete[](void*p) noexcept;
由于你在读这本书,你肯定是一个非常敏锐的读者,所以你可能已经注意到了这里的一个问题:我们的分配函数知道从它们的参数中分配的字节数,但我们的释放函数没有这个特权,它们只提供了要释放的内存块的起始地址。这意味着我们需要一种方法来在 operator new()
返回的地址和关联的内存块大小之间建立联系。
这似乎是一个容易解决的问题:只需分配类似于 std::vector<std::pair<void*,std::size_t>>
或 std::map<void*,std::size_t>
的道德等价物,以便轻松检索与给定地址关联的 std::size_t
,但这样的容器需要分配内存,这意味着为了实现我们分配内存的方式而分配内存。这至少可能会出现问题,因此我们需要另一个解决方案。
我们会做任何理智的程序员在类似情况下都会做的事情:我们会撒谎。是的,我们会!你为什么认为我们花了时间查看那些第一章中的棘手和危险代码?
你说撒谎能帮助我们解决问题吗?好吧,记住,写下以下代码会导致以 sizeof(X)
作为参数调用 operator new()
:
X *p = new X{ /* ... */ };
让我们称这个参数为 n
。这意味着如果分配和随后的构造都成功,从客户端代码的角度来看,情况将如下所示:
图 8.1 – 从客户端代码的角度看分配的内存块
为了让 operator delete()
能够根据 p
找到 n
的值,一种策略(以及我们将为此示例采用的策略)将是将 n
的值隐藏在 p
之前。从我们自己的代码的角度来看,内存的实际布局将如下所示:
图 8.2 – 从分配操作符的角度看分配的内存块。
在这里,p
将是客户端代码中看到的地址,但p'
将是实际分配的内存块的起始位置。显然,这是谎言:分配函数返回的地址将是一个有效的地址,可以在其中构造对象,但它不会是我们实际分配的内存块的起始位置。只要p
和p'
之间的空间对operator new()
和operator delete()
都是已知的,这就可以工作。
由于显而易见的原因,重载operator new()
来完成这种技巧意味着我们必须重载operator delete()
来完成相反的体操:给定一些指针p
,在内存中向后移动到p'
的位置,找到那里隐藏的n
值,并通知Accountant
对象已经释放了n
字节。
现在,让我们看看我们将如何做到这一点。
第一个实现(几乎可行)
现在我们有一个计划,因此我们准备开始实现我们泄漏检测器的初始版本。这个实现将稍微天真,但将帮助我们理解基本思想;一旦基本基础设施到位,我们将检查实现的更微妙方面。不要在生产代码中使用这个第一个版本,因为它将是(稍微但危险地)不正确的。当然,我们将在本章的后面提供正确版本。
作为建议,在我们稍后在本章中介绍它们之前,尝试自己识别我们实现的“粗糙边缘”。这里会留下一些线索,如果你阅读了这一章之前的章节,你可能已经对应该寻找什么有了想法。
会计单例类
我们的Accountant
类将是对单例设计模式的实现,其作用是允许全局重载的内存分配操作员跟踪程序中动态分配的字节数。如前所述,单例是一个概念:在程序中只有一个实例的类。这种概念可以在各种语言中实现(至少是支持某种变体的面向对象范式的语言),并且尊重每种语言的特定性。
C++的一个关键特性是用户代码中存在实际对象,而不仅仅是对象的引用。这意味着 C++的单例通常具有以下特征:
-
一个
private
的默认构造函数,因为如果这个构造函数是public
的,它就可以被多次调用,这将使类成为一个非单例。 -
删除复制操作,因为我们允许对象的副本会使它成为一个非单例。
-
确保单例可以被创建和访问的一种方法。这种机制必须不能被滥用以创建多个对象。由于我们的默认构造函数将是
private
,这种机制将是一个static
成员函数(这将是我们选择的方式)或是一个friend
函数。 -
最后,用于对象表示的状态以及单例提供的任何服务。
我们Accountant
类的一个对象将公开三种服务:一个让new
和new[]
运算符通知Accountant
对象内存已被占用,一个通知它内存已被释放,还有一个让客户端代码知道在特定时间使用了多少内存。
到目前为止,我们对Accountant
类的理解是不完整的,如下所示:
#ifndef LEAK_DETECTOR_H
#define LEAK_DETECTOR_H
#include <cstddef>
#include <new>
class Accountant {
Accountant(); // note: private
//...
public:
// deleted copy operations
Accountant(const Accountant&) = delete;
Accountant& operator=(const Accountant&) = delete;
// to access the singleton object
static Accountant& get();
// services offered by the object
// n bytes were allocated
void take(std::size_t n);
// n bytes were deallocated
void give_back(std::size_t n);
// number of bytes currently allocated
std::size_t how_much() const;
};
// allocation operators (free functions)
void *operator new(std::size_t);
void *operator new[](std::size_t);
void operator delete(void*) noexcept;
void operator delete[](void*) noexcept;
#endif
通过这种方式,我们就可以完成本章前面提到的测试程序的框架:
#include "leak_detector.h"
#include <iostream>
int main() {
auto pre = Accountant::get().how_much();
{ // BEGIN
int *p = new int{ 3 };
int *q = new int[10]{ }; // initialized to zero
delete p;
// oops! Forgot to delete[] q
} // END
auto post = Accountant::get().how_much();
// with this code, supposing sizeof(int)==4, we
// expect to see "Leaked 40 bytes" printed
if(post != pre)
std::cout << "Leaked " << (post - pre) << " bytes\n";
}
现在,我们需要检查Accountant
类的实现。我们需要决定的第一件事是实际对象将如何以及在哪里创建。实际上,有惊人的多种方法可以做到这一点,但就我们而言(我们并不关心执行速度),正确实例化对象的最简单方法被称为Meyers 单例,以纪念现已退休但始终受到尊敬的斯科特·梅耶斯,他在其著名的书籍《Effective C++:改进您的程序和设计的具体方法(第 3 版)》的第 47 条中提出了这一技术。
Meyers 单例技术
Meyers 单例技术旨在避免俗称的静态初始化顺序灾难,这是一个非正式的名称,用来描述在由多个翻译单元组成的 C++程序中,无法从源代码中知道全局对象将被构造的顺序(这个问题也存在于销毁顺序上,尽管 Meyers 技术对此无能为力)。
技巧是将单例对象声明为提供对对象访问的static
成员函数中的static
局部变量(在这里,是get()
函数):这样做确保对象只会在函数第一次被调用时创建,并且在整个程序执行过程中保持其状态。这样做会有轻微但可测量的成本,因为对象构建周围存在一种低级隐式同步,以避免在多线程程序中对象被创建多次。
这种技术确保所有这样的单例都按正确的顺序创建(这意味着,如果单例 A 的构造函数需要单例 B 的服务,这将导致单例 B“及时”构建)即使它们在技术上被视为“全局”变量,只要创建它们的调用中没有循环当然。
在状态方面,由于take()
和give_back()
都接受std::size_t
类型的参数,所以可能会倾向于将当前内存量也表示为std::size_t
,但请允许我推荐另一种方法。实际上,std::size_t
是一个指向无符号整型的别名,这意味着这种表示法会使得检测已分配的字节数多于已释放的字节数的情况变得困难,这是我们肯定希望处理的不愉快情况。因此,我们将使用一个(较大的)有符号整型。
好吧,你可能认为:我们可以使用long long
表示法!然而,请记住,内存分配和释放机制需要是线程安全的,因此我们需要确保对那个整型表示的所有访问都将同步。有许多方法可以实现这一点,但最简单的方法可能是使用原子类型,在我们的例子中是std::atomic<long long>
。请注意,原子对象是不可复制的,所以我们的单例会隐式地不可复制,但明确地陈述这一事实并没有什么坏处,就像我们在删除复制操作时做的那样。
Accountant
类的完整实现如下:
#ifndef LEAK_DETECTOR_H
#define LEAK_DETECTOR_H
#include <cstddef>
#include <atomic>
#include <new>
class Accountant {
std::atomic<long long> cur;
Accountant() : cur{ 0LL } { // note: private
}
public:
// deleted copy operations
Accountant(const Accountant&) = delete;
Accountant& operator=(const Accountant&) = delete;
// to access the singleton object
static auto& get() { // auto used for simplicity
static Accountant singleton; // here it is
return singleton;
}
// services offered by the object
// n bytes were allocated
void take(std::size_t n) { cur += n; }
// n bytes were deallocated
void give_back(std::size_t n) { cur -= n; }
// number of bytes currently allocated
std::size_t how_much() const { return cur.load(); }
};
// allocation operators (free functions)
void *operator new(std::size_t);
void *operator new[](std::size_t);
void operator delete(void*) noexcept;
void operator delete[](void*) noexcept;
#endif
对于大多数服务来说,理解起来可能很简单。由于cur
是一个原子对象,所以像+=
或-=
这样的操作将以同步方式修改cur
,从而避免数据竞争。how_much()
函数的两个微妙之处值得简要讨论:
-
第一点是,我们返回的是
cur.load()
而不是cur
,因为我们关心的是由atomic
对象表示的值,而不是原子对象本身(它是一个同步机制,不是一个整数值,并且如前所述不可复制)。这就像在特定时间点拍摄该值的快照一样。 -
第二点,是第一点的结果,即当客户端代码获取该函数返回的值时,实际值可能已经改变,所以如果在使用多线程的情况下使用这个函数,它本质上是有风险的。当然,对于我们的测试代码来说这不是问题,但这是需要注意的一点。
现在我们已经建立了一个跟踪分配字节数的框架,我们可以开始编写实际的分配和释放函数。
实现 new 和 new[]运算符
如果您还记得我们的计划,我们在内存分配操作符中要做的就是在客户端代码请求的字节数n
的基础上稍微多分配一些,因为我们将在返回给客户端的n
字节块之前隐藏n
。最少,我们需要分配n + sizeof n
字节来实现这一点。在这个例子中,我们将使用std::malloc()
和std::free()
来执行低级分配操作。
我们将按照 C++的惯例通过抛出std::bad_alloc
来表示分配失败。如果分配成功,我们将通知Accountant
对象已分配了n
字节,尽管我们将分配得更多。我们的策略导致我们分配比请求的更多的事实是一个不影响客户端代码的副作用,甚至可能在尝试诊断问题时造成混淆:一个分配单个字节并被告知它泄漏了多得多的程序会有些尴尬。
一个完整但天真(并且稍微不正确,如前所述)的实现如下所示:
#include <cstdlib>
void *operator new(std::size_t n) {
// allocate n bytes plus enough space to hide n
void *p = std::malloc(n + sizeof n); // to revisit
// signal failure to meet postconditions if needed
if(!p) throw std::bad_alloc{};
// hide n at the beginning of the allocated block
auto q = static_cast<std::size_t*>(p);
*q = n; // to revisit
// inform the Accountant of the allocation
Accountant::get().take(n);
// return the beginning of the requested block memory
return q + 1; // to revisit
}
void *operator new[](std::size_t n) {
// exactly the same as operator new above
}
记住,尽管在这个例子中operator new()
和operator new[]()
是相同的,但在所有情况下都没有义务使它们相同。此外,请注意,本节中的一些行有注释说明“待回顾”,因为我们将在本章稍后更仔细地查看这些内容。
实现 delete 和 delete[]操作符。
我们的释放操作符将与分配操作符精心准备的谎言合作:我们知道new
和new[]
操作符返回指向一个n
字节块的指针,但那个块并不是真正分配的,它“只是”一个对象短暂居住的地方。因此,delete
和delete[]
操作符在执行实际释放之前进行必要的地址调整是很重要的。
正确实现operator delete
的规则如下:
-
在空指针上应用
operator delete()
或operator delete[]()
是一个无操作。 -
释放函数不应抛出异常。
-
释放代码应与相关的分配函数保持一致。
并非所有空指针都相同。
虽然对于某个名为p
的T*
对象,如果p==nullptr
,则写入delete p
或delete [] p
将是一个无操作。然而,写入delete nullptr
将无法编译,因为nullptr
是std::nullptr_t
类型的对象,而不是指针。
根据上一节中我们的分配操作符的实现,这意味着一个大致合适的释放操作符可能如下所示:
void operator delete(void *p) noexcept {
// delete on a null pointer is a no-op
if(!p) return;
// find the beginning of the block that was allocated
auto q = static_cast<std::size_t*>(p) - 1; // to revisit
// inform the Accountant of the deallocation
Accountant::get().give_back(*q);
// free the memory
std::free(q);
}
void operator delete[](void *p) noexcept {
// exactly the same as operator delete above
}
这样就完成了谎言,或者说,至少完成了泄漏检测器,至少对于这个第一个(并且不完美)的实现是这样。如果你在sizeof(int)==4
的编译器上运行带有我们实现的测试程序,你可以期望它显示其执行泄漏了预期的 40 字节。
可视化这一切。
当享受这种低级编程(接管程序的记忆分配函数、操作原始内存块、隐藏信息以及玩弄地址)时,很难可视化正在做什么,以及会有什么后果。
如果你喜欢的调试器允许这样做,你可能想尝试逐步执行测试程序的执行。请确保你在所谓的“调试”(非优化)模式下工作,以便充分利用这一经验,因为优化后的代码通常会被编译器充分转换,使得源代码和生成的代码之间的关联变得难以确定。
让我们一步一步地分析对 operator new()
的调用。我们首先在 main()
函数的开始处询问 Accountant
动态分配的内存量:
int main() {
auto pre = Accountant::get().how_much();
{ // BEGIN
int *p = new int{ 3 };
int *q = new int[10]{ }; // initialized to zero
delete p;
// oops! Forgot to delete[] q
} // END
auto post = Accountant::get().how_much();
if(post != pre)
std::cout << "Leaked " << (post - pre) << " bytes\n";
}
此时可以预期 pre==0
,但存在一些情况,例如全局对象在其构造函数中调用 new
,这可能导致 pre
有其他值。这是可以的,因为我们通过这种方法监控的是在 BEGIN
和 END
标记的大括号之间是否存在内存泄漏,而这应该与那些大括号外分配的字节数是否为零无关。
下一步是调用 operator new()
并请求一个足够存储一个 int
对象的内存块:
int main() {
auto pre = Accountant::get().how_much();
{ // BEGIN
int *p = new int{ 3 };
int *q = new int[10]{ }; // initialized to zero
delete p;
// oops! Forgot to delete[] q
} // END
auto post = Accountant::get().how_much();
if(post != pre)
std::cout << "Leaked " << (post - pre) << " bytes\n";
}
这引导我们到我们的 operator new()
实现中,其中 n==sizeof(int)
。为了这个例子,假设 sizeof(int)==4
和 sizeof(std::size_t)==8
,我们的 std::malloc()
调用将请求至少 12 字节的内存块:
void *operator new(std::size_t n) {
void *p = std::malloc(n + sizeof n);
if(!p) throw std::bad_alloc{};
auto q = static_cast<std::size_t*>(p);
*q = n;
Accountant::get().take(n);
return q + 1;
}
当 std::malloc()
调用完成后,如果你用调试器查看 p
所指向的内存,可能会看到以下内容(所有数字均以十六进制形式表示):
图 8.3 – 分配块的可能的初始状态
注意,你看到的这些特定值没有保证,因为 C++ 对 std::malloc()
返回的内存块的初始化没有提出任何要求。然而,当使用“调试构建”时,这些 0xcd
十六进制值(或类似的可识别模式)是可能的,因为为调试编译的库通常会在未初始化的内存中放置可识别的位模式,以帮助检测编程错误。
你可能还会注意到尾部的四个字节(每个都包含 0xfd
),这些字节也令人可疑地可识别,表明我使用的 std::malloc()
实现分配了比请求的更多的内存,并在我的代码请求的块之后存储了一个标记,可能是为了帮助检测缓冲区溢出。毕竟,我们的库和我们一样有相同的实现自由度!
我们做的第一个错误是关于实际请求的内存分配超量。现在,我们对所指向内存的本质又撒了一个谎:
void *operator new(std::size_t n) {
void *p = std::malloc(n + sizeof n);
if(!p) throw std::bad_alloc{};
auto q = static_cast<std::size_t*>(p);
*q = n;
Accountant::get().take(n);
return q + 1;
}
如同在 第三章 中解释的那样,使用 static_cast
可以有效地将指针从或转换为 void*
。我们现在对同一内存块有两个视角,p
声称该块包含原始内存,而 q
声称(错误地)它至少包含一个 std::size_t
:
图 8.4 – 同一内存块的两个视角
通过 q
,我们在分配的内存块的开头隐藏了 n
的值。记住,这不是我们将返回给调用者的内容,因此这是在不让客户端代码知道的情况下完成的:
void *operator new(std::size_t n) {
void *p = std::malloc(n + sizeof n);
if(!p) throw std::bad_alloc{};
auto q = static_cast<std::size_t*>(p);
*q = n;
Accountant::get().take(n);
return q + 1;
}
p
和 q
指向的内存的一个可能视图现在如下所示:
图 8.5 – 隐藏 n 值后内存块的可能状态
再次强调,你的视图可能与这个不同:我们写入了一个八字节的整数值,这解释了受此写入影响的连续字节数,但整数字节的顺序取决于底层硬件架构:一些架构的 4
位更接近右侧,而不是像这个例子中这样在左侧。
在通知 Accountant
我们分配了 4 个字节(而不是 12 个,记住)之后,我们到达了返回到调用者的 4 字节块的实际请求开始点:
void *operator new(std::size_t n) {
void *p = std::malloc(n + sizeof n);
if(!p) throw std::bad_alloc{};
auto q = static_cast<std::size_t*>(p);
*q = n;
Accountant::get().take(n);
return q + 1;
}
看看我们的内存块,现在的情况如下:
图 8.6 – 返回点内存块的状态
返回调用者时,int
对象的构造函数应用于 operator new()
返回的块:
int main() {
auto pre = Accountant::get().how_much();
{ // BEGIN
int *p = new int{ 3 };
int *q = new int[10]{ }; // initialized to zero
delete p;
// oops! Forgot to delete[] q
} // END
auto post = Accountant::get().how_much();
if(post != pre)
std::cout << "Leaked " << (post - pre) << " bytes\n";
}
在 main()
中对 p
指向的内存应用构造函数后,我们的内存块看起来如下:
图 8.7 – 构建 *p 后内存块的可能状态
Voilà!所有这一切的美丽之处在于,客户端代码(main()
函数)根本不知道我们玩弄了这些诡计并执行了这些谎言,就像我们真的不知道 std::malloc()
为我们做了哪些其他诡计一样(除非我们能查看其源代码,当然)。程序执行继续正常,*p
可以像任何其他 int
一样使用,直到我们决定释放它:
int main() {
auto pre = Accountant::get().how_much();
{ // BEGIN
int *p = new int{ 3 };
int *q = new int[10]{ }; // initialized to zero
delete p;
// oops! Forgot to delete[] q
} // END
auto post = Accountant::get().how_much();
if(post != pre)
std::cout << "Leaked " << (post - pre) << " bytes\n";
}
当进入 operator delete()
时,你可能会注意到由 p
参数指向的内存以值 3
(int
的值)开始,而不是值 4
。这合乎逻辑,因为 p
指向的是客户端代码获得的内存块,而不是我们实际分配的块的开始:
图 8.8 – 销毁前的内存块状态(调用者视角)
在继续之前,重要的是要理解这里你看到 3
的原因可能是 int
是一个平凡可销毁的类型,所以它的析构函数实际上是一个空操作。通常,在 operator delete()
开始执行的时候,指向的对象的析构函数已经运行,内存块可能包含几乎所有内容。
在 operator delete()
中,我们的第一个任务是检索在相应的 operator new()
调用期间我们隐藏的 n
值的位置:
void operator delete(void *p) noexcept {
if(!p) return;
auto q = static_cast<std::size_t*>(p) - 1;
Accountant::get().give_back(*q);
std::free(q);
}
在这一点上,q
是存储 n
值的位置,也是分配的内存块开始的地方。我们通知 Accountant
有 n
字节被释放,并调用 std::free()
来执行实际的释放操作。
如果你正在观察调用 std::free()
时由 q
指向的内存,那么你可能会看到该内存被写入(但这并非保证会发生)。同样,你也有可能看到在 q
之前以及你分配的字节内存块结束之后被写入的内存。记住,std::free()
,就像 std::malloc()
一样,可以执行它需要的任何账目管理任务,就像它可以覆盖已经释放的内存块一样,尤其是在为调试而构建的情况下;或者,它也可以让内存保持原样,这在优化构建中更为常见。
那很有趣,不是吗?它确实看起来是可行的,至少在某些机器上。然而,正如之前所述,我们这个泄漏检测器的这个版本有 bug,这些 bug 可能会给我们带来真正的麻烦。作为一个提示,要知道如果我们在一个 std::size_t
是四字节宽的编译器上编译这个泄漏检测器,并尝试调用 new double
,我们可能会遇到非常严重的问题。现在是我们更仔细地审视我们的实现,以了解为什么会出现这种情况并修复我们造成的问题的时候了。
识别(并修复)问题
我们的初始实现实际上有一个真正的问题,以及一些工作正常但可以更简洁且值得讨论的问题。
真正的问题是我们在危险的方式中表达我们的谎言,并且我们没有充分考虑对齐要求。确实,看看我们 operator new()
的初始实现:
void *operator new(std::size_t n) {
// allocate n bytes plus enough space to hide n
void *p = std::malloc(n + sizeof n); // to revisit
// signal failure to meet postconditions if needed
if(!p) throw std::bad_alloc{};
// hide n at the beginning of the allocated block
auto q = static_cast<std::size_t*>(p);
*q = n; // to revisit
// inform the Accountant of the allocation
Accountant::get().take(n);
// return the beginning of the requested block memory
return q + 1; // to revisit
}
我们确实知道 std::malloc()
返回的内存必须适当地对齐,以适应我们机器的最严格(意味着最坏)的自然对齐:确实,由于该函数不知道分配完成后将构造什么对象,它必须确保分配的内存块在所有“自然”情况下都得到适当的对齐。C++ 编译器提供 std::max_align_t
作为机器上具有最严格自然对齐的类型的一个别名,在实践中这通常是,但不一定是 double
类型。
现在,我们分配比请求的更多一点,精确地说,比请求的sizeof(std::size_t)
更多字节。这在某种程度上是可以接受的:我们可以确信在std::malloc()
返回的块的开头存储std::size_t
,因为即使在最坏的情况下,这个块也是正确对齐的。
然后,我们“跳过”std::size_t
,并返回一个比我们分配的地址多sizeof(std::size_t)
字节的地址。如果即使在最坏的情况下仍然产生正确对齐的地址,这可能还是可以接受的,但这只有在std::size_t
和std::max_align_t
具有相同大小的情况下才成立,这是不保证的(在实践中,它们的大小通常不同)。
如果这些类型的大小不同,并且因此operator new()
返回的地址不匹配std::max_align_t
的对齐要求,会发生什么?好吧,这取决于:
-
如果我们“幸运”地得到一个正确对齐的地址,那么它就可以工作。例如,假设
alignof(int)==4
和alignof(std::max_align_t)==8
,那么调用new int
将可以工作,即使operator new
返回的地址是 4 的倍数但不是 8 的倍数。然而,调用new double
可能只会带来痛苦。这种“幸运”可能是一种诅咒,隐藏一个潜在的、破坏性的错误一段时间,并在以后带来不愉快的惊喜。 -
你可能会得到缓慢且危险的代码,因为某些硬件将支持访问未对齐的对象。然而,你并不想这样做,因为为了实现这一点,机器需要执行杂技般的操作,将看似简单的操作,例如在寄存器中加载
double
,转换成一系列操作(加载“低”字节,加载“高”字节,并通过位操作从这两部分中创建一个double
)。这导致代码执行速度显著减慢,显然,如果是一个多线程程序,还可能变得危险,因为一个线程可能会读取一个部分形成的对象(这被称为撕裂读取)或写入一个部分形成的对象(一个撕裂写入)。你真的不希望调试发生这种情况的代码。 -
你的代码可能会简单地崩溃,就像在许多嵌入式平台(包括相当多的游戏机)上发生的那样。在这种情况下,这可能是最合理的结果。
为了解决这个问题,我们需要确保从我们的重载operator new()
返回的地址对std::max_align_t
是正确对齐的,并且operator delete()
相应地调整。一种方法是通过确保“隐藏区域”的大小,使得跳过额外的内存块仍然导致一个对std::max_align_t
对象正确对齐的地址:
void *operator new(std::size_t n) {
// allocate n bytes plus enough space to hide n,
// taking worst case natural alignment into account
void *p = std::malloc(sizeof(std::max_align_t) + n);
// signal failure to meet postconditions if needed
if(!p) throw std::bad_alloc{};
// hide n at the beginning of the allocated block
*static_cast<std::size_t*>(p) = n; // to revisit
// inform the Accountant of the allocation
Accountant::get().take(n);
// return the beginning of the requested block memory
return static_cast<std::max_align_t*>(p) + 1;
}
如您所见,此实现除了为请求的n
字节分配空间外,还为std::max_align_t
分配空间,然后“跳过”额外的存储空间,从而得到一个在最坏情况下仍然正确对齐的地址。如果sizeof(std::size_t)
恰好小于sizeof(std::max_align_t)
,这可能会意味着比初始(错误)实现浪费更多的空间,但至少我们知道客户端代码能够在那里构造其对象。
相应的operator delete()
将执行相同的指针体操,但方向相反,回退sizeof(std::max_align_t)
字节:
void operator delete(void *p) noexcept {
// delete on a null pointer is a no-op
if(!p) return;
// find the beginning of the block that was allocated
p = static_cast<std::max_align_t*>(p) - 1;
// inform the Accountant of the deallocation
Accountant::get().give_back(
*static_cast<std::size_t*>(p)
);
// free the memory
std::free(p);
}
注意,此实现将std::max_align_t*
赋值给void*
(指针p
),这是完全合法的,不需要进行类型转换。
我们应该讨论的另一个问题在这个实现中不是技术问题,但在一般情况下是问题。看看operator new()
的以下摘录:
void *operator new(std::size_t n) {
void *p = std::malloc(n + sizeof(std::max_align_t));
if(!p) throw std::bad_alloc{};
// hide n at the beginning of the allocated block
*static_cast<std::size_t*>(p) = n; // to revisit
Accountant::get().take(n);
return static_cast<std::max_align_t*>(p) + 1;
}
您注意到有什么奇怪的地方吗?高亮显示的代码行在p
指向的位置执行赋值操作,但这个赋值只有在现有对象上才有意义。在那个时刻,位置*p
处有一个对象吗?
答案是…奇怪。要创建一个对象,必须调用其构造函数,但我们在代码中的位置p
从未调用std::size_t
的构造函数。这可能会让您想知道为什么我们的代码似乎能正常工作。实际上情况是这样的:
-
C++中有些类型被称为
std::nullptr_t
,包括它们的 cv-限定版本)和隐式生命周期类(没有用户提供的析构函数的聚合体,至少有一个合格的平凡构造函数以及一个非删除的平凡析构函数)。您会注意到,std::size_t
作为一个无符号整型别名的别名,属于隐式生命周期类型的范畴。如果您有一个 C++23 编译器,您可以通过std::is_implicit_lifetime<T>
特性来编程测试某些类型T
是否符合隐式生命周期类型。 -
一些标准库函数隐式地开始隐式生命周期类型的对象的寿命。这包括一些 C 函数,如
std::memcpy()
、std::memmove()
和std::malloc()
,还包括std::bit_cast
、分配器中的某些函数(参见第十四章)以及 C++23 中的两个函数,分别命名为std::start_lifetime_as()
和std::start_lifetime_as_array()
。
使这个赋值操作在这个特定情况下工作的是,我们正在向一个隐式生命周期类型的对象所在的内存块中写入,该内存块是正确对齐的,并且是用具有隐式开始对象寿命特性的这些特殊函数之一分配的。如果我们决定存储比某些隐式生命周期类型的对象更复杂的东西,我们的赋值要么在编译时失败(如果我们的编译器足够好,能注意到我们的错误),要么在运行时造成损害的风险。
一种更好、通常也更安全的隐藏n
值在未初始化存储中的方法,是使用placement new
,正如在第七章中所述。因此,以下operator new()
的实现通常更可取,因为它避免了(通常是错误的)对一个非对象的赋值:
void *operator new(std::size_t n) {
void *p = std::malloc(n + sizeof(std::max_align_t));
if(!p) throw std::bad_alloc{};
// hide n at the beginning of the allocated block
new (p) std::size_t{ n };
Accountant::get().take(n);
return static_cast<std::max_align_t*>(p) + 1;
}
注意,由于std::size_t
有一个平凡的析构函数,因此不需要在operator delete()
中调用它的析构函数;只需释放其底层存储就足够了,这样就可以结束它的生命周期。现在我们有一个正确、有效的泄漏检测器!
回顾我们的实现(以及学到的教训)
我们只是重载了内存分配运算符,公然绕过了类型系统的保护,执行了可能危险的操作,这些操作可能导致对齐错误的对象,并看到了如何避免这个陷阱。这确实是一次有趣的冒险,但作为一位敏锐的读者,你可能会想知道这个技巧的成本,特别是它在内存消耗方面的成本。
使用我们“分配多于请求的量并在开头隐藏n
”的方法,每次分配都会比客户端代码需要的多消耗sizeof(std::max_align_t)
个字节。如果我们分配大对象,这种成本可能微不足道,但如果我们分配小对象,这种开销可能是不合理的,并且可能主导我们整个程序的内存消耗。
记住从第七章中提到的,C++14 使得提供接受刚刚销毁的对象大小作为参数的operator delete()
重载成为可能。这使得在operator new()
期间隐藏n
的行为变得冗余,因为我们这样做正是为了在operator delete()
中检索n
,而我们现在不再需要这样做。
由于我们不需要隐藏n
,我们可以简化我们的实现并显著减少我们的内存消耗:
void *operator new(std::size_t n) {
// allocate n bytes (no need for more!)
void *p = std::malloc(n);
// signal failure to meet postconditions if needed
if(!p) throw std::bad_alloc{};
// inform the Accountant of the allocation
Accountant::get().take(n);
// return the beginning of the requested block memory
return p;
}
void *operator new[](std::size_t n) {
// exactly the same as operator new above
}
void operator delete(void *p, std::size_t n) noexcept {
// delete on a null pointer is a no-op
if(!p) return;
// inform the Accountant of the deallocation
Accountant::get().give_back(n);
// free the memory
std::free(p);
}
void operator delete[](void *p, std::size_t n) noexcept {
// exactly the same as operator delete above
}
这个泄漏检测器仍然有效,并且与它之前更天真的版本相比,代表了一个严格的升级。
摘要
这很有趣,不是吗?你可以使用这个非常简单的工具让它更有趣:例如,你可以用它来检查分配的内存块的前后注入哨兵值,以检查溢出和下溢,或者你可以用它来制作你内存使用方式的某种映射。
这就结束了我们对利用我们可用的内存分配设施的应用程序的第一轮探索。我们的下一步,也是下一章,将引导我们探讨一个 C++程序如何与典型内存或处理典型分配情况交互。
当然,没有任何一种编程语言(即使是像 C++这样多功能和广泛的编程语言)能够声称涵盖操作系统可能提供服务的所有可能的内存类型,也不应该是这种语言的角色。然而,正如我们将看到的,C++为我们提供了构建桥梁所需的“语法粘合剂”,以连接非典型需求与程序的其他部分。
第九章:非典型分配机制
我们在使用 C++ 进行内存管理方面的探索中取得了进展。在第七章中,我们探讨了可以通过哪些语法方式来重载 operator new()
和 operator delete()
(以及它们的数组对应物),而在第八章中,我们编写了一个实际的真实例子(一个内存泄漏检测器),这个例子依赖于编写这样的重载的能力。这是一个很好的开始,具体地展示了这些知识有实际的应用,但你可能会(正确地)想知道在控制内存管理功能时我们还能做些什么。
本章将与其他章节略有不同。在这里,我们将展示一系列非详尽的方法,说明如何通过控制 C++ 的内存分配函数来受益。更确切地说,我们将展示以下内容:
-
如何使用 placement
new
高效地驱动内存映射硬件 -
如何通过
operator new()
的nothrow
版本简化错误管理 -
如何安装和使用
std::new_handler
来使处理内存不足情况变得更加容易 -
如何通过标准 C++ 的中介处理“奇特”的内存,如共享内存或持久内存
在本章结束时,我们将对 C++ 基本内存分配功能为我们提供的机遇有一个更广阔的视角。后续章节将回到更具体的话题,例如基于区域的分配(第十章)、延迟回收(第十一章),以及在后续章节中,如何使用容器和分配器来控制内存分配。
技术要求
你可以在本书的 GitHub 仓库中找到本章的代码文件:github.com/PacktPublishing/C-Plus-Plus-Memory-Management/tree/main/chapter9
。
Placement new 和内存映射硬件
placement new
(如你可能记得,在第 7 章 中讨论的一个重要特性)有许多用途,但其中一个特别有趣的使用是它允许我们将软件对象映射到内存映射硬件,从而有效地允许我们像操作软件一样驱动硬件。
要编写一个这个特性的工作示例会相当棘手,因为我们可能会发现自己处于“非可移植代码地带”,使用操作系统特定的功能来获取特定设备的地址,并讨论获取通常由软件驱动程序访问的内存位置的读写权限的方法。因此,我们将构建一个人工但具有说明性的示例,并要求您,尊敬的读者,想象这个示例中缺失的部分。
首先,假设我们正在开发一个新显卡的驱动程序,这款显卡非常出色,其代号为super_video_card
。为了说明这一点,我们将通过以下类来模拟:
#include <cstdint>
class super_video_card {
// ...
public:
// super duper registers
volatile std::uint32_t r0{}, r1{}, r2{}, r3{};
static_assert(sizeof(float) == 4); // sanity check
volatile float f0{}, f1{}, f2{}, f3{};
// etc.
// initialize the video card's state
super_video_card() = default;
super_video_card(const super_video_card&) = delete;
super_video_card&
operator=(const super_video_card&) = delete;
// could be used to reset the video card's state
~super_video_card() = default;
// various services (omitted for brevity)
};
// ...
对于我们的目的,这个类的重要方面如下:
-
它是一个不可复制的类型,因为它旨在映射到特定的内存区域。复制此类对象至少是无效的。
-
它被设计成这样的方式,其状态在概念上可以叠加到其硬件等价物上。例如,给定前面的类声明,从硬件内存布局的开始处开始,我们期望有四个 32 位整数寄存器,然后是四个 32 位浮点寄存器。我们使用了
<cstdint>
来获取我们编译器上固定宽度整数类型的别名。 -
在这种情况下,我们应该通过
static_assert
来表述我们的期望。此外,由于硬件寄存器的状态可以通过我们程序之外的其他操作而改变,我们将寄存器等价物标记为volatile
,这样对这些成员变量的访问将等同于 C++抽象机中的 I/O 操作。
为什么我们在这个例子中使用volatile
变量?
如果你不太习惯volatile
变量,你可能会想知道为什么我们在内存映射硬件表示类的数据成员上使用了这个限定符。这样做之所以重要,是因为我们希望避免编译器基于(在这种情况下是错误的)假设来优化代码,即如果我们的代码没有触摸这些变量,那么它们的状态不会改变,或者如果我们的代码中对这些变量的写入没有跟随读取,那么可以假设没有效果。通过volatile
限定的变量,我们实际上在告诉编译器“这里有一些你不知道的事情在这些对象上发生,所以请不要假设太多。”
为了简单起见,我们使用了一个将数据成员清零的构造函数和一个平凡的析构函数,但在实践中,我们本可以使用构造函数(默认或其他)来初始化内存映射设备的状态以符合我们的需求,并使用析构函数将设备状态重置为某种可接受的状态。
通常,为了程序能够访问内存映射的硬件,我们可能会通过操作系统提供的服务与操作系统通信,这些服务接受作为参数的所需信息来识别我们寻求地址的设备。在我们的情况下,我们将简单地让它看起来我们可以访问一个正确大小和对齐的内存区域,我们可以从中读取和写入。内存地址以原始内存(void*
类型)的形式暴露出来,这是在类似情况下我们可以从操作系统函数中合理期望的:
// somewhere in memory where we have read / write
// access privileges is a memory-mapped hardware
// that corresponds to the actual device
alignas(super_video_card) char
mem_mapped_device[sizeof(super_video_card)];
void* get_super_card_address() {
return mem_mapped_device;
}
// ...
然后,我们到达了如何使用放置new
将对象映射到某些内存映射硬件位置的方法。请注意,我们需要包含<new>
头文件,因为这是放置new
定义的地方。达到我们目标的方法如下:
-
首先,获取我们想要映射我们精心制作的
super_video_card
对象的地址。 -
然后,通过在该地址的放置
new
,构造一个super_video_card
对象,使得该对象的数据成员对应于它们所代表的寄存器的地址。 -
在该对象的生命周期内,通过相应的指针(以下代码摘录中的
the_card
变量)使用该对象。 -
当我们完成工作后,我们最不想做的事情就是在
the_card
上应用operator delete()
,因为我们一开始就没有分配相关的内存。然而,我们确实希望通过~super_video_card()
来最终化这个对象,以确保运行该对象的清理或重置代码(如果有的话)。
因此,我们得到了以下结果:
// ...
#include <new>
int main() {
// map our object to the hardware
void* p = get_super_card_address();
auto the_card =
new(p) super_video_card{ /* args */ };
// through pointer the_card, use the actual memory-
// mapped hardware
// ...
the_card->~super_video_card();
}
如果显式析构函数调用是一个问题,例如在可能抛出异常的代码中,我们可以使用一个带有自定义删除器的std::unique_ptr
对象来最终化super_video_card
对象(参见第五章):
// ...
#include <new>
#include <memory>
int main() {
// map our object to the hardware
void* p = get_super_card_address();
std::unique_ptr<
super_video_card,
decltype([](super_video_card *p) {
p->~super_video_card(); // do not call delete p!
})
> the_card {
new(p) super_video_card{ /* args */ }
};
// through pointer the_card, use the actual memory-
// mapped hardware
// ...
// implicit call to the_card->~super_video_card()
}
在这种情况下,std::unique_ptr
对象最终化了指针(即super_video_card
对象),但没有释放其内存存储,这使得在the_card
变量生命周期中存在异常时,代码更加健壮。
简化 nothrow new 的使用
如第七章所述,当operator new()
无法执行分配请求时,其默认行为是抛出异常。这可能是由于内存不足或其他无法满足分配请求的情况,在这种情况下,通常抛出std::bad_alloc
;由于数组长度不正确(例如,一个长度为负的一维数组超过了实现定义的限制),通常会导致抛出std::bad_array_new_length
;或者由于在operator new()
完成后未能完成对象的后续构造,在这种情况下,将被抛出的异常将是来自失败构造函数的任何异常。
异常是 C++函数表示未能满足函数后置条件的“正常”方式。在某些情况下,例如构造函数或重载运算符,这是唯一真正可行的方式:构造函数没有返回值,并且重载运算符的函数签名通常没有为额外的参数或错误报告返回值留下空间,尽管对于某些类型(如std::optional
或std::expected
)可以提出一些重载运算符使用情况的替代方案。
当然,有些领域通常不使用异常:例如,许多视频游戏在没有异常支持的情况下编译,同样,为嵌入式系统编写的许多程序也是如此。提出的原因从技术上的(对内存空间消耗、执行速度或两者都视为不希望的开销的恐惧)到更哲学上的(不喜欢被视为隐藏的控制路径),但无论原因是什么,事实是,没有异常支持的 C++代码确实存在,nothrow
版本的operator new()
是一个现实。
当然,这也意味着即使是看似简单的代码,如以下所示,也可能导致未定义行为(UB):
#include <new>
#include <iostream>
struct X {
int n;
X(int n) : n { n } { }
};
int main() {
auto p = new (std::nothrow) X{ 3 };
std::cout << p->n; // <-- HERE
delete p;
}
这种潜在的不确定行为(UB)的原因是,如果operator new()
的nothrow
版本失败(虽然不太可能,但并非不可能,尤其是在内存受限的情况下),那么p
将会是空指针,通过p
访问n
数据成员将是一个非常糟糕的想法。
当然,解决方案很简单,鉴于你是一位敏锐的读者,你可能已经注意到了:在使用它之前先测试指针!当然,这在这里是有效的,如下所示:
#include <new>
#include <iostream>
struct X {
int n;
X(int n) : n { n } { }
};
int main() {
auto p = new (std::nothrow) X{ 3 };
if(p) {
std::cout << p->n; // ...use *p as needed...
}
delete p; // fine even in p is null
}
这种方法的缺点是代码很快就会充满测试,因为程序中很少只有一个指针,这提醒我们,使用异常的代码之美在于不需要担心这些测试。使用异常,要么operator new()
和随后的构造都成功了,可以自信地使用结果指针,要么这些步骤中有一个失败了,代码执行没有达到可能陷入麻烦的点:
#include <new>
#include <iostream>
struct X {
int n;
X(int n) : n { n } { }
};
int main() {
auto p = new X{ 3 }; // throws if operator new() or
// X::X(int) fails
std::cout << p->n; // ...use *p as needed...
delete p;
}
当然,即使有异常,也可能遇到麻烦,例如,如果存在一个执行路径让p
保持为空或未初始化,而其他路径则不会发生这种情况(你通常可以通过在声明时初始化对象来避免这种情况,但这并不总是可能的);让我们现在暂时将这些代码卫生考虑放在一边,因为它们会偏离我们感兴趣的主题。
面对分配失败的情况时,一个重要的考虑是当它发生时应该做什么。无论我们的代码库是否使用异常,我们很可能不希望程序执行继续,从而通过诸如不正确使用空指针之类的操作导致未定义行为(UB)。
在失败分配点停止执行的一种常见方法是在某些代码结构中包装尝试分配和构造操作、对结果指针的后续测试以及如果指针为空要采取的行动。我们想要包装的代码可能如下所示,假设我们想要分配并构造一个int
对象:
// ...
int *p = new int{ 3 };
if(!p) std::abort(); // for example
return p;
// ...
此代码使用std::abort()
作为结束程序执行的机制;异常会给我们提供可能可恢复的错误,但没有异常,我们可用的大多数标准机制都会导致程序终止,在这种情况下,std::abort()
是一个合理的选择。
结束程序执行的方式
C++程序可以以许多不同的方式结束:到达main()
函数的末尾是最明显的一种,但还有其他例子。例如,std::exit()
用于带有清理步骤的正常程序终止;std::quick_exit()
用于不带清理步骤的程序终止。可以使用std::atexit()
和std::at_quick_exit()
注册一些在退出前要调用的函数,而std::abort()
用于在没有清理步骤的情况下发出程序异常终止的信号。当在文档列表中的某些不愉快情况发生时(这个列表包括从static
变量的构造函数或noexcept
函数体中抛出的异常等情况),使用std::terminate()
函数。在我们的情况下,唯一真正适合的机制是std::abort()
。
解决此问题的一个可能方法是使用一个宏和一个立即调用的函数表达式(IIFE),这是对一个匿名 lambda 表达式所构成的、立即创建、执行和丢弃的表达式的称呼。为了使我们的解决方案通用,我们需要能够做到以下几步:
-
指定要创建的对象类型
-
使宏可变参数,因为我们需要能够将任何类型和数量的参数传递给对象的构造函数
这样一个宏的可能实现是TRY_NEW
,如下所示:
#include <new>
#include <cstdlib>
#define TRY_NEW(T,...) [&] { \
auto p = new (std::nothrow) T(__VA_ARGS__); \
if(!p) std::abort(); \
return p; \
}()
struct dies_when_newed {
void* operator new(std::size_t, std::nothrow_t) {
return {};
}
};
int main() {
// p0 is int*, points to an int{ 0 }
auto p0 = TRY_NEW(int);
// p1 is int*, points to an int{ 3 }
auto p1 = TRY_NEW(int, 3);
auto q = TRY_NEW(dies_when_newed); // calls abort()
}
并非每个人都熟悉可变参数宏,所以让我们一步一步来:
-
我们宏的“签名”是
TRY_NEW(T,...)
,这意味着T
是必需的,而...
可以是任何数量的标记(包括一个都没有),由逗号分隔。不出所料,我们将使用T
来表示要构造的类型,而...
用于传递给将被调用的构造函数的参数。 -
由于我们为了可读性将宏写在了多行上,除了最后一行外,每一行都以一个空格后跟一个反斜杠结束,以通知预处理器它应该在下一行继续解析。
-
...
上的符号通过名为__VA_ARGS__
的特殊宏进行中继,该宏展开为...
包含的内容,如果...
本身为空,则可以是空的。这在 C 和 C++中都有效。请注意,我们在构造函数调用中使用括号而不是花括号,因为我们想避免无意中构建一个初始化列表,如果__VA_ARGS__
的所有元素都是同一类型的话。 -
我们测试由调用
operator new()
的std::nothrow
版本产生的p
指针,如果p
为空,则调用std::abort()
。 -
如前所述,整个操作序列被一个立即执行函数表达式(IIFE)包裹,并返回新分配的指针。请注意,如果我们愿意,我们也可以从那个 lambda 表达式返回一个
std::unique_ptr<T>
对象。另外,请注意,这个 lambda 表达式使用了一个[&]
捕获块来确保在 lambda 的作用域内__VA_ARGS__
中的标记可用。
一个小但有趣的影响
注意,由于我们使用了括号(同样适用于花括号),一个空的__VAR_ARGS__
将导致这个宏将基本类型(如int
)初始化为零,而不是将它们留作未初始化。您可以比较:截至 C++23,new int;
产生一个指向未初始化int
对象的指针,但new int();
和new int{};
都将分配的块初始化为零。这有一个优点,就像这个宏一样,即使对于平凡类型,我们也不会得到一个指向未初始化对象的指针。然而,也有一个缺点,因为我们甚至在不必要的情况下也要为初始化付费。
另一种方法可能是使用变长参数函数模板,这在实践中可能会带来更好的调试体验。它的客户端代码看起来略有不同,但在使用和效果上与其他类似:
#include <new>
#include <cstdlib>
#include <utility>
template <class T, class ... Args>
auto try_new(Args &&... args) {
auto p =
new (std::nothrow) T(std::forward<Args>(args)...);
if(!p) std::abort();
return p;
}
struct dies_when_newed {
void* operator new(std::size_t, std::nothrow_t) {
return {};
}
};
int main() {
// p0 is int*, points to an int{ 0 }
auto p0 = try_new<int>();
// p1 is int*, points to an int{ 3 }
auto p1 = try_new<int>(3);
auto q = try_new<dies_when_newed>(); // calls abort()
}
可变参数函数版本的调用语法看起来像是一个类型转换,传递给try_new()
的参数被完美转发到T
的构造函数中,以确保最终调用预期的构造函数。就像宏的情况一样,我们可以选择用这个函数返回一个std::unique_ptr<T>
对象,而不是T*
对象。
内存不足的情况和 new_handler
到目前为止,包括本章在内,我们已声明operator new()
和operator new[]()
在无法分配内存时通常会抛出std::bad_alloc
异常。这在很大程度上是正确的,但我们之前避免了一个细微之处,现在我们将花些时间和精力来关注它。
想象一种情况,用户代码已经专门化了内存分配函数,以便从具有有趣性能特性的预分配数据结构中获取内存块。假设这个数据结构最初为少量块分配空间,然后在用户代码耗尽初始分配的块之后继续分配更多空间。换句话说:在这种情况下,我们有一个初始的快速设置(让我们称它为“乐观”状态)和一个次要设置(让我们称它为“第二次机会”状态),允许用户代码在“乐观”状态的资源耗尽后继续分配。
为了使此类场景无缝,在不显式干预用户代码的情况下实现透明的分配策略更改,仅显式抛出std::bad_alloc
是不够的。抛出会完成operator new()
的执行,客户端代码可以捕获异常并采取行动,当然,但在这种(合理的)场景中,我们希望分配失败导致采取某些行动,并且operator new()
在更新后的状态(如果有的话)下再次尝试。
在 C++中,此类场景通过std::new_handler
来处理,它是类型为void(*)()
的函数指针的别名。需要了解的是以下内容:
-
程序中有一个全局的
std::new_handler
,默认情况下其值为nullptr
。 -
可以通过
std::set_new_handler()
函数设置活动的std::new_handler
,并且可以通过std::get_new_handler()
函数获取活动的std::new_handler
。请注意,为了方便起见,std::set_new_handler()
返回正在被替换的std::new_handler
。 -
当一个分配函数,如
operator new()
失败时,它应该首先获取活动的std::new_handler
。如果该指针为空,则分配函数应该抛出std::bad_alloc
,就像我们迄今为止所做的那样;否则,它应该调用该std::new_handler
并在新条件下重试。
如预期的那样,你的标准库应该已经实现了这个算法,但我们的operator new()
和operator new[]()
的重载函数还没有这样做,至少到目前为止是这样。为了展示如何从std::new_handler
中受益,我们现在将实现上述两步场景的人工版本。
这个玩具实现将使用某些X
类型的分配操作符的成员版本,并表现得好像我们最初有足够内存来存储该类型的limit
个对象(通常,我们实际上会管理这些内存,你可以在第十章中看到一个这样的管理示例,我们将提供一个更现实的例子)。我们将安装一个std::new_handler
,当被调用时,将limit
改为一个更大的数字,然后重置活动处理程序为nullptr
,这样后续尝试分配X
对象失败将导致抛出std::bad_alloc
:
#include <new>
#include <vector>
#include <iostream>
struct X {
// toy example, not thread-safe
static inline int limit = 5;
void* operator new(std::size_t n) {
std::cout << "X::operator new() called with "
<< limit << " blocks left\n";
while (limit <= 0) {
if (auto hdl = std::get_new_handler(); hdl)
hdl();
else
throw std::bad_alloc{};
}
--limit;
return ::operator new(n);
}
void operator delete(void* p) {
std::cout << "X::operator delete()\n";
::operator delete(p);
}
// same for the array versions
};
int main() {
std::set_new_handler([]() noexcept {
std::cout << "allocation failure, "
"fetching more memory\n";
X::limit = 10;
std::set_new_handler(nullptr); // as per default
});
std::vector<X*> v;
v.reserve(100);
try {
for (int i = 0; i != 10; ++i)
v.emplace_back(new X);
} catch(...) {
// this will never be reached with this program
std::cerr << "out of memory\n";
}
for (auto p : v) delete p;
}
注意X::operator new()
处理失败的方式:如果它注意到它将无法满足其后续条件,它会获取活动的std::new_handler
,如果它不为空,则在再次尝试之前调用它。这意味着当std::new_handler
被调用时,它必须以某种方式改变情况,使得后续的尝试分配可以成功,或者将std::new_handler
改为nullptr
,这样失败将导致抛出异常。不遵守这些规则可能导致无限循环,并随之而来的是许多悲伤。
在 main()
中为这个玩具示例安装的处理程序执行以下操作:当被调用时,它改变执行分配的条件(它增加了 X::limit
的值)。然后,它使用 nullptr
调用 std::set_new_handler()
,因为我们没有计划在“乐观”和“第二次机会”情况之后采取另一种方法,所以如果我们耗尽了第二次机会的资源,我们(正如他们所说)就完蛋了。
lambda 作为 new_handler?
您可能已经注意到,我们将 std::new_handler
类型描述为 void(*)()
类型函数指针的别名,然而在我们的玩具示例中,我们安装了一个 lambda。为什么这行得通?好吧,碰巧无状态的 lambda——一个空的捕获块的 lambda 表达式——可以隐式转换为具有相同调用签名的函数指针。这在许多情况下都是很有用的,比如当编写与 C 代码或操作系统 API 交互的 C++ 代码时。
我们现在即将进入本章的一个奇怪且相当技术性的部分,我们将看到如何利用 C++ 来处理非典型内存。
标准 C++和奇异内存
在这个有点奇怪的章节的最后,我们关注的是我们可以如何编写处理“奇异”内存的标准 C++ 程序。通过“奇异”,我们指的是需要显式操作来“接触”(分配、读取、写入、释放等)的内存,并且与我们的程序控制的“正常”内存块不同,例如本章前面使用 placement new
的内存映射使用示例。这类内存的例子包括持久(非易失)内存或共享内存,但实际上任何 非同寻常的 都可以。
由于我们必须选择一个示例,我们将编写一个使用(虚构的)共享内存块的示例。
一个小小的谎言……
重要的是要理解,我们正在描述的是一个通常会在 进程 之间共享的内存机制,但进程间通信是操作系统的领域。标准 C++ 只描述了在进程中的 线程 之间共享数据的规则;因此,我们将说一个小小的谎言,并编写一个多线程系统,而不是多进程系统,使用该内存来共享数据。我们的重点是内存管理功能,而不是进程间通信,所以这不应该构成问题。
按照本章前面部分的做法,我们将构建一个可移植的示例,展示如何在代码中管理非典型内存,并让您将这些细节映射到您选择平台的服务。我们的示例代码将具有以下形式:
-
将分配一个共享内存块。我们将让它看起来这个内存是特殊的,因为需要特殊的操作系统函数来创建它、分配它或释放它,但我们故意避免使用实际的操作系统函数。这意味着,如果您想将本节中的代码用于实际应用,您需要将其适配到您选择的平台 API。
-
我们将制作一个使用这个虚构的共享内存 API 的“手工”版本玩具程序,以此来展示在这些情况下用户代码会是什么样子。
-
然后,我们将展示理解 C++的内存管理功能如何帮助我们编写更愉快且“看起来更正常”的用户代码,这些代码与“手工”代码做同样的事情……甚至更好。
虚构的现实感?
在本节中,我们将讨论 C++和异构内存,希望这将是有趣的,我们将编写的代码将力求在内存管理方面具有现实性。如前所述,由于 C++标准在多进程系统方面的内容很少,我们将尝试使多线程代码看起来有点像多进程代码。我希望你,敏锐的读者,会接受这个提议。
请注意,本节用户代码中会有一些低级同步,包括一些通过原子变量。我尽量保持其最小化且合理现实,希望即使我不会详细解释所有内容,你也能接受,因为本书的重点是内存管理而不是并发计算(当然,这也是一个很好的主题)。如果你想知道更多关于等待原子变量或使用线程栅栏等事情,请自由使用你喜欢的并发编程资源。
准备好了吗?让我们开始吧!
一个虚构的共享内存 API
我们将编写一个虚构但受大多数操作系统启发(除了我们将通过异常报告错误以简化用户代码之外)的 API。操作系统主要通过从返回值中表达的错误代码来报告错误,但这会导致用户代码更加复杂。我希望这对你,亲爱的读者,来说似乎是一个可以接受的折衷方案。
正如大多数操作系统所做的那样,我们将通过一种形式的手柄或键来抽象实际的资源;创建一个某个大小的“共享内存”段将产生一个键(一个整数标识符),之后,访问该内存将需要这个键,销毁该内存也是如此。正如预期的那样,对于一个旨在用于在进程之间共享数据的设施,销毁内存不会最终确定其中的对象,因此用户代码需要确保在释放共享内存段之前销毁共享内存中的对象。
我们 API 的签名和类型如下:
// ...
#include <cstddef> // std::size_t
#include <new> // std::bad_alloc
#include <utility> // std::pair
class invalid_shared_mem_key {};
enum shared_mem_id : std::size_t;
shared_mem_id create_shared_mem(std::size_t size);
std::pair<void*, std::size_t>
get_shared_mem(shared_mem_id);
void destroy_shared_mem(shared_mem_id);
// ...
你可能会注意到我们正在使用enum
类型为shared_mem_id
。这样做的原因是enum
类型在 C++中是不同的类型,而不仅仅是using
或typedef
会得到的一个别名。当基于它们的参数类型重载函数时,具有不同的类型可能很有用。这是一个有用的技巧:如果我们编写两个具有相同名称的函数(一个接受shared_mem_id
类型的参数,另一个接受std::size_t
类型的参数),这些将是不同的函数,尽管shared_mem_id
的底层类型是std::size_t
。
由于我们正在构建一个“共享内存”的人工实现来展示内存分配函数如何简化用户代码,因此我们的 API 函数实现将编写得简单,但让我们编写客户端代码,使其表现得好像它正在使用共享内存。我们将定义一个共享内存段为一个由字节数组及其字节大小组成的shared_mem_block
对。我们将保持一个该类型的std::vector
对象,使用该数组中的索引作为shared_mem_id
。这意味着当shared_mem_block
对象被销毁时,我们不会在std::vector
中重用其索引(容器最终会有“空洞”,换句话说)。
我们的实施方案如下。请注意,它不是线程安全的,但这不会影响我们与内存管理相关的讨论:
// ...
#include <vector>
#include <memory>
#include <utility>
struct shared_mem_block {
std::unique_ptr<char[]> mem;
std::size_t size;
};
std::vector<shared_mem_block> shared_mems;
std::pair<void*, std::size_t>
get_shared_mem(shared_mem_id id) {
if (id < std::size(shared_mems))
return { shared_mems[id].mem.get(),
shared_mems[id].size };
return { nullptr, 0 };
}
shared_mem_id create_shared_mem(std::size_t size) {
auto p = std::make_unique<char[]>(size);
shared_mems.emplace_back(std::move(p), size);
// note the parentheses
return shared_mem_id(std::size(shared_mems) - 1);
}
// function for internal purposes only
bool is_valid_shared_mem_key(shared_mem_id id) {
return id < std::size(shared_mems) &&
shared_mems[id].mem;
}
void destroy_shared_mem(shared_mem_id id) {
if (!is_valid_shared_mem_key(id))
throw invalid_shared_mem_key{};
shared_mems[id].mem.reset();
}
如果你想进行实验,你可以用你选择的操作系统的等效实现替换这些函数的实现,如果需要,调整 API。
配备了这个实现,我们现在可以比较一个使用共享内存的“手工”代码示例和一个受益于 C++设施的实现。我们将通过以下代码进行这种比较:一个从共享内存段分配一些数据块,然后启动两个线程(一个写线程和一个读线程)。写线程将写入共享数据,然后(通过最小化同步)读线程将从它读取。如前所述,我们的代码将使用进程内同步(C++原子变量),但在实际代码中,你应该使用操作系统提供的进程间同步机制。
关于生命周期
你可能还记得从第一章中了解到,每个对象都有一个关联的生命周期,编译器会在你的程序中跟踪这一事实。我们的虚构多进程示例实际上是一个单进程、多线程示例,因此通常的 C++生命周期规则适用。
如果你想将本节中的代码用于编写一个真正的多进程系统以运行一些测试,你可能需要考虑在这些没有明确创建data
对象的进程中使用 C++23 中的std::start_lifetime_as()
,并避免基于编译器的推理产生的有害优化,即在这些进程中,对象从未被构造。在早期的编译器中,一个通常有效的方法是将未正式构造的对象的std::memcpy()
调用到自身上,从而有效地开始其生命周期。
在我们的“手工制作”和标准外观的实现中,我们将使用由一个int
值和一个布尔ready
标志组成的data
对象:
struct data {
bool ready;
int value;
};
在单进程实现中,对于完成标志,更好的选择是atomic<bool>
对象,因为我们想确保在写入ready
标志之前写入值,但由于我们希望这个示例看起来像我们正在使用进程间共享内存,我们将限制自己使用简单的bool
,并通过其他方式确保这种同步。
关于同步的一席话
在一个现代程序中,优化编译器通常会重新排序看似独立的操作以生成更好的代码,处理器在代码生成后也会进行同样的操作,以最大化处理器内部流水线的使用。并发代码有时会包含既对编译器也对处理器不可见的依赖。在我们的示例中,我们希望ready
完成标志仅在value
写入操作完成后变为true
;这个顺序之所以重要,是因为写入操作在一个线程中执行,但另一个线程将查看ready
以确定是否可以读取value
。
如果不通过某种形式的同步强制执行value
-ready
写入顺序,编译器或处理器可能会重新排序这些(看似独立的)写入,并破坏我们对ready
含义的假设。
一个手工的用户代码示例
我们当然可以编写使用我们虚构的 API 的用户代码,而无需求助于 C++的特殊内存管理设施,仅仅依靠如第七章中看到的放置new
的使用。可能会诱使人们认为放置new
是一种特殊设施,因为你可能从这本书中了解到它,但如果这是你的观点,我们邀请你重新考虑:放置new
机制是一种几乎在所有程序中使用的根本性内存管理工具,无论用户代码是否意识到它。
作为提醒,我们的示例程序将执行以下操作:
-
创建一个指定大小的共享内存段(在这种情况下,我们将分配比所需更多的内存)。
-
在该段的开始处构造一个
data
对象,显然是通过放置new
来完成的。 -
启动一个线程,该线程将等待
go
变量(类型为atomic<bool>
)上的信号,然后获得对共享内存段的访问权限,写入value
数据成员,然后仅通过ready
数据成员发出写入已发生的信号。 -
启动另一个线程,该线程将获得对共享内存段的访问权限,获取其中共享
data
对象的指针,然后对ready
标志进行一些(非常低效的)忙等待以改变状态,之后将读取并使用value
。一旦完成,将通过done
标志(类型为atomic<bool>
)发出完成信号。 -
然后我们的程序将从键盘读取一个键,向线程(实际上是写入线程)发出信号,表明是时候开始工作了,并在释放共享内存段并结束工作之前等待它们完成。
因此,我们最终得到以下结果:
// ...
#include <thread>
#include <atomic>
#include <iostream>
int main() {
// we need a N-bytes shared memory block
constexpr std::size_t N = 1'000'000;
auto key = create_shared_mem(N);
// map a data object in the shared memory block
auto [p, sz] = get_shared_mem(key);
if (!p) return -1;
// start the lifetime of a non-ready data object
auto p_data = new (p) data{ false };
std::atomic<bool> go{ false };
std::atomic<bool> done{ false };
std::jthread writer{ [key, &go] {
go.wait(false);
auto [p, sz] = get_shared_mem(key);
if (p) {
auto p_data = static_cast<data*>(p);
p_data->value = 3;
std::atomic_thread_fence(
std::memory_order_release
);
p_data->ready = true;
}
} };
std::jthread reader{ [key, &done] {
auto [p, sz] = get_shared_mem(key);
if (p) {
auto p_data = static_cast<data*>(p);
while (!p_data->ready)
; // busy waiting, not cool
std::cout << "read value "
<< p_data->value << '\n';
}
done = true;
done.notify_all();
} };
if (char c; !std::cin.get(c)) exit(-1);
go = true;
go.notify_all();
// writer and reader run to completion, then complete
done.wait(false);
p_data->~data();
destroy_shared_mem(key);
}
我们使这项工作得以实现:我们有一种某种形式的基础设施来管理共享内存段,我们可以使用这些内存块来共享数据,我们可以编写读取该共享数据的代码,也可以写入它。请注意,我们在每个线程中捕获了 key
变量中的密钥,然后通过该密钥在每个 lambda 中获取内存块,但简单地捕获 p_data
指针并使用它也是合理的。
然而,请注意,我们并没有真正管理那个块:我们创建了它,并在开始时使用了一个大小为 sizeof(data)
的小块。现在,如果我们想在那个区域创建多个对象呢?如果我们想编写既创建又销毁对象的代码,引入了在给定时间管理该块哪些部分正在使用的需求呢?根据我们刚才写的,这意味着所有这些都在用户代码中完成,这是一个相当繁重的任务。
记住这一点,我们现在将用不同的方法解决相同的问题。
一个看起来标准的用户代码等效
那么,如果我们想以更习惯的方式使用“奇特”内存,C++ 提供了什么机制呢?嗯,这样做的一种方法如下:
-
编写一个用于“奇特”内存的管理器类,封装对操作系统的不可移植接口,并公开更接近 C++ 用户代码预期的服务
-
编写内存分配操作符的重载(如
operator new()
、operator delete()
等),这些重载接受对这样一个管理对象的引用作为额外的参数 -
通过在内存管理器对象上委托来使这些重载的内存分配操作符在可移植和非可移植代码之间架起桥梁
这样,用户代码可以基本上写成“看起来正常”的代码,调用 new
和 delete
操作符,只是这些调用将使用与 第七章 中类似的那种扩展符号,例如 nothrow
或放置版本的 operator new()
。
我们的 shared_mem_mgr
类将使用本节前面描述的虚构操作系统 API,但通常,人们会编写一个封装所需操作系统服务的类,以便在程序中使用目标内存。
由于这是一个为了简单而制作的示例,主要是为了展示功能的工作方式和如何使用,聪明的读者你可能会看到很多改进和优化的空间…确实,这个管理器非常慢,且占用内存,它保持一个 std::vector<bool>
对象,其中每个 bool
值指示内存块中的字节是否已被占用,并且每当有分配请求时,都会在这个容器中进行简单的线性搜索(此外,它不是线程安全的,这是不好的!)。我们将在 第十章 中检查一些实现质量的考虑因素,但没有任何阻止你在同时期将 shared_mem_mgr
改进得更好的事情。
你会注意到 shared_mem_mgr
被表达为一个 RAII 类型:它的构造函数创建一个共享内存段,它的析构函数释放该内存段,并且 shared_mem_mgr
类型已经被设置为不可复制的,这在 RAII 类型中很常见。在下面的代码摘录中,需要查看的关键成员函数是 allocate()
和 deallocate()
;前者尝试从共享内存段分配一个块并记录这一行为,而后者释放与块内地址关联的内存:
#include <algorithm>
#include <iterator>
#include <new>
class shared_mem_mgr {
shared_mem_id key;
std::vector<bool> taken;
void *mem;
auto find_first_free(std::size_t from = 0) {
using namespace std;
auto p = find(begin(taken) + from, end(taken),
false);
return distance(begin(taken), p);
}
bool at_least_free_from(std::size_t from, int n) {
using namespace std;
return from + n < size(taken) &&
count(begin(taken) + from,
begin(taken) + from + n,
false) == n;
}
void take(std::size_t from, std::size_t to) {
using namespace std;
fill(begin(taken) + from, begin(taken) + to,
begin(taken) + from, true);
}
void free(std::size_t from, std::size_t to) {
using namespace std;
fill(begin(taken) + from, begin(taken) + to,
begin(taken) + from, false);
}
public:
// create shared memory block
shared_mem_mgr(std::size_t size)
: key{ create_shared_mem(size) }, taken(size) {
auto [p, sz] = get_shared_mem(key);
if (!p) throw invalid_shared_mem_key{};
mem = p;
}
shared_mem_mgr(const shared_mem_mgr&) = delete;
shared_mem_mgr&
operator=(const shared_mem_mgr&) = delete;
void* allocate(std::size_t n) {
using namespace std;
std::size_t i = find_first_free();
// insanely inefficient
while (!at_least_free_from(i, n) && i != size(taken))
i = find_first_free(i + 1);
if (i == size(taken)) throw bad_alloc{};
take(i, i + n);
return static_cast<char*>(mem) + i;
}
void deallocate(void *p, std::size_t n) {
using namespace std;
auto i = distance(
static_cast<char*>(mem), static_cast<char*>(p)
);
take(i, i + n);
}
~shared_mem_mgr() {
destroy_shared_mem(key);
}
};
如你所见,shared_mem_mgr
确实是一个管理内存块段的类,其中并不涉及任何魔法。如果有人想要改进内存管理算法,他们可以在不触及这个类的接口的情况下做到这一点,从而受益于封装带来的低耦合。
如果你想玩…
一种有趣的改进 shared_mem_mgr
的方法可能是首先让这个类负责分配和释放共享内存,正如它已经做的那样,然后编写一个不同的类来管理该共享内存块内的内存,最后使它们协同工作。这样,人们可以使用 shared_mem_mgr
与不同的内存管理算法,并根据个别程序或其部分的需求选择管理策略。如果你想要找些乐子,这是一个可以尝试的方法!
下一步是实现接受 shared_mem_mgr&
类型参数的分配运算符重载。这基本上是微不足道的,因为这些重载只需要将工作委托给管理器:
void* operator new(std::size_t n, shared_mem_mgr& mgr) {
return mgr.allocate(n);
}
void* operator new[](std::size_t n, shared_mem_mgr& mgr) {
return mgr.allocate(n);
}
void operator delete(void *p, std::size_t n,
shared_mem_mgr& mgr) {
mgr.deallocate(p, n);
}
void operator delete[](void *p, std::size_t n,
shared_mem_mgr& mgr) {
mgr.deallocate(p, n);
}
配备了我们的管理器和这些重载,我们可以编写我们的测试程序,该程序执行与上一节中“手工”相同的任务。然而,在这种情况下,有一些不同之处:
-
我们不需要管理共享内存段的创建和销毁。这些任务由
shared_mem_mgr
对象作为其实现 RAII 习语的组成部分来处理。 -
我们根本不需要管理共享内存块,因为这个任务分配给了
shared_mem_mgr
对象。在块中找到一个放置对象的位置,跟踪块如何被对象使用,确保可以区分已使用区域和未使用区域,等等,这些都是该类职责的一部分。 -
作为推论,在“手工”版本中,我们在共享内存块的开始处构建了一个对象,并指出构建更多对象或管理共享内存段以考虑对
new
和delete
操作符的多次调用将给用户代码带来负担,但在这个实现中,我们可以自由地调用new
和delete
,因为我们希望这种内存管理对客户端代码来说是透明的。
在非典型内存中构建对象方面,相当简单:只需在调用new
和new[]
操作符时传递额外的参数即可。然而,通过此类经理对象管理的对象的最终化部分则稍微复杂一些:我们不能在我们的指针上写delete p
这样的代码,因为这会尝试最终化对象并通过“正常”方式释放内存。相反,我们需要手动最终化对象,然后手动调用适当的operator delete()
函数版本,以执行异构的内存清理任务。当然,鉴于我们在第六章中写的内容,你可以将这些任务封装在你自己的智能指针中,以获得更简单、更安全的用户代码。
我们最终得到了以下示例程序:
int main() {
// we need a N-bytes shared memory block
constexpr std::size_t N = 1'000'000;
// HERE
shared_mem_mgr mgr{ N };
// start the lifetime of a non-ready data object
auto p_data = new (mgr) data{ false };
std::atomic<bool> go{ false };
std::atomic<bool> done{ false };
std::jthread writer{ [p_data, &go] {
go.wait(false);
p_data->value = 3;
std::atomic_thread_fence(std::memory_order_release);
p_data->ready = true;
} };
std::jthread reader{ [p_data, &done] {
while (!p_data->ready)
; // busy waiting, not cool
std::cout << "read value " << p_data->value << '\n';
done = true;
done.notify_all();
} };
if (char c; !std::cin.get(c)) exit(-1);
go = true;
go.notify_all();
// writer and reader run to completion, then complete
done.wait(false);
p_data->~data();
operator delete(p_data, sizeof(data), mgr);
}
这仍然不是一个简单的例子,但内存管理方面显然比“手工”版本简单,任务的模块化使得优化内存管理方式变得更容易。
然后……我们就完成了。呼!这真是一次相当刺激的旅程,又一次!
摘要
本章探讨了各种使用 C++内存管理设施的特殊方式:将对象映射到内存映射硬件上,将基本的错误处理与nothrow
版本的operator new()
集成,使用std::exception_handler
来应对内存不足的情况,以及通过“正常”分配操作符和经理对象的专业化来访问非典型内存。这为我们提供了对 C++内存管理设施的更广泛概述,以及如何利用它们来发挥优势。
我们提到过但尚未讨论的一件事是优化:如何在满足某些条件时使内存分配和内存释放变得快速,甚至非常快,并且在执行速度方面是确定的。这就是我们在第十章中解释如何编写基于竞技场的分配代码时将要做的。
哦,而且作为额外奖励,我们将消灭奥克瑞斯。
奥克瑞斯?你在说什么?
兽人是一种虚构的生物,出现在众多虚构幻想作品中,通常被用作敌人,与精灵(另一种虚构生物,通常有更好的声誉)有不健康的关联。由于你的友好作者在过去几十年里与游戏程序员合作了很多,兽人往往会出现在他的例子中,并且将是我们在第十章中编写的代码的核心。
听起来不错吗?那么,接下来是下一章!
第十章:基于区域的内存管理和其他优化
我们的内存管理工具箱随着每一章的增长而增长。我们现在知道如何重载内存分配运算符(第七章)以及如何将这项技能应用于解决各种具体问题的方法(第八章和第九章都提供了一些说明性的、现实世界的例子)。
想要控制内存分配机制的一个重要原因是性能。现在,声称能够轻易击败库供应商提供的这些函数的实现是轻率的(而且显然是错误的!),因为这些实现对于平均情况来说通常是好的,很多时候是非常好的。当然,前一句话的关键元素是“对于平均情况”。当一个人的使用案例在事先已知其特定性时,有时可以利用这些信息,设计出一个超越任何可能为优秀平均性能设计的实现的实现。
本章是关于使用我们想要解决的内存管理问题的知识来构建一个对我们来说表现卓越的解决方案。这可能意味着一个平均情况下更快、在最坏情况下也足够快、显示确定性的执行时间、减少内存碎片等解决方案。毕竟,现实世界程序中有许多不同的需求和约束,我们经常不得不做出选择。
一旦本章结束,我们的工具箱将扩展,使我们能够做到以下事情:
-
编写针对事先已知约束优化的基于区域的分配策略算法
-
编写按内存块大小分配的策略
-
理解与这些技术相关的益处以及风险
本章涵盖的技术将引导我们探索与某些专用应用领域中内存分配运算符重载非常接近的使用案例。因此,我们最初将它们应用于一个“真实生活”问题:中世纪幻想游戏中兽人与精灵之间的战斗。
关于优化的(有时是减少的)回报
由于我们将在本章中讨论优化技术(以及其他内容),因此需要一些警告:优化是一件棘手的事情,是一个移动的目标,今天使代码变得更好的东西,明天可能会使其变差。同样,理论上看起来不错的主意,一旦实施和测试,可能会在实践中导致减速,有时人们可能会花费大量时间优化很少使用的代码,实际上是在浪费时间和金钱。
在尝试优化程序的部分之前,通常明智的做法是测量,理想情况下使用分析工具,并确定可能从你的努力中受益的部分。然后,保留一个简单(但正确)的代码版本,并使用它作为基线。每次尝试优化时,将结果与基线代码进行比较,并定期运行这些测试,尤其是在更改硬件、库、编译器或其版本时。有时,例如编译器升级可能会引入一种新的优化,它“看穿”简单的基线代码,使其比精心制作的替代方案更快。要谦逊,要合理,要尽早测量,要经常测量。
技术要求
您可以在此处找到本书中该章节的代码文件:github.com/PacktPublishing/C-Plus-Plus-Memory-Management/tree/main/chapter10
。
基于竞技场的内存管理
基于竞技场内存管理的理念是在程序中的某个已知时刻分配一块内存,并根据对情况或问题域的了解,将其管理为一个“小而个性化的堆”。
在这个一般主题上有许多变体,包括以下内容:
-
在游戏中,通过场景或级别分配和管理内存,在场景或级别结束时作为一个单独的块释放它。这有助于减少程序中的内存碎片。
-
当已知分配和释放的条件遵循给定的模式或具有有限的内存需求时,专门化分配函数以利用这些信息。
-
以一种方式表达对一组相似对象的所有权,以便在程序稍后某个时刻一次性销毁它们,而不是逐个销毁。
解释基于竞技场分配的工作原理的最佳方式可能是编写一个使用它的示例程序,并展示它所做的工作以及它带来的好处。我们将以这种方式编写代码,以便根据宏的存在,使用标准库提供的分配函数或我们自己的专用实现,并且当然,我们将测量分配和释放代码,以查看我们的努力是否有益。
具体示例 - 基于大小的实现
假设我们正在制作一款视频游戏,其中动作汇聚到一个壮丽的终局,兽人和精灵在一场宏大的战斗中相遇。没有人真的记得为什么这两个群体彼此仇恨,但有一种怀疑,有一天,一个精灵对一个兽人说:“你知道,你今天闻起来并不那么糟糕!”而这个兽人如此受辱,以至于它开始了一场至今仍在进行的纷争。无论如何,这是一个谣言。
在这个游戏中,关于使用兽人代码的行为有一些了解,具体如下:
-
总共动态分配的
Orc
对象数量将不会超过某个特定数量,因此我们有存储这些生物所需空间的上限。 -
在那个游戏中,死亡的兽人将不会复活,因为没有萨满可以将其复活。换句话说,没有必要实现一个在对象被销毁后重用
Orc
对象存储的策略。
这两个属性为我们提供了算法选择:
-
如果我们有足够的内存可用,我们可以预先分配一个足够大的内存块,以便将所有
Orc
对象放入游戏中,因为我们知道最坏的情况是什么。 -
由于我们知道我们不需要重用与单个
Orc
对象关联的内存,我们可以实现一个简单(并且非常快速)的分配策略,这个策略几乎不做记录,并且正如我们将看到的,让我们能够实现针对这种类型的确定性、常数时间分配。
为了这个例子,Orc
类将由三个数据成员表示,name
(一个char[4]
,因为这些生物的词汇有限),strength
(类型为int
),和smell
(类型为double
,因为这些生物有…声誉),如下所示:
class Orc {
char name[4]{ 'U', 'R', 'G' };
int strength = 100;
double smell = 1000.0;
public:
static constexpr int NB_MAX = 1'000'000;
// ...
};
在这个例子中,我们将为Orc
对象使用任意默认值,因为我们只关心这个例子中的分配和释放。当然,如果你愿意,你可以编写更复杂的测试代码来使用非默认值,但这不会影响我们的讨论,所以我们将目标定为简单。
由于我们通过基于大小的竞技场预先讨论了大型内存块的内存分配,我们需要查看Orc
对象的内存大小消耗。假设sizeof(int)==4
和sizeof(double)==8
,并且假设作为基本类型,它们的对齐要求与它们各自的大小相匹配,在这种情况下,我们可以假设sizeof(Orc)==16
。如果我们旨在一次性为所有Orc
对象分配足够的空间,确保sizeof(Orc)
对于我们的资源来说是合理的,这一点很重要。例如,将程序中Orc
对象的最大数量定义为Orc::NB_MAX
,以及我们可以一次性为Orc
对象分配的最大内存量定义为某个假设的常量THRESHOLD
,我们可以在源代码中留下一个如下的static_assert
作为约束检查的形式:
static_assert(Orc::NB_MAX*sizeof(Orc) <= THRESHOLD);
这样,如果我们最终将Orc
类发展到资源成为问题的情况,代码将无法编译,我们就能重新评估情况。在我们的例子中,考虑到大约 16 MB 的内存消耗,我们假设我们处于预算范围内,可以继续我们的竞技场开发。
我们将想要将我们的基于地盘的实现与基线实现进行比较,在这种情况下,将是标准库提供的内存分配函数的实现。重要的是要提前指出,每个标准库实现都提供自己版本的这些函数,因此您可能需要在多个实现上运行我们将要编写的代码,以更好地了解我们的技术的影响。
要编写允许我们进行适当比较的代码,我们需要两个不同的可执行文件,因为我们将处于一个非此即彼的情况(我们要么得到标准版本,要么得到我们正在编写的“自制”版本),因此这是一个基于宏的条件编译的好用例。因此,我们将编写一组单一的源文件,这些文件将条件性地替换标准库提供的分配运算符版本为我们自己的版本,但其他方面基本上是相同的。
我们将使用三个文件进行工作:Orc.h
,它声明了 Orc
类和条件定义的分配运算符重载;Orc.cpp
,它提供了这些重载的实现以及本身的地盘实现;以及一个测试程序,该程序分配 Orc::NB_MAX
个 Orc
类型的对象,然后稍后销毁它们,并测量执行这两个操作所需的时间。当然,像大多数微基准测试一样,对这些测量结果要持保留态度:在真实程序中,分配操作会与其他代码交织在一起,因此这些数字不会相同,但至少我们将对分配运算符的两个实现应用相同的测试,所以比较应该是相对公平的。
声明 Orc
类
首先,让我们检查 Orc.h
,我们已经在之前展示 Orc
类的数据成员布局时部分看到了它:
#ifndef ORC_H
#define ORC_H
// #define HOMEMADE_VERSION
#include <cstddef>
#include <new>
class Orc {
char name[4]{ 'U', 'R', 'G' };
int strength = 100;
double smell = 1000.0;
public:
static constexpr int NB_MAX = 1'000'000;
#ifdef HOMEMADE_VERSION
void * operator new(std::size_t);
void * operator new[](std::size_t);
void operator delete(void *) noexcept;
void operator delete[](void *) noexcept;
#endif
};
#endif
HOMEMADE_VERSION
宏可以取消注释以使用我们版本的分配函数。正如预期的那样,因为我们正在为 Orc
类及其预期的使用模式应用一种特殊策略,所以我们使用成员函数重载来处理分配运算符。(我们难道愿意像对待奥克一样对待 int
对象,或者——想象一下!——精灵吗?我想不是吧。)
定义 Orc
类和实现地盘
与内存管理相关的代码的核心将在 Orc.cpp
中。我们将分两步进行,地盘实现和分配运算符重载,并将分别分析不同的重要部分。此文件中找到的整个实现将根据 HOMEMADE_VERSION
宏进行条件编译。
我们将命名我们的地盘类为 Tribe
,它将是一个单例。是的,我们在 第八章 中使用过的那个被诅咒的设计模式再次出现,但我们在程序中确实想要一个单一的 Tribe
对象,这样就能很好地传达意图。我们实现的重要部分如下:
-
Tribe
类的默认(也是唯一)构造函数分配了一个Orc::NB_MAX*sizeof(Orc)
字节的单个块。重要的是要立即指出,这个块中没有Orc
对象:这个内存块的大小和形状正好适合放置我们需要的所有Orc
对象。基于竞技场分配的一个关键思想是,至少对于这个实现来说,竞技场管理的是原始内存,而不是对象:对象的构造和析构是用户代码的范畴,任何在程序结束时没有正确析构的对象都是用户代码的过错,而不是竞技场的过错。 -
我们立即验证分配是否成功。在这种情况下,我使用了
assert()
,因为代码的其余部分都依赖于这个成功,但抛出std::bad_alloc
或调用std::abort()
也是一个合理的选择。Tribe
对象保持两个指针,p
和cur
,两者最初都指向块的开始。我们将使用p
作为 块开始 标记,而cur
作为 返回下一个块的指针;因此,p
将在整个程序执行过程中保持稳定,而cur
将随着每次分配向前移动sizeof(Orc)
字节。
使用 char* 或 Orc*
这个 Tribe
实现使用 char*
作为 p
和 cur
指针,但 Orc*
也是一个正确的选择。只需记住,对于 Tribe
对象而言,竞技场中没有 Orc
对象,使用 Orc*
类型仅仅是一个方便的谎言,以简化指针运算。这种改变将涉及在构造函数中将 static_cast<char*>
替换为 static_cast<Orc*>
,以及在 allocate()
成员函数的实现中将 cur+=sizeof(Orc)
替换为 ++cur
。这主要是一个风格和个人偏好的问题。
-
析构函数释放了由
Tribe
对象管理的整个内存块。这是一个非常高效的程序:它比单独释放较小的块要快,并且导致非常少的内存碎片。 -
这个第一个实现使用了在 第八章 中看到的梅耶斯单例技术,但我们在本章的后面将使用不同的方法来比较两种实现策略对同一设计模式性能的影响……因为确实有这种影响,正如我们将看到的。
我们基于预期使用模式的先验知识,我们的基于大小的竞技场实现将如何受益如下:
-
每次分配都将返回一个顺序“分配”的
Orc
大小块,这意味着没有必要搜索一个合适大小的块——我们始终知道它在哪里。 -
在释放内存时没有工作要做,因为我们一旦使用过这些块就不会再重用它们。请注意,根据标准规则,分配和释放函数必须是线程安全的,这解释了为什么在这个实现中我们使用了
std::mutex
。
代码如下:
#include "Orc.h"
#ifdef HOMEMADE_VERSION
#include <cassert>
#include <cstdlib>
#include <mutex>
class Tribe {
std::mutex m;
char *p, *cur;
Tribe() : p{ static_cast<char*>(
std::malloc(Orc::NB_MAX * sizeof(Orc))
) } {
assert(p);
cur = p;
}
Tribe(const Tribe&) = delete;
Tribe& operator=(const Tribe&) = delete;
public:
~Tribe() {
std::free(p);
}
static auto &get() {
static Tribe singleton;
return singleton;
}
void * allocate() {
std::lock_guard _ { m };
auto q = cur;
cur += sizeof(Orc);
return q;
}
void deallocate(void *) noexcept {
}
};
// ...
如你所猜想的,这些分配条件几乎是最优的,但在实际应用中发生的频率比我们想象的要高。一个类似高效的用法模式可以模拟栈(最后分配的块是下一个释放的块),而我们每天编写的使用局部变量的代码往往就是底层内存的常用最优使用模式,而我们可能并没有意识到这一点。
接下来,我们将讨论重载的分配运算符。为了使此实现简单,我们将假设不会有 Orc
对象的数组需要分配,但你可以将实现细化以考虑数组(这不是一个困难的任务;只是编写相关测试代码更复杂)。这些函数所起的作用是将工作委托给底层区域,并且它们仅用于 Orc
类(这一点在后面的 当参数改变 部分会有所讨论)。因此,它们几乎是微不足道的:
// ...
void * Orc::operator new(std::size_t) {
return Tribe::get().allocate();
}
void * Orc::operator new[](std::size_t) {
assert(false);
}
void Orc::operator delete(void *p) noexcept {
Tribe::get().deallocate(p);
}
void Orc::operator delete[](void *) noexcept {
assert(false);
}
#endif // HOMEMADE_VERSION
测试我们的实现
接下来,我们将讨论我们将使用的测试代码实现。此程序将由一个名为 test()
的微基准函数和一个 main()
函数组成。我们将分别检查这两个函数。
test()
函数将接受一个非 void
函数 f()
,一个可变参数包 args
,并调用 f(args...)
,确保在该调用中使用完美转发来传递参数,以确保参数以原始调用中预期的语义传递。它在调用 f()
之前和之后读取时钟,并返回一个由 f(args...)
执行的结果和此调用期间经过的时间组成的 pair
。我在我的代码中使用了 high_resolution_clock
,但在此情况下使用 system_clock
或 steady_clock
也有合理的理由:
#include <chrono>
#include <utility>
template <class F, class ... Args>
auto test(F f, Args &&... args) {
using namespace std;
using namespace std::chrono;
auto pre = high_resolution_clock::now();
auto res = f(std::forward<Args>(args)...);
auto post = high_resolution_clock::now();
return pair{ res, post - pre };
}
// ...
你可能会想知道为什么我们要求非 void
函数,即使在某些情况下返回值可能有些人为,也要返回 f(args...)
的调用结果。这里的想法是确保编译器认为 f(args...)
的结果是有用的,并且不会将其优化掉。编译器确实很聪明,可以根据所谓的“as-if 规则”删除看似无用的代码(简单来说,如果调用函数没有明显的效果,就把它去掉!)。
对于测试程序本身,请注意以下方面:
-
首先,我们将使用
std::vector<Orc*>
,而不是std::vector<Orc>
。一开始这可能会显得有些奇怪,但既然我们正在测试Orc::operator new()
和Orc::operator delete()
的速度,我们确实需要调用这些运算符!如果我们使用Orc
对象的容器,那么根本不会调用我们的运算符。 -
在运行测试之前,我们在那个
std::vector
对象上调用reserve()
,为我们将要构建的Orc
对象的指针分配空间。这是我们测量中的一个重要方面:在std::vector
对象中对push_back()
和类似的插入函数的调用,如果尝试向一个满容器添加元素,将需要重新分配,这种重新分配会给我们的基准测试增加噪声,因此确保容器在测试期间不需要重新分配有助于我们专注于我们想要测量的内容。 -
我们用
test()
函数(已经在本书中多次使用)测量的东西是一系列Orc::NB_MAX
次对Orc::operator new()
的调用,最终由相同数量的Orc::operator delete()
调用跟随。我们假设在构建和销毁之间的时间有一个某种程度的破坏,但我们出于对您,亲爱的读者的尊重,没有展示这种破坏。 -
一旦我们到达终点,我们就打印出我们的测量结果,使用微秒作为测量单位——我们今天的计算机足够快,以至于毫秒可能不够精确。
以下是代码:
// ...
#include "Orc.h"
#include <print>
#include <vector>
int main() {
using namespace std;
using namespace std::chrono;
#ifdef HOMEMADE_VERSION
print("HOMEMADE VERSION\n");
#else
print("STANDARD LIBRARY VERSION\n");
#endif
vector<Orc*> orcs;
auto [r0, dt0] = test([&orcs] {
for(int i = 0; i != Orc::NB_MAX; ++i)
orcs.push_back(new Orc);
return size(orcs);
});
// ...
// CARNAGE (CENSORED)
// ...
auto [r1, dt1] = test([&orcs] {
for(auto p : orcs)
delete p;
return size(orcs);
});
print("Construction: {} orcs in {}\n",
size(orcs), duration_cast<microseconds>(dt0));
print("Destruction: {} orcs in {}\n",
size(orcs), duration_cast<microseconds>(dt1));
}
在这一点上,你可能会想知道这一切是否值得努力。毕竟,我们的标准库可能非常高效(实际上,它们平均来说非常出色!)。唯一知道结果是否会让我们满意的方法是运行测试代码并亲自查看。
看看这些数字
使用带有 -O2 优化级别的在线 gcc 15 编译器和运行此代码两次(一次使用标准库版本,一次使用使用 Meyers 单例的家用版本),我在 Orc::NB_MAX
(此处为 106)个对象上对 new
和 delete
操作符的调用得到了以下数字:
自制 | ||
---|---|---|
N=106 | 标准库 | Meyers 单例 |
operator new() |
23433μs | 17906μs |
operator delete() |
7943μs | 638μs |
表 10.1 – 与 Meyers 单例实现的性能比较
实际数字会因各种因素而有所不同,但比较中有趣的是比率:我们自制的 operator new()
只用了标准库提供的版本的 76.4% 的时间,而我们自制的 operator delete()
则用了基准的… 8.03% 的时间。
这些结果相当令人愉快,但它们实际上并不应该让我们感到惊讶:我们执行了常数时间的分配和几乎“无时间”的释放。我们确实在每个分配上花费时间锁定和解锁一个 std::mutex
对象,但大多数标准库实现的互斥锁在低争用情况下预期并且在这些情况下非常快,而且我们的程序确实进行了单线程的分配和释放,这导致代码明显没有争用。
现在,你敏锐的推理能力可能会让你惊讶,分配实际上并没有比我们刚刚测量的更快。毕竟,我们调用的是一个空函数,那么是什么消耗了这些 CPU 时间?
答案是…我们的单例,或者更准确地说,对用于 Meyers 实现的static
局部变量的访问。记得从第八章中,这种技术旨在确保在需要时创建单例,static
局部变量是在其封装函数第一次被调用时构造的。
C++实现了“魔法静态”机制,其中对static
局部对象的构造函数的调用被同步机制保护,确保对象只被构造一次。正如我们所看到的,这种同步虽然高效,但并非免费。在我们的情况下,如果我们能保证在调用main()
之前没有其他全局对象需要调用Tribe::get()
,我们可以用一种更经典的方法替换 Meyers 方法,其中单例只是Tribe
类的static
数据成员,在类的范围内声明并在全局作用域中定义:
// ...
// "global" singleton implementation (the rest of
// the code remains unchanged)
class Tribe {
std::mutex m;
char *p, *cur;
Tribe() : p{ static_cast<char*>(
std::malloc(Orc::NB_MAX * sizeof(Orc))
) } {
assert(p);
cur = p;
}
Tribe(const Tribe&) = delete;
Tribe& operator=(const Tribe&) = delete;
static Tribe singleton;
public:
~Tribe() {
std::free(p);
}
static auto &get() {
return singleton;
}
void * allocate() {
std::lock_guard _ { m };
auto q = cur;
cur += sizeof(Orc);
return q;
}
void deallocate(void *) noexcept {
}
};
// in a .cpp file somewhere, within a block surrounded
// with #ifdef HOMEMADE_VERSION and #endif
Tribe Tribe::singleton;
// ...
将单例对象的定义从函数内部移出——放置在全局作用域中——消除了对其构造函数调用周围的同步需求。现在,我们可以将这种实现与之前的结果进行比较,以评估涉及的成本和可获得的收益(如果有的话)。
使用之前相同的测试设置,将“全局”单例添加到比较的实现集合中,我们得到以下结果:
N=106 | 自制 | |
---|---|---|
标准库 | Meyers 单例 | 全局单例 |
Operator new() |
23433μs | 17906μs |
Operator delete() |
7943μs | 638μs |
表 10.2 – 与 Meyers 和“全局”单例实现的性能比较
现在,这更像样子了!对operator new()
的调用比之前快,74.99%(与标准库版本相比,以及 98.14%与 Meyers 单例相比),但operator delete()
的调用已经变成了空操作。这已经很难做得更好了!
那么,这样做值得吗?当然,这取决于你的需求。速度是一个因素;在某些程序中,速度的提升可能是一个必需品,但在其他程序中,它可能不是一个因素,或者几乎可以忽略不计。在内存碎片减少方面,在某些程序中也能产生很大的影响,有些程序正是出于这个原因使用区域。关键是:如果你需要这样做,现在你知道如何了。
将 SizeBasedArena<T,N>泛化
按照编写的Tribe
类似乎特定于Orc
类,但在实践中,它实际上特定于Orc
-大小的对象,因为它从未调用过Orc
类的任何函数;它从未构造过Orc
对象,也从未销毁过。这意味着我们可以将这个类转换成一个通用类,并为其在其他类似约束下预期使用的类型重用。
为了实现这一点,我们将区域代码与Orc
类解耦,并将其放入一个单独的文件中,例如可能叫做SizeBasedArena.h
:
#ifndef SIZE_BASED_ARENA_H
#define SIZE_BASED_ARENA_H
#include <cassert>
#include <cstdlib>
#include <mutex>
template <class T, std::size_t N>
class SizeBasedArena {
std::mutex m;
char *p, *cur;
SizeBasedArena() : p{ static_cast<char*>(
std::malloc(N * sizeof(T))
) } {
assert(p);
cur = p;
}
SizeBasedArena(const SizeBasedArena&) = delete;
SizeBasedArena&
operator=(const SizeBasedArena&) = delete;
public:
~SizeBasedArena() {
std::free(p);
}
static auto &get() {
static SizeBasedArena singleton;
return singleton;
}
void * allocate_one() {
std::lock_guard _ { m };
auto q = cur;
cur += sizeof(T);
return q;
}
void * allocate_n(std::size_t n) {
std::lock_guard _ { m };
auto q = cur;
cur += n * sizeof(T);
return q;
}
void deallocate_one(void *) noexcept {
}
void deallocate_n(void *) noexcept {
}
};
#endif
可能会令人惊讶的是,我们使用了T
和N
作为模板参数。如果我们不在区域中使用T
,为什么不用初始化为sizeof(T)
的整数来代替T
?嗯,如果Elf
类(例如)也使用基于大小的区域,并且如果我们不幸到sizeof(Orc)==sizeof(Elf)
,那么如果我们基于类型的尺寸而不是类型本身,并且如果它们各自的N
参数的值相同,可能会导致Orc
和Elf
使用相同的区域……而我们不想这样(他们也不想!)。
为了简化这个泛型示例中单例的初始化,我们回到了梅耶斯技术。在编写泛型代码时,比编写Orc
特定等效代码更难保证全局对象在构造时的相互依赖性不存在,因为转向泛型代码显著扩大了潜在用户基础。
Orc.cpp
中的实现现在如下所示:
#include "Orc.h"
#ifdef HOMEMADE_VERSION
#include "SizeBasedArena.h"
using Tribe = SizeBasedArena<Orc, Orc::NB_MAX>;
void * Orc::operator new(std::size_t) {
return Tribe::get().allocate_one();
}
void * Orc::operator new[](std::size_t n) {
return Tribe::get().allocate_n(n / sizeof(Orc));
}
void Orc::operator delete(void *p) noexcept {
Tribe::get().deallocate_one(p);
}
void Orc::operator delete[](void *p) noexcept {
Tribe::get().deallocate_n(p);
}
#endif
你可能已经注意到,由于SizeBasedArena<T,N>
实现了单个对象或n
个对象的数组的分配函数,我们已经扩展了Orc
类的成员函数分配运算符重载,以覆盖operator new[]()
和operator delete[]()
。在这个点上,真的没有不这样做的原因。
当参数改变时
我们基于大小的区域实现非常具体:它假设了顺序分配的可能性,并且能够忽略(通常很重要)的问题,即释放内存后是否可以重用内存。
任何基于大小的实现的一个重要注意事项显然是,我们依赖于一个特定的尺寸。因此,要知道,在这个约束下,我们当前的实现稍微有些危险。确实,考虑以下我们程序的发展,我们设想了更强大、更狡猾的Orc
子类,如下所示:
class MeanOrc : public Orc {
float attackBonus; // oops!
// ...
};
起初可能并不明显,但我们可能已经在这个新类中破坏了一些重要的东西,因为成员函数分配运算符被派生类继承。这意味着Tribe
类,也被称为相对嘈杂的名称SizeBasedArena<Orc,Orc::NB_MAX>
,将实现一个针对sizeof(Orc)
字节块的策略,但(意外地)也会用于大小为MeanOrc
的对象。这只会导致痛苦。
我们可以通过两种方式保护自己免受这种灾难性的情况。对于Orc
类,我们可以通过将类标记为final
来完全禁止派生类:
class Orc final {
// ...
};
这消除了将MeanOrc
作为Orc
的派生类的可能性;我们仍然可以编写MeanOrc
,但通过组合或其他技术,这样可以绕过继承的运算符问题。
从SizeBasedArena<T,N>
本身的视角来看,我们也可以选择将我们的实现限制为final
类型,如下例所示:
// ...
#include <type_traits>
template <class T, std::size_t N>
class SizeBasedArena {
static_assert(std::is_final_v<T>);
// ...
};
然而,最后一部分可能并不适合所有人。有许多类型(例如基本类型)不是final
的,并且可以在基于大小的竞技场中合理使用,所以这取决于你,看看这对你所写的代码来说是否是一个好主意。如果你觉得不好,那么这些约束可以用散文而不是代码来表达。
基于大小的竞技场远非内存竞技场的唯一用例。我们可以在基于大小的主题和分配策略上设想许多变体。
例如,假设我们在游戏中引入了萨满,并且内存重用成为现实需求。我们可能会遇到这样的情况:在程序中,最多有Orc::NB_MAX
个Orc
类型的对象同时存在,但在整个程序执行期间,总数可能超过这个数字。在这种情况下,我们需要考虑以下事项:
-
如果我们允许数组,我们将在竞技场内部处理内部碎片化,因此我们可能想要考虑一种实现方式,为每个竞技场分配超过
N*sizeof(T)
字节的内存,但要多多少呢? -
我们需要一种策略来重用内存。我们有多种方法可供选择,包括维护一个有序的
begin,end
对列表来界定空闲块(并且更容易将它们融合以减少碎片化)或者保留一个堆栈(可能是一系列基于块大小的堆栈)来存储最近释放的块,以便更快地重用这些块。
对于“我们代码库的最佳方法是什么?”这样的问题,部分是技术性的,部分是政治性的:什么使得分配快速可能会减慢释放,什么使得分配速度确定性可能会在内存空间开销上付出更多,等等。问题是要确定在我们的情况下哪些权衡效果最好,并测量以确保我们获得预期的收益。如果我们无法做得比标准库更好,那么无论如何,使用标准库吧!
分块池
我们基于大小的竞技场示例是为了优化单个块大小和特定的使用模式,但还有许多其他原因想要应用专门的分配策略。在本节中,我们将探讨“分块池”的概念,或者说是预分配选定块大小的原始内存的池。这更多的是作为一个学术示例来构建,而不是作为生产中使用的示例;接下来的代码将相当快速,并且可以变得非常快速,但在这本书中,我们将关注一般方法,并让你,亲爱的读者,去享受优化它到你满意的程度。
在这个例子中的想法是,用户代码计划分配大小相似(但不一定是相同的)的各种类型和对象,并假设最大对象数量的上限。这给我们提供了额外的知识;利用这些知识,我们将编写一个 ChunkSizedAllocator<N,Sz...>
类型,其中 N
将是每个“尺寸类别”的对象数量,而 Sz...
中的每个整数值将是一个不同的尺寸类别。
为了给出一个澄清的例子,一个 ChunkSizedAllocator<10,20,40,80,160>
对象将预先分配足够的原始内存来容纳 10 个大小为 20 字节、40 字节、80 字节和 160 字节的对象,总共至少 3,000 字节(每个尺寸类别所需的最小尺寸之和为 200 + 400 + 800 + 1600)。我们在这里说“至少”,是因为为了有用,我们的类需要考虑对齐,并且为了避免分配未对齐的对象,通常需要比最小内存量更多的内存。
为了理解我们将要做什么,这里有一些提示(当然,是字面上的意思):
-
在整数值的变长序列
Sz...
中,我们将要求值按升序排序,因为这会使进一步的查找更快(线性复杂度而不是二次复杂度)。由于这些值在编译时已知,是类型模板参数的一部分,因此这没有运行时成本,更多的是对用户施加的约束。当然,我们将在编译时验证这一点,以避免不愉快的事故。 -
在 C++ 中,变长参数包可以是空的,但在这个例子中,一个空的尺寸类别集合将没有意义,因此我们将确保这种情况不会发生(当然是在编译时)。显然,
N
必须大于零,这样这个类才有用,因此我们也将对此进行验证。 -
可能不明显的是,
Sz...
中的值至少要大于sizeof(std::max_align_t)
(我们也可以测试alignof
,但对于基本类型来说这是多余的)并且实际上,我们需要将有效的尺寸类别设置为 2 的幂,以确保可以分配任意类型。这部分将内部处理,因为对用户代码施加这一点更复杂。
通过查看代码,我们可以看到这些约束被明确地表达出来。请注意,为了使“代码叙述”更容易理解,接下来的代码是逐步展示的,所以如果你想尝试它,请确保查看完整的示例:
#include <algorithm>
#include <vector>
#include <utility>
#include <memory>
#include <cassert>
#include <concepts>
#include <limits>
#include <array>
#include <iterator>
#include <mutex>
// ... helper functions (shown below)...
template <int N, auto ... Sz>
class ChunkSizedAllocator {
static_assert(is_sorted(make_array(Sz...)));
static_assert(sizeof...(Sz) > 0);
static_assert(
((Sz >= sizeof(std::max_align_t)) && ...)
);
static_assert(N > 0);
static constexpr unsigned long long sizes[] {
next_power_of_two(Sz)...
};
using raw_ptr = void*;
raw_ptr blocks[sizeof...(Sz)];
int cur[sizeof...(Sz)] {}; // initialized to zero
// ...
注意,我们有两个数据成员——即 blocks
,它将包含每个尺寸类别的原始内存块的指针,以及 cur
,它将包含每个尺寸类别内下一个分配的索引(默认初始化为零,因为我们将在每种情况下从头开始)。
本类的代码将很快继续。目前,你可能注意到一些未解释的辅助函数:
-
我们使用
make_array(Sz...)
,这是一个constexpr
函数,它从Sz...
的值构建一个类型为std::array<T,N>
的对象,期望所有值都是同一类型(Sz...
的第一个值的类型)。我们知道N
对于结果std::array<T,N>
是一个编译时常数,因为它是从Sz...
中的值的数量计算出来的。 -
我们使用
is_sorted()
谓词在std::array<T,N>
对象上,以确保在编译时值是按升序排序的,正如我们所期望的那样。不出所料,这会简单地调用std::is_sorted()
算法,它是一个constexpr
,因此可以在这种上下文中使用。 -
命名为
sizes
的非static
成员数组将包含Sz...
中每个值(包括该值)的下一个 2 的幂:如果该值已经是 2 的幂,那太好了!因此,如果Sz...
是10,20,32
,那么sizes
将包含16,32,32
。
为什么是 2 的幂?
在实践中,如果我们连续分配不是 2 的幂的块,那么在第一次分配之后,这些块将导致对象对齐错误,为了避免这种情况而管理填充可能会变得可能,但这将显著复杂化我们的实现。为了使分配更快,我们在编译时计算Sz...
每个元素的下一个 2 的幂,并将它们存储在sizes
数组中。这意味着我们可能会有两个最终大小相同的尺寸类别(例如,40
和60
都会导致 64 字节的块),但这是一个小问题(因为代码仍然可以工作),考虑到这是一个为知识渊博的用户设计的专用设施。
这些辅助函数的代码,在实践中,是在ChunkSizedAllocator<N,Sz...>
类的声明之前定义的,如下所示:
// ...
template <class T, std::same_as<T> ... Ts>
constexpr std::array<T, sizeof...(Ts)+1>
make_array(T n, Ts ... ns) {
return { n, ns... };
}
constexpr bool is_power_of_two(std::integral auto n) {
return n && ((n & (n - 1)) == 0);
}
class integral_value_too_big {};
constexpr auto next_power_of_two(std::integral auto n) {
constexpr auto upper_limit =
std::numeric_limits<decltype(n)>::max();
for(; n != upper_limit && !is_power_of_two(n); ++n)
;
if(!is_power_of_two(n)) throw integral_value_too_big{};
return n;
}
template <class T>
constexpr bool is_sorted(const T &c) {
return std::is_sorted(std::begin(c), std::end(c));
}
// ...
注意,make_array()
使用概念来约束所有值都是同一类型,is_power_of_two(n)
确保测试n
的正确位以使此测试快速(它还测试n
以确保我们不报告0
为 2 的幂)。next_power_of_two()
函数可能可以做得更快,但在这里这影响不大,因为它仅在编译时使用(我们可以通过将其改为consteval
而不是constexpr
来强制执行这一点,但可能有一些用户想要在运行时和编译时使用之间进行选择,所以我们将给他们这个选择)。
在简要讨论了辅助函数之后,我们回到ChunkSizedAllocator<N,Sz...>
实现,这里有一个名为within_block(p,i)
的成员函数,它仅在指针p
位于blocks[i]
内时返回true
,blocks[i]
是我们对象内存的i
-th 预分配块。该函数的逻辑看似简单:人们可能只想测试类似blocks[i]<=p&&p<blocks[i]+N
的东西,但考虑到blocks[i]
变量是void*
类型,这阻止了指针运算,但在 C++中这实际上是错误的(记得我们在第二章中关于指针运算复杂性的讨论)。在实践中,这可能因为与 C 代码的兼容性而有效,但这不是你想要依赖的东西。
到目前为止,正在进行讨论,以添加一个标准库函数来测试一个指针是否位于两个其他指针之间,但直到这种情况发生,我们至少可以使用标准库提供的std::less
函数对象来使比较变得合法。我知道这并不令人满意,但今天它可能适用于所有编译器……通过将这个测试局部化到一个专用函数中,一旦我们有一个真正的标准解决方案来解决这个问题,我们就可以简化源代码的更新:
// ...
bool within_block(void *p, int i) {
void* b = blocks[i];
void* e = static_cast<char*>(b) + N * sizes[i];
return p == b ||
(std::less{}(b, p) && std::less{}(p, e));
}
// ...
没有必要使ChunkSizedAllocator<N,Sz...>
对象全局可用:这是一个可以在程序中多次实例化并用于解决各种问题的工具。然而,我们不希望该类型是可复制的(我们可以这样做,但这会真正复杂化设计,而回报有限)。
通过std::malloc()
,我们的构造函数为Sz...
中的各种大小分配了原始内存块,或者至少是每个这些大小的下一个 2 的幂,正如本节前面所解释的,之后确保所有分配都成功。我们使用了assert()
来做到这一点,但也可以在成功分配内存块后抛出std::bad_alloc
异常,前提是必须小心地调用std::free()
。
我们的析构函数,不出所料,对每个内存块调用std::free()
:正如本章前面提到的区域实现一样,ChunkSizedAllocator<N,Sz...>
对象负责内存,而不是客户端代码放入其中的对象,因此我们必须假设在调用该对象的析构函数之前,客户端代码已经销毁了存储在ChunkSizedAllocator
对象内存块中的所有对象。
注意存在一个std::mutex
数据成员,因为我们稍后需要这个(或某种其他同步工具)来确保分配和释放是线程安全的:
// ...
std::mutex m;
public:
ChunkSizedAllocator(const ChunkSizedAllocator&)
= delete;
ChunkSizedAllocator&
operator=(const ChunkSizedAllocator&) = delete;
ChunkSizedAllocator() {
int i = 0;
for(auto sz : sizes)
blocks[i++] = std::malloc(N * sz);
assert(std::none_of(
std::begin(blocks), std::end(blocks),
[](auto p) { return !p; }
));
}
~ChunkSizedAllocator() {
for(auto p : blocks)
std::free(p);
}
// ...
最后,我们通过allocate()
和deallocate()
成员函数到达了我们努力的精髓。在allocate(n)
中,我们寻找最小的元素sizes[i]
,其分配的块大小足够大,可以容纳n
字节。一旦找到这样一个块,我们就锁定我们的std::mutex
对象以避免竞争条件,然后查看blocks[i]
中是否至少还有一个可用的块;这种实现按顺序取用它们,并且不重复使用它们,以保持讨论简单。如果有,我们就取用这个块,更新cur[i]
,并将适当的地址返回给用户代码。
注意,当我们没有在我们的预分配块中找到空闲块,或者当n
太大而无法使用我们事先分配的块时,我们将分配责任委托给::operator new()
,这样分配请求仍然可能成功。我们也可以在这种情况下抛出std::bad_alloc
,这取决于意图:如果我们认为分配必须在我们自己的块中进行,而不是在其他地方,那么抛出或以其他方式失败是一个更好的选择。
失败怎么会是好事呢?
一些应用,尤其是在低延迟或实时系统领域的嵌入式系统中,软件即使提供了正确答案或产生了正确的计算,但如果没有及时完成,其效果与产生错误答案的软件一样糟糕。例如,考虑一个控制汽车刹车的系统:一辆在碰撞后停止的汽车实际上作用有限。这样的系统在发布之前会进行严格的测试以捕捉故障,并将依赖于特定的运行时行为;因此,在开发过程中,它们可能更愿意失败(在测试阶段会被捕捉到),而不是默认采用可能有时无法满足其时序要求的策略。当然,请不要发布在现实生活中使用时停止工作的关键系统:请充分测试它们,并确保用户的安全!但也许你正在开发一个系统,如果发生糟糕的事情,你更愿意在某个地方打印“抱歉,我们搞砸了”,然后只是重新启动程序,有时这也是完全可以接受的。
deallocate(p)
释放函数会遍历每个内存块,查看p
是否在该块内。记住,我们的within_block()
函数将受益于一个指针比较测试,而截至本文撰写时,标准还没有提供这个测试,所以如果你在实际中使用此代码,请确保你给自己留一个笔记,以便一旦该新功能可用就应用它。如果p
不在我们的任何块中,那么它可能通过::operator new()
分配,所以我们确保通过::operator delete()
释放它,就像我们应该做的那样。
如前所述,我们的实现一旦释放内存就不会重用内存,但重用应该发生的位置已被留在了注释中(以及锁定该部分的互斥锁的代码),所以如果你想要实现内存块重用逻辑,请随意:
// ...
auto allocate(std::size_t n) {
using std::size;
// use smallest block available
for(std::size_t i = 0; i != size(sizes); ++i) {
if(n < sizes[i]) {
std::lock_guard _ { m };
if(cur[i] < N) {
void *p = static_cast<char*>(blocks[i]) +
cur[i] * sizes[i];
++cur[i];
return p;
}
}
}
// either no block fits or no block left
return ::operator new(n);
}
void deallocate (void *p) {
using std::size;
for(std::size_t i = 0; i != size(sizes); ++i) {
if(within_block(p, i)) {
//std::lock_guard _ { m };
// if you want to reuse the memory,
// it's in blocks[i]
return;
}
}
// p is not in our blocks
::operator delete(p);
}
};
// ...
由于这是一种客户端代码按需使用的特殊分配形式,我们将使用分配运算符的特殊重载。正如预期的那样,这些重载将是基于要使用的ChunkSizedAllocator
对象参数的模板:
template <int N, auto ... Sz>
void *operator new(std::size_t n, ChunkSizedAllocator<
N, Sz...
> &chunks) {
return chunks.allocate(n);
}
template <int N, auto ... Sz>
void operator delete (void *p, ChunkSizedAllocator<
N, Sz...
> &chunks) {
return chunks.deallocate(p);
}
// new[] and delete[] left as an exercise ;)
现在,我们已经编写了这些分配设施,但我们需要测试它们,因为我们需要看到这种方法的益处。
测试 ChunkSizedAllocator
我们现在将编写一个简单的测试程序,该程序使用一个具有适当大小类别的ChunkSizedAllocator
对象,然后以应该对我们类有益的方式分配和释放适合这些类别的对象大小。通过这样做,我们假设这个类的用户这样做是为了从先验已知的大小类别中受益。还可以进行其他测试,以验证代码在请求不适当的大小或在存在抛出构造函数的情况下行为,例如,所以请随意编写比我们为执行速度相关讨论提供的更详尽的测试框架。
在本章前面用于测试基于大小的竞技场的test()
函数将再次在这里使用。参见该部分以了解其工作原理。
编写一个良好的测试程序来验证分配和释放各种大小对象的程序的行为并非易事。我们将要做的是使用一个dummy<N>
类型,其对象在内存中将各自占用N
字节的空间(由于我们将使用char[N]
数据成员来获取这个结果,我们知道对于所有有效的N
值,alignof(dummy<N>)==1
)。
我们还将编写两个不同的test_dummy<N>()
函数。每个这样的函数都将分配并构造dummy<N>
对象,并设置相关的销毁然后释放代码,但一个将使用标准库实现的分配运算符,另一个将使用我们的重载。
你会注意到我们的两个test_dummy<N>()
函数都返回一对值:一个将是分配对象的指针,另一个将是销毁和释放该对象的代码。由于我们将在此存储信息,我们需要这些对是共享公共类型的抽象,这解释了我们为什么使用void*
作为地址和std::function<void(void*)>
作为销毁代码。我们需要std::function
或类似的东西:函数指针不足以作为销毁代码,因为销毁代码可以是状态化的(我们有时需要记住用于管理分配的对象)。
这些工具的代码如下:
#include <chrono>
#include <utility>
#include <functional>
template <class F, class ... Args>
auto test(F f, Args &&... args) {
using namespace std;
using namespace std::chrono;
auto pre = high_resolution_clock::now();
auto res = f(std::forward<Args>(args)...);
auto post = high_resolution_clock::now();
return pair{ res, post - pre };
}
template <int N> struct dummy { char _[N] {}; };
template <int N> auto test_dummy() {
return std::pair<void *, std::function<void(void*)>> {
new dummy<N>{},
[](void *p) { delete static_cast<dummy<N>*>(p); }
};
}
template <int N, class T> auto test_dummy(T &alloc) {
return std::pair<void *, std::function<void(void*)>> {
new (alloc) dummy<N>{},
&alloc { ::operator delete(p, alloc); }
};
}
// ...
最后,我们必须编写测试程序。我们将逐步讨论这个程序,以确保我们掌握过程中涉及的所有细微差别。
我们程序首先为ChunkSizedAllocator
对象确定一个N
的值,以及内存管理器要使用的Sz...
大小类别(我选择的N
的值是任意的)。我故意使用了一个非 2 的幂的大小类别,以表明这些值被适当地“向上取整”到下一个 2 的幂:62
的大小请求在构建我们的类型的数据成员sizes
时被转换为64
。然后我们构建这个对象,并将其命名为chunks
,因为……好吧,为什么不呢?
// ...
#include <print>
#include <vector>
int main() {
using namespace std;
using namespace std::chrono;
constexpr int N = 100'000;
using Alloc = ChunkSizedAllocator<
N, 32, 62 /* 64 */, 128
>;
Alloc chunks; // construct the ChunkSizedAllocator
// ...
接下来的测试对于标准库和我们的专用设施具有相同的形式。让我们详细看看:
-
我们创建了一个名为
ptrs
的std::vector
对象对,填充了默认值(空指针和非可调用函数),用于三个大小类别的N
个对象。这确保了std::vector
对象使用的空间分配在我们测量之前(在传递给test()
的 lambda 表达式执行之前)进行,并且不会干扰它们。请注意,每个测试的 lambda 都是可变的,因为它需要修改捕获的ptrs
对象。 -
对于三个大小类别中的每一个,我们随后分配适合该类别的
N
个对象,并通过返回的pair
记住该对象的地址以及稍后正确终结它的代码。 -
然后,为了结束每个测试,我们使用每个对象的 finalization 代码,将其销毁并重新分配。
幸运的是,这听起来比实际情况要糟糕。一旦测试运行完成,我们就打印出每个测试的执行时间,以微秒为单位:
// ...
auto [r0, dt0] = test([ptrs = std::vector<
std::pair<
void*, std::function<void(void*)>
>>(N * 3)]() mutable {
// allocation
for(int i = 0; i != N * 3; i += 3) {
ptrs[i] = test_dummy<30>();
ptrs[i + 1] = test_dummy<60>();
ptrs[i + 2] = test_dummy<100>();
}
// cleanup
for(auto & p : ptrs)
p.second(p.first);
return std::size(ptrs);
});
auto [r1, dt1] = test([&chunks, ptrs = std::vector<
std::pair<
void*, std::function<void(void*)>
>>(N * 3)]() mutable {
// allocation
for(int i = 0; i != N * 3; i += 3) {
ptrs[i] = test_dummy<30>(chunks);
ptrs[i + 1] = test_dummy<60>(chunks);
ptrs[i + 2] = test_dummy<100>(chunks);
}
// cleanup
for(auto & p : ptrs)
p.second(p.first);
return std::size(ptrs);
});
std::print("Standard version : {}\n",
duration_cast<microseconds>(dt0));
std::print("Chunked version : {}\n",
duration_cast<microseconds>(dt1));
}
好吧,所以这稍微有点复杂,但希望是有教育意义的。这值得麻烦吗?嗯,这取决于你的需求。
当我用相同的在线 gcc 15 编译器运行此代码,并且使用与基于大小的区域相同的-O2 优化级别时,标准库版本报告的执行时间为 13,360 微秒,而“分块”版本报告的时间为 12,032 微秒,相当于标准版本执行时间的 90.05%。只要我们记住,在chunks
对象的构造函数中进行的初始分配没有被测量:这里的想法是表明,当时间重要时,我们可以节省时间,并且当我们不急于求成时,我们愿意为此付费。
重要的是要记住,这种实现不会重用内存,但标准版本会这样做,这意味着我们的加速可能被功能性的损失所抵消(如果这是你需要的功能的话)。在我进行的测试中,锁定std::mutex
对象或不锁定它对加速有显著影响,所以(a)根据你的平台,你可能会有更好的同步机制可供选择,并且(b)这种实现可能过于天真,如果deallocate()
成员函数也需要锁定std::mutex
对象,那么它可能无法带来任何好处。
当然,可以对这种(相当学术的)版本进行相当多的优化,我邀请亲爱的读者们这样做(并且每一步都测试结果!)本节的目的更多的是为了展示(a)基于块大小的分配是可以实现的,(b)从架构的角度来看如何实现,以及(c)指出沿途的一些风险和潜在陷阱。
那很有趣,不是吗?
摘要
作为提醒,在本章中,我们通过一个具体的例子(基于大小的特定使用模式的区域)考察了基于区域的分配,并看到我们可以从中获得显著的结果,然后看到了另一个使用预分配内存块的用例,我们从其中挑选了放置对象的块,再次看到了一些好处。这些技术展示了控制内存管理的新方法,但它们绝对不是对这一主题的全面讨论。说实话,整本书都不可能对这一主题进行全面论述,但希望它能给我们一些启发!
我们旅程的下一步将是扩展本章中看到的技巧,并编写一些实际上不是垃圾回收器但在某些方面较弱且在某些方面更好的内容:延迟回收内存区域。这将是我们开始讨论容器内存管理之前的最后一步。
第十一章:延迟回收
在第九章中,我们展示了某些不寻常的内存分配机制的示例以及如何使用它们,包括如何响应错误以给我们的程序一种“第二次机会”来继续执行,以及如何通过 C++语言设施使用非典型或异国内存。然后,在第第十章中,我们考察了基于竞技场的分配及其一些变体,重点关注速度、确定性和对资源消耗的控制问题。
在当前章节中,我们将进行一些在 C++中不常见但在许多其他语言中是常见做法的操作:我们将在程序执行过程中选择特定时刻延迟动态分配对象的销毁。
我们将不会编写一个真正的垃圾回收器,因为这会涉及到对编译器内部工作的更深入参与,并影响使 C++成为如此美妙工具的编程模型。然而,我们将组装延迟回收的机制,即选择性地在特定时刻销毁选定的对象,并一起释放其底层存储,但不保证销毁顺序。当然,我们不会提供实现这一目标的技术的详尽概述,但我们希望给你,亲爱的读者,足够的“思考材料”,以便在需要时构建自己的延迟回收机制。
本章中的技术可以与第第十章中看到的技术相结合,以使程序运行更快并减少内存碎片,但我们将单独介绍延迟回收,以使我们的论述更加清晰。阅读本章后,你将能够做到以下几件事情:
-
理解与延迟回收相关的权衡,因为虽然可以取得收益,但也涉及成本(这并非万能药!)
-
实现一个几乎透明的外部包装器,以跟踪需要收集的内存
-
实现一个几乎透明的外部包装器,以帮助最终确定那些受到延迟回收的对象
-
实现一个类似于
std::shared_ptr
对象的引用计数的计数指针,以识别在所选作用域结束时可以回收的对象
我们需要采取的第一步是尝试理解一些可以从中受益的延迟回收问题领域,包括它与(不同但并非完全不相似的)垃圾收集问题的关系。
最终化?回收?
您会注意到,在本章中,我们经常使用“finalization”(最终化)这个词而不是“destruction”(销毁)这个词,因为我们试图强调这样一个事实:在对象生命周期的末尾(其析构函数)执行的代码与释放其底层存储的代码是不同的。作为额外的好处,finalization在垃圾回收语言中更为常见,而垃圾回收是接下来几节讨论的技术的一个近亲。将 finalization(不进行回收)视为调用对象析构函数(不释放底层存储)的等价物。
如本章前面所述,我们将回收定义为在选定的时刻(例如,作用域结束时或程序执行结束时)释放一个或多个对象的内存。再次强调,这个术语在垃圾回收语言中比在 C++中更为常见,但本章的主题在某种程度上更接近这些语言所做的工作,因此,使用类似的术语可能有助于形成对涉及的思想和技术的共同理解。
技术要求
您可以在本书的 GitHub 仓库中找到本章的代码文件:github.com/PacktPublishing/C-Plus-Plus-Memory-Management/tree/main/chapter11
。
我们所说的延迟回收是什么意思?
为什么会有人想要求助于延迟回收?这确实是一个合理的问题,所以感谢您提问!
简短的回答是,它解决了实际问题。确实,有些程序在对象停止被客户端代码引用后不立即收集对象是有意义的,或者在我们确定可能使用它们的代码结束之前,不清楚它们是否可以被收集。由于我们用 C++语言思考代码的方式,这些程序在 C++中相对较少,但从一般编程世界的角度来看,它们并不罕见。
例如,考虑一个函数,其中一些局部分配的对象之间存在循环引用,或者一个可以从根节点导航到其叶节点的树,但在这个树中,叶节点也指向其根节点。有时,我们可以确定如何销毁一组对象:例如,在树的情况下,我们可以决定从根节点开始,沿着分支向下进行。在其他情况下,如果我们知道一组对象不会逃离给定的函数,我们也可以利用在函数结束时它们都可以作为一个组回收的知识。
如果你熟悉垃圾回收语言,你可能知道在大多数语言中,回收器“回收字节”,释放回收对象的基础存储(有时在执行过程中还会压缩内存),但不会最终化对象。其中一个原因是,在这样的语言中,一个对象很难(在某些情况下,甚至不可能)知道程序中还存在哪些其他对象,因为没有最终化顺序的保证……如果垃圾回收器需要处理相互引用的对象循环,又怎么可能存在这样的顺序呢?当对象达到其生命周期结束时,不知道哪些其他对象仍然存在,这严重限制了最终化代码能做的事情。
在许多语言中,回收并不意味着最终化,这简化了收集对象的任务:从概念上讲,可以调用std::free()
或一些等效函数来释放内存,而无需担心其中的对象。在那些在回收之前保证最终化的语言中,通常会发现一个以单个公共基类(通常称为object
或Object
)为根的类层次结构,这使得可以在每个对象上调用等效的virtual
析构函数,并多态地最终化它。当然,在这种情况下,在最终化对象时能做的事情是有限的,因为对象最终化的顺序通常是未知的。
在当代垃圾回收语言中,更常见的是将最终化的责任交给客户端代码,而将回收工作留给语言本身。这样的语言通常使用一个特殊的接口(例如 C#中的IDisposable
和 Java 中的Closeable
),由需要最终化的类实现(通常是管理外部资源的类),客户端代码将明确地放置所需的机制以实现对象的有序最终化。这将从对象本身(如 C++中的 RAII 习语所述,见第四章)将部分资源管理责任转移到使用它的代码上,这提醒我们,垃圾回收器倾向于简化内存管理,但同时也倾向于使其他资源的管理复杂化。
这样的客户端代码驱动的资源管理示例包括一个带有finally
块的try
块,无论try
块是否正常结束或进入了一些catch
块,它都作为应用清理代码的焦点。还有一些简化语法,以更轻松的方式为客户代码执行相同的事情。例如,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
的功能完成的,但作者警告用户,这种终结器能做的事情是有限的,就像垃圾回收语言中(以及在本节中之前讨论过)的情况一样。
进一步阅读
要进一步探索,请查看www.hboehm.info/gc/
.
在这一章中,我们将使用其他技术。正如本书中始终所做的那样,我们的意图是展示你可以从中实验并构建你代码需要的解决方案的想法。我们将展示三个不同的示例:
-
在程序执行结束时回收选定对象但不会终结它们的代码,将延迟回收限制为具有平凡析构类型的对象
-
在程序执行结束时回收和终结选定对象的代码
-
在选定作用域结束时回收和终结选定对象的代码
在每种情况下,我们将采取不同的方法,以给你更广泛的视角,了解可以做什么。在所有三种情况下,我们将在全局可访问的对象中存储指针。是的,这是一个单例,但在这里这是正确的工具,因为我们正在讨论影响整个程序的功能。准备好了吗?我们开始了!
我们有时会做一些事情来使示例更易于阅读…
下文中的代码可能会让一些读者感到奇怪。为了专注于代码的延迟回收方面,并保持整体演示的可读性,我选择不深入探讨线程安全方面,尽管这在当代代码中是至关重要的。然而,在本章的 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()
所不知道的。
为了能够在程序执行结束时回收这些对象的内存,我们需要一种全局可用的存储形式,我们将把分配的指针放在那里。我们将这样的指针称为roots
,并将它们存储在GC
类型的单例中(受垃圾收集器通常使用的昵称的启发,尽管这并不是我们正在实现的功能——这个名称将很好地传达意图,而且足够短,不会妨碍使用)。
GC::add_root<T>(args...)
成员函数将确保T
是一个简单可销毁的类型,分配一个sizeof(T)
字节的块,在该位置构造T(args...)
,在roots
中存储对该对象的抽象指针(一个void*
),并返回一个指向新创建对象的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() calls std::free() but invokes no destructor, as this implementation reclaims memory but does not finalize objects.
This example shows a way to group memory reclamation as a single block to be executed at the end of a program. In code where there is more available memory than what the program requires, this can lead to a more streamlined program execution, albeit at the cost of a slight slowdown at program termination (of course, if you want to try this, please measure to see whether there are actual benefits for your code base!). It can also help us write analysis tools that examine how memory has been allocated throughout program execution and can be enhanced to collate additional information such as memory block size and alignment: we simply would need to keep pairs – or tuples, depending on the needs – instead of single `void*` objects in the `roots` container to aggregate the desired data.
Of course, not being able to finalize objects allocated through this mechanism can be a severe limitation, as no non-trivially destructible type can benefit from our efforts. Let’s see how we could add finalization support to our design.
Reclamation and finalization at the end of the program
Our second implementation will not only free the underlying storage for the objects allocated through our deferred reclamation system but will also finalize them by calling their destructors. To do so, we will need to remember the type of each object that goes through our system. There are, of course, many ways to achieve this, and we will see one of them.
By ensuring the finalization of reclaimed objects, we can get rid of the trivially destructible requirement of our previous implementation. We still will not guarantee the order in which objects are finalized, so it’s important that reclaimed objects do not refer to each other during finalization if we are to have sound programs, but that’s a constraint many other popular programming languages also share. This implementation will, however, keep the singleton approach and finalize and then deallocate objects and their underlying storage at the end of program execution.
As in the previous section, we will first look at client code. In this case, we will be using (and benefitting from) non-trivially destructible objects and use them to print out information during finalization: this will simplify the task of tracing program execution. Of course, we will also use trivially destructible types (such as `struct X`, local to the `h()` function) as there is no reason not to support these too. Note that, often (but not always), non-trivially destructible types will be RAII types (see *Chapter 4*) whose objects need to free resources before their life ends, but we just want a simple example here so doing anything non-trivial such as printing out some value (which is what we are doing with `NamedThing`) will suffice in demonstrating that we handle non-trivially-destructible types correctly.
We will use nested function calls to highlight the local aspect of construction and allocation, as well as the non-local aspect of object destruction and deallocation since these will happen at program termination time. Our example code will be as follows:
// ...
// 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
[[maybe_unused]] auto q = gcnew
}
auto h() {
struct X {
int m() const { return 123; }
};
return gcnew
}
auto f() {
g();
return h();
}
int main() {
std::print("Pre\n");
std::print("{}\n", f()->m());
std::print("Post\n");
}
When executed, you should expect the following information to be printed on the screen:
Pre
hi ctor
there ctor
123
Post
hi dtor
there dtor
As can be seen, the constructors happen when invoked in the source code, but the destructors are called at program termination (after the end of `main()`) as we had announced we would do.
On the importance of interfaces
You might notice that user code essentially did not change between the non-object-finalizing implementation and this one. The beauty here is that our upgrade, or so to say, is completely achieved in the implementation, leaving the interface stable and, as such, the differences transparent to client code. Being able to change the implementation without impacting interfaces is a sign of low coupling and is a noble objective for one to seek to attain.
How did we get from a non-finalizing implementation to a finalizing one? Well, this implementation will also use a singleton named `GC` where “object roots” will be stored. In this case, however, we will store semantically enhanced objects, not just raw addresses (`void*` objects) as we did in the previous implementation.
We will achieve this objective through a set of old yet useful tricks:
* Our `GC` class will not be a generic class, as it would force us to write `GC<T>` instead of just `GC` in our code, and find a way to have a distinct `GC<T>` object for each `T` type. What we want is for a single `GC` object to store the required information for all objects that require deferred reclamation, regardless of type.
* In `GC`, instead of storing objects of the `void*` type, we will store objects of the `GC::GcRoot*` type. These objects will not be generic either but will be polymorphic, exposing a `destroy()` service to destroy (call the destructor, then free the underlying storage) objects.
* There will be classes that derive from `GC::GcRoot`. We will call such classes `GC::GcNode<T>` and there will be one for each type `T` in a program that is involved in our deferred reclamation mechanism. These are where the type-specific code will be “hidden.”
* By keeping `GC::GcRoot*` objects as roots but storing `GC::GcNode<T>*` in practice, we will be able to deallocate and finalize the `T` object appropriately.
The code for this implementation follows:
include
include
include
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;
};
// ...
As can be seen, `GC::GcRoot` is an abstraction that trades in raw pointers (objects of the `void*` type) and contains no type-specific information, per se.
The type-specific information is held in derived classes of the `GcNode<T>` type:
// ...
template
void destroy(void* q) const noexcept override {
delete static_cast<T*>(q);
}
public:
template <class T, class ... Args>
GcNode(Args &&... args) :
GcRoot(new T(std::forward
}
~GcNode() {
destroy(get());
}
};
// ...
As we can see, a `GcNode<T>` object can be constructed with any sequence of arguments suitable for type `T`, perfectly forwarding them to the constructor of a `T` object. The actual (raw) pointers are stored in the base class part of the object (the `GcRoot` but the destructor of a `GcNode<T>` invokes `destroy()` on that raw pointer, which casts the `void*` to the appropriate `T*` type before invoking `operator delete()`.
Through the `GcRoot` abstraction, a `GC` object is kept apart from type-specific details of the objects it needs to reclaim at a later point. This implementation can be seen as a form of **external polymorphism**, where we use a polymorphic hierarchy “underneath the covers” to implement functionality in such a way as to keep client code unaware.
Given what we have written so far, our work is almost done:
* Lifetime management can be delegated to smart pointers, as the finalization code is found in the destructor of `GcNode<T>` objects. Here, we will be using `std::unique_ptr<GcRoot>` objects (simple and efficient).
* The `add_root()` function will create `GcNode<T>` objects, store them in the `roots` container as pointers to their base class, `GcRoot`, and return the `T*` pointing to the newly constructed object. Thus, it installs lifetime management mechanisms while exposing pointers in ways that look natural to users of `operator new()`.
That part of the code follows:
// ...
std::vector<std::unique_ptr
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
std::forward
)->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
std::forward
);
}
// ...
So, there we have it: a way to create objects at selected points, and destroy and reclaim them all at program termination, with the corresponding upsides and downsides, of course. These tools are useful, but they are also niche tools that you should use (and customize to your needs) if there is indeed a need to do so.
So far, we have seen deferred reclamation facilities that terminate (and finalize, depending on the tool) at program termination. We still need a mechanism for reclamation at the end of selected scopes.
Reclamation and finalization at the end of the scope
Our third and last implementation for this chapter will ensure reclamation and finalization at the end of the scope, but only on demand. By this, we mean that if a user wants to reclaim unused objects that are subject to deferred reclamation at the end of a scope, it will be possible to do so. Objects subject to deferred reclamation that are still considered in use will not be reclaimed, and objects that are not in use will not be reclaimed if the user code does not ask for it. Of course, at program termination, all remaining objects that are subject to deferred reclamation will be claimed, as we want to avoid leaks.
This implementation will be more subtle than the previous ones, as we will need to consider (a) whether an object is still being referred to at a given point in program execution and (b) whether there is a need to collect objects that are not being referred to at that time.
To get to that point, we will inspire ourselves from `std::shared_ptr`, a type we provided an academic and simplified version of in *Chapter 6*, and will write a `counting_ptr<T>` type that, instead of destroying the pointee when its last client disconnects, will mark it as ready to be reclaimed.
The client code for this example follows. Pay attention to the presence of objects of the `scoped_collect` type in some scopes. These represent requests made by client code to reclaim objects not in use anymore at the end of that scope:
// ...
// 注意:不是简单可销毁的
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
auto q = gcnew
return q;
} // 在这里将发生回收
auto h() {
struct X {
int m() const { return 123; }
};
return gcnew
}
auto f() {
auto _ = scoped_collect{};
auto p = g();
std::cout << '"' << p->name << '"' << std::endl;
} // 在这里将发生回收
int main() {
using namespace std;
cout << "Pre" << endl;
f();
cout << h()->m() << endl;
cout << "Post" << endl;
} scoped_collect 对象的生命周期结束将导致通过 gcnew
执行此代码,我们最终得到以下结果:
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
的“标记”数据成员相结合 -
make_collectable(p)
成员函数将p
指针关联的根标记为可回收 -
collect()
成员函数回收所有标记为可回收的根
此实现所做的(a)是给每个可回收指针关联一个布尔标记(回收或不回收),(b)使用 counting_ptr<T>
对象与每个 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
?)。
当然,到达那里的方法有很多。想知道一些吗?让我们深呼吸,深入探讨…
第四部分:编写泛型容器(以及更多内容)
在本部分中,我们将专注于编写高效的泛型容器,通过显式内存管理来实现,然后通过隐式内存管理,最后通过分配器,在多年来的各种形式下。利用我们对内存管理技术和设施的更深入理解,我们将以比简单、更直观的实现方式更有效的方式表达两种类型的容器(一种使用连续内存,另一种使用链式节点)。我们以对 C++内存管理近未来展望结束本部分。
本部分包含以下章节:
-
第十二章, 使用显式内存管理编写泛型容器
-
第十三章, 使用隐式内存管理编写泛型容器
-
第十四章, 使用分配器支持的泛型容器编写
-
第十五章, 当代问题
第十二章:使用显式内存管理编写泛型容器
自我们从 C++内存管理机制和技术奥秘的旅程开始以来,我们已经走了很长的路。从 第四章 到 第七章,我们建立了一个有趣的工具箱,我们可以在此基础上构建,也可以从中适应以解决我们未来可能遇到的新问题。这个工具箱现在包含了许多其他东西,例如以下内容:
-
通过这些技术,对象隐式管理其资源
-
类似指针但将指向者的责任编码在类型系统中的类型
-
我们可以接管程序内存分配机制行为的各种方式
我们尚未涉及的一个(非常重要!)的内存管理方面是如何在容器中管理内存。这实际上是一个非常有趣的话题,我们将通过三个不同的角度,在三个不同的章节中探讨这个问题。
第一个角度是如何在容器中显式且高效地处理内存管理。这正是当前章节的内容。在某些应用领域,实现(或维护)自己的容器而不是使用标准库提供的容器是一种惯例。这样做可能有各种原因:例如,也许贵公司有高度专业化的需求。也许贵公司对标准库容器过去的性能不满意,可能是因为当时的实现没有达到预期的效率,因此开发了替代的容器。在基于自己的容器编写代码多年之后,回到标准库容器可能会显得成本过高。
第二个角度,相对较短,是如何在容器中隐式且高效地处理内存,将在本书的第十三章中介绍,我们将回顾并简化当前章节中看到的实现。
第三个角度,它更为复杂和微妙,是如何通过容器中的分配器来处理内存,并将构成本书的第十四章。
在当前章节中,我们将编写一个类似(天真)的 std::vector<T>
的名为 Vector<T>
的类。我们将利用这个机会来讨论异常安全性(一个重要的问题,尤其是在编写泛型代码时)。然后,我们会注意到,到目前为止,我们在效率上非常低,从某种程度上说,std::vector<T>
将比我们的 Vector<T>
替代品更有效率,至少对于某些类型来说是这样。基于这一认识,我们将重新审视我们的设计,以更好的内存管理,看到许多方面的重大改进,并讨论一些重要的低级标准内存管理设施,这些设施可以使我们的生活更容易。
我们还将编写一个名为 ForwardList<T>
的类似 std::forward_list<T>
的自定义版本,因为基于节点的容器存在一些特定的问题和考虑因素,而类似向量的类型实际上并不允许我们深入讨论。本章将编写一个“纯”版本的链表,我们将在第十三章中简要回顾它,然后在第十四章中更详细地讨论。
这意味着在阅读完本章之后,你将能够做到以下几件事情:
-
使用原始的内存管理技术编写正确且异常安全的容器
-
理解与
const
或引用数据成员相关的问题 -
使用标准提供的低级内存管理算法
更普遍地说,你将知道为什么 std::vector<T>
是如此之快,以及为什么在资源管理游戏中这个类型如此难以超越。你还将了解基于节点的容器(如 std::forward_list<T>
)面临的挑战,尽管后面的章节将更深入地探讨这一点。但这并不意味着你不应该编写自己的容器(对于特定的用例,我们通常可以做得比通用解决方案更好),但这确实意味着你将更好地了解为什么(以及何时)这样做,以及你需要投入多少努力。
全面性或代表性
本书通常不追求全面的表现或实现(物理对象如书籍有大小限制!),本章也不例外……远非如此!实现受标准库启发的两种容器类型所提供的全部成员函数将使本书大幅增长——而且你的标准库实现涵盖了比本书所能展示的更多边缘情况(以及提供了更多酷炫的优化)。因此,我们将尝试展示一组核心成员函数,你可以从中构建,而不是试图编写每一个。
技术要求
你可以在本书的 GitHub 仓库中找到本章的代码文件:github.com/PacktPublishing/C-Plus-Plus-Memory-Management/tree/main/chapter12
。
编写自己的 vector 替代方案
假设你有一天突然说:“嘿,我要在 std::vector
的领域打败它”并自信地开始编码。以下是一些建议:
-
这个看似简单的任务竟然如此难以完成:一方面,
std::vector
是一件艺术品,另一方面,你的标准库编写者也是技艺高超的个人。 -
你可能仍然认为你可以做到,所以尝试一下是完全可以的,但请确保用两种类型的元素测试你的想法:一种是 trivially constructible(例如,
int
或double
),另一种不是(例如,std::string
),并比较结果。对于许多人来说,前者将导致出色的性能,但后者可能会带来……悲伤。 -
这种差异的原因在于,像
std::vector
这样的容器在管理内存方面非常高效(我知道,在这本书中读到这一点肯定相当令人震惊!)。实际上,它比自制的替代品要好得多,除非你投入大量的时间和精力,并且(很可能是)有一个特定的用例在心中,对于这个用例,自制的版本会被更具体地优化。
你的标准库供应商确实投入了这样的时间和精力,这样做是为了你的利益,因此,学习如何最优地使用std::vector
可能最终会是一条比尝试编写你自己的等效容器带来更好结果的道路。当然,最终,使用哪个容器取决于你,你通常可以为特定情况编写代码,其性能优于标准容器的通用解决方案。
关于我们将如何编写我们的容器的通用说明
我们将在本章和随后的章节中编写(并使用)容器,因此如果我们要对我们将如何进行有一个共同的理解,就需要简要解释。首先,我们将在容器中使用与标准容器中使用的类型别名相匹配的类型别名,因为这有助于在其他标准库工具(如标准算法)中实现更流畅的集成。然后,我们将努力使用与标准库中相同的公共名称来命名我们的成员函数(例如,我们将编写empty()
来测试容器是否为空或不是,这与标准库中的现有实践相匹配,尽管有些人可能会认为is_empty()
更可取)。最后,我们将采用逐步改进的方法:我们的第一个版本将比后来的版本简单但效率较低,所以耐心点,亲爱的读者:我们正在遵循自己的道路走向启迪!
连续元素容器的表示选择
非正式地说,std::vector
代表一个动态分配的数组,可以根据需要增长。与任何数组一样,std::vector<T>
是内存中连续排列的T
类型元素的序列。我们将我们的自制版本命名为Vector<T>
,以便使其与std::vector<T>
在视觉上明显区分开来。
要获得一个性能合理的实现,第一个关键思想是区分大小和容量。如果我们不这样做,决定将大小和容量视为同一件事,我们的Vector<T>
实现将始终在概念上处于满载状态,并且需要增长,这意味着分配更多内存,将元素从旧存储复制到新存储,丢弃旧存储,等等,每次插入单个元素都需要这样做。说这样的实现会痛苦似乎是一个严重的低估。
向量类类型的内部表示主要有两种方法。一种方法是跟踪三个指针:
-
指向分配存储的开始
-
指向元素末尾
-
指向分配存储的末尾(注意,我们在这里指的是半开区间,包括开始但不包括结束)
简化的说明如下:
template <class T>
class Vector {
T *elems;
T *end_elems;
T *end_storage;
// ...
另一个方法是保留对分配存储开始的指针,以及两个整数(分别用于容器的尺寸和容量)。在这种情况下,一个简化的说明如下:
template <class T>
class Vector {
T *elems;
std:size_t nelems; // number of elements
std::size_t cap; // capacity
// ...
在这个意义上,这些是等效的表示,因为它们都允许我们编写正确的容器,但它们带来了不同的权衡。例如,保留三个指针使得计算end()
迭代器变得快速,但使得size()
和capacity()
需要计算指针减法,而保留一个指针和两个整数使得size()
和capacity()
都快速,但需要计算指针和整数的加法来获取end()
迭代器。
就大小而言,三指针表示使得sizeof(Vector<T>)
等于3*sizeof(void*)
,因此在 64 位平台上,对齐为 8 的情况下,可能是 24 字节。指针和两个整数可能具有相同的大小,也可能根据使用的整数类型略有不同。例如,在 64 位机器上选择 32 位整数作为大小和容量将导致 16 字节的表示和对齐为 8。这些细节可能在资源受限的系统上有所区别,但正如你可能已经推断出的那样,Vector<T>
等事物的主要内存消耗成本来自为T
对象分配的内存。
由于大小考虑、对哪些成员函数平均调用频率的估计等因素,不同的实现将做出不同的表示选择。我们也将需要做出选择;在这本书中,我们将选择“一个指针和两个整数”的方法,但请记住,这只是几个合理选项之一(你甚至可以玩一下这个想法,通过其他表示选择来实现以下内容,看看这会把你引向何方!)。
Vector<T>
的实现
我们将逐步分析我们的初始(天真)Vector<T>
实现,逐步了解这一切是如何工作的,以及是什么让我们声称这个实现确实是天真的。我们的第一步已经基本完成,主要是通过定义我们的抽象,通过符合标准库的类型别名和选择我们的内部表示:
#include <cstddef>
#include <algorithm>
#include <utility>
#include <initializer_list>
#include <iterator>
#include <type_traits>
template <class T>
class Vector {
public:
using value_type = T;
using size_type = std::size_t;
using pointer = T*;
using const_pointer = const T*;
using reference = T&;
using const_reference = const T&;
private:
pointer elems{};
size_type nelems{},
cap{};
// ...
你会注意到这种实现选择了为Vector<T>
的三个数据成员使用非static
数据成员初始化器,并将它们初始化为其默认值(整数是 0,指针是 null),这在我们的实现中是合适的,因为它代表了一个空的容器,这似乎是一个默认Vector<T>
的合理状态。
一些简单但基本的成员函数随后而来:
// ...
public:
size_type size() const { return nelems; }
size_type capacity() const { return cap; }
bool empty() const { return size() == 0; }
private:
bool full() const { return size() == capacity(); }
// ...
注意empty()
和full()
的实现。有些人可能会在实现成员函数时内部访问数据成员(在这里:使用nelems
和cap
而不是size()
和capacity()
),但考虑重用你更基本的成员函数来实现更“合成”的函数。这将使你的代码对实现的变化不那么敏感,并且 C++编译器非常擅长函数内联,尤其是当这些函数是非virtual
时。
到目前为止,我们可能能设计出的最有用的成员集可能是我们类的迭代器类型和数据成员,因为这将帮助我们使用标准算法干净且高效地实现其余的成员函数。
迭代器
C++容器通常将其接口的一部分暴露为迭代器,我们的也不例外。我们将为const
和非const
迭代器类型定义类型别名,因为这使实现如界限检查迭代器等替代方案变得简单,如果我们觉得需要这样做,并实现begin()
和end()
成员函数的const
和非const
版本:
// ...
public:
using iterator = pointer;
using const_iterator = const_pointer;
iterator begin() { return elems; }
const_iterator begin() const { return elems; }
iterator end() { return begin() + size(); }
const_iterator end() const {
return begin() + size();
}
// for users' convenience
const_iterator cend() const { return end(); }
const_iterator cbegin() const { return begin(); }
// ...
你可能会对为begin()
和end()
编写的const
和非const
版本带来的语法重复表示不满,因为它们在语法上相似,但在语义上不同。如果你手头有 C++23 编译器,你可以通过方便的“推导this
”功能来简化这一点:
// alternative approach (requires C++23)
template <class S>
auto begin(this S && self) { return self.elems; }
template <class S>
auto end(this S && self) {
return self.begin() + self.size();
}
这是一种稍微复杂的方式来表达这些函数,但它通过利用类型推导系统通过转发引用将begin()
和end()
的两个版本合并为一个。
构造函数和其他特殊成员函数
现在我们来看看构造函数。我们将首先查看的是默认构造函数和一个参数化构造函数,它接受元素数量和一个初始值作为参数,这样Vector<char>(3,'a')
就产生了一个包含三个值为'a'
的元素的容器。请注意,在这种情况下,default
默认构造函数(是的,我知道)是隐式constexpr
的,因为所有非static
成员初始化器都可以在constexpr
上下文中解析:
// ...
Vector() = default;
Vector(size_type n, const_reference init)
: elems{ new value_type[n] },
nelems{ n }, cap{ n } {
try {
std::fill(begin(), end(), init);
} catch(...) {
delete [] elems;
throw;
}
}
// ...
请注意在这个构造函数中的异常处理代码,因为它会反复出现。我们正在编写一个通用容器,因此我们使用了一些我们事先不知道的类型 T
。当调用 std::fill()
,将 init
参数的值赋给序列中每个 T
对象时,我们正在将一个 T
值赋给一个 T
对象,但我们不知道那个赋值操作符是否可以抛出异常。
我们的职责是 elems
,一个动态分配的 T
类型的数组,所以如果其中一个赋值操作符抛出异常,我们需要确保在 Vector<T>
构造函数失败之前,该数组被销毁并释放;否则,我们将泄漏内存,甚至更糟的是,我们在这个数组中构造的对象将不会被最终化。catch(...)
块意味着“捕获任何东西”,实际上并不真正知道在这种情况下你捕获了什么,而 throw;
表达式意味着“重新抛出你所捕获的任何东西”。确实,我们不想在这种情况下处理异常(我们没有足够的执行上下文知识来这样做:这是一个控制台应用程序?一个图形应用程序?一个嵌入式系统?其他什么?);我们只想确保我们未能构造 Vector<T>
对象没有泄漏资源,并让用户代码确切地知道为什么我们的构造函数未能满足其后置条件(未能构造一个有效的对象)。
复制构造函数将遵循类似的模式,除了不是用单个值的副本填充序列,而是从源序列(other
)复制值到目标序列(*this
或 elems
,取决于你如何看待它)。移动构造函数当然大不相同:
// ...
Vector(const Vector &other)
: elems{ new value_type[other.size()] },
nelems{ other.size() }, cap{ other.size() } {
try {
std::copy(other.begin(), other.end(), begin());
} catch(...) {
delete [] elems;
throw;
}
}
// ...
Vector(Vector &&other) noexcept
: elems{ std::exchange(other.elems, nullptr) },
nelems{ std::exchange(other.nelems, 0) },
cap{ std::exchange(other.cap, 0) } {
}
// ...
如您所见,对于这种类型,复制构造函数是一个昂贵的家伙:为 other.size()
个对象进行分配(对于非平凡构造的对象,伴随着对类型 T
的默认构造函数的多次调用),然后进行 other.size()
次赋值,以及抛入异常处理。
移动构造函数更简单:它是一个常数时间的 noexcept
函数。在大多数类中,技术上不需要移动操作(毕竟,C++ 没有移动操作也能良好地运行多年),但当你能利用它们时,你很可能应该这样做。速度提升可能是惊人的,执行速度变得更加可预测。
关于价值和显著特性
如果你仔细阅读复制构造函数的代码,可能会注意到*this
没有复制other.capacity()
,而是决定让cap
成为other.size()
的副本。实际上,在这种情况下这样做是正确的:容器的大小(即所谓的capacity()
)更多的是该对象生命周期的产物,显示了它随时间增长的过程。我们想要的是,在复制一个对象之后,原始对象和副本在operator==
运算符下比较相等,当然,capacity()
函数不会干预这个功能:两个数组通常被认为相等,如果它们有相同数量的元素,并且当与另一个容器中的对应元素比较时,每个元素都有相同的值。在实际情况中复制容量是可行的,但对于大多数用例来说,这将是浪费的。
我添加了一个构造函数,它接受一个initializer_list<T>
参数,以便用类型为T
的值的序列初始化Vector<T>
对象。析构函数应该是自解释的:
// ...
Vector(std::initializer_list<T> src)
: elems{ new value_type[src.size()] },
nelems {src.size() }, cap{ src.size() } {
try {
std::copy(src.begin(), src.end(), begin());
} catch(...) {
delete [] elems;
throw;
}
}
// ...
~Vector() {
delete [] elems;
}
如果以……无纪律的方式实现从源对象(这里:other
)到目标对象(*this
)的复制赋值运算符,可能会很复杂,因为它涉及到清理代码(*this
在赋值之前的内容)、复制源对象的状态,并确保我们正确处理自赋值和复制源对象状态时可能抛出的异常。
幸运的是,Scott Meyers(以及无数其他人)提出的一个巧妙技巧解决了这个问题,他注意到复制赋值可以表达为复制构造函数(对象复制的焦点)、析构函数(清理发生的地方)和swap()
成员函数的组合:你只需将参数复制到一个匿名对象中(以使它的生命周期最小),然后交换那个未命名的临时对象的状态与*this
的状态,从而使得*this
成为other
的副本。这种编程习惯几乎总是有效的,这也解释了它的成功!
移动赋值可以像复制赋值一样表达,但在赋值运算符的实现中,用移动构造函数替换复制构造函数:
// ...
void swap(Vector &other) noexcept {
using std::swap;
swap(elems, other.elems);
swap(nelems, other.nelems);
swap(cap, other.cap);
}
Vector& operator=(const Vector &other) {
Vector{ other }.swap(*this);
return *this;
}
Vector& operator=(Vector &&other) {
Vector{ std::move(other) }.swap(*this);
return *this;
}
// ...
类似向量的类的基本服务
我们现在已经实现了处理Vector<T>
对象内部表示的特殊成员函数,但编写一个方便的动态数组类型还有更多的工作要做。例如,成员函数允许你访问first()
元素,或者最后一个(back()
)元素,或者允许你通过方括号访问数组中的特定索引的元素(这些都是预期的):
// ...
reference operator[](size_type n) {
return elems[n];
}
const_reference operator[](size_type n) const {
return elems[n];
}
// precondition: !empty()
reference front() { return (*this)[0]; }
const_reference front() const { return (*this)[0]; }
reference back() { return (*this)[size() - 1]; }
const_reference back() const {
return (*this)[size() - 1];
}
// ...
如预期的那样,在空的Vector<T>
上调用front()
或back()
是未定义的行为(如果你愿意,你可以让这些函数抛出异常,但这样所有人都要为那些可能只在不那么this
的功能中表现不佳的少数程序付出代价):
// alternative approach, (requires C++23)
// ...
template <class S>
decltype(auto) operator[](this S && self,
size_type n) {
return self.elems[n];
}
// precondition: !empty()
template <class S>
decltype(auto) front(this S &&self) {
return self[0];
}
template <class S>
decltype(auto) back(this S &&self) {
return self[self.size()-1];
}
// ...
一些开发者可能会想在 const
和非 const
形式下都添加一个 at()
成员函数,它的行为类似于 operator[]
,但如果尝试访问底层数组越界则抛出异常。如果你愿意,可以这样做。
比较两个 Vector<T>
对象是否相等或不相等,如果我们使用算法,并且为我们的类型实现了迭代器,那么这是一个相对简单的问题:
// ...
bool operator==(const Vector &other) const {
return size() == other.size() &&
std::equal(begin(), end(), other.begin());
}
// can be omitted since C++20 (synthesized by
// the compiler through operator==())
bool operator!=(const Vector &other) const {
return !(*this == other);
}
// ...
最后,你可能会说,我们达到了讨论内存管理的书籍中最吸引我们的点:如何向我们的容器中添加元素,以及底层内存是如何管理的。在不逐一介绍客户端代码可能使用的向 Vector<T>
对象添加元素的所有机制的情况下,我们至少会检查 push_back()
和 emplace_back()
成员函数:
-
在这个版本中,将有两个
push_back()
成员函数:一个接受const T&
作为参数,另一个接受T&&
。接受const T&
参数的那个函数将在容器的末尾复制该参数,而接受T&&
的那个函数将移动它到那个位置。 -
emplace_back()
成员函数将接受一个可变参数包,然后将它们完美地转发到将被放置在容器末尾的T
对象的构造函数中。 -
emplace_back()
通过返回对新构造对象的引用来提供便利,以防用户代码希望立即使用它。这不是通过push_back()
实现的,push_back()
是用一个已经完全构造的对象调用的,用户代码已经可以访问它。
在所有三个函数中,我们首先检查容器是否已满,如果是,则调用 grow()
,这是一个私有成员函数。grow()
函数需要分配比容器当前持有的更多内存,这当然可能会失败。注意,如果 grow()
抛出异常,则新对象的添加从未发生,容器保持完整。注意,grow()
考虑了 capacity()
的值为 0
的可能性,在这种情况下,将选择任意默认容量。
一旦 grow()
成功,我们就在容器的存储中添加新元素到最后的对象之后。注意,值是通过赋值添加的,这意味着赋值操作左侧的对象,意味着 grow()
不仅添加了存储,还用(很可能是)类型 T
的默认对象初始化了它。因此,我们可以推断,在这个 Vector<T>
的实现中,类型 T
需要公开一个默认构造函数:
// ...
void push_back(const_reference val) {
if(full())
grow();
elems[size()] = val;
++nelems;
}
void push_back(T &&val) {
if(full())
grow();
elems[size()] = std::move(val);
++nelems;
}
template <class ... Args>
reference emplace_back(Args &&...args) {
if (full())
grow();
elems[size()] =
value_type(std::forward<Args>(args)...);
++nelems;
return back();
}
private:
void grow() {
resize(capacity()? capacity() * 2 : 16);
}
// ...
注意,push_back()
和 emplace_back()
中的插入代码在两种情况下都执行以下操作:
elems[size()] = // the object to insert
++nelems;
你可能会想将元素数量的增加和实际的插入表达式合并为一个,如下所示:
elems[nelems++] = // the object to insert
然而,不要这样做。“你为什么阻止我?”你可能会问。嗯,这会导致异常不安全的代码!原因在于 operator++()
的后缀版本有很高的(非常高!)优先级,远高于赋值。这意味着在组合表达式中,nelems++
发生得非常早(这可能会被忽视,因为那个表达式返回 nelems
的旧值),而赋值发生在稍后,但赋值可能会抛出异常:我们正在将类型 T
的对象赋值给另一个相同类型的对象,我们不知道 T::operator=(const T&)
是否会抛出异常。当然,如果它抛出了异常,赋值就不会发生,并且容器末尾不会添加任何对象;但元素的数量会增加,导致一个不一致的 Vector<T>
对象。
这里有一个通用的技巧:在你知道可以安全修改之前不要修改你的对象。尽量先执行可能抛出异常的操作,然后执行可以修改你的对象的操作。这样你会睡得更好,并且对象损坏的风险会得到一定程度的缓解。
我们的 grow()
成员函数通过调用 resize()
并将容器的容量加倍(除非该容量为 0,在这种情况下它选择一个默认容量)来完成其工作。resize()
是如何工作的?在我们的实现中,这只是一个分配足够内存来覆盖新容量需求的问题,然后将对象从旧内存块复制或移动到新内存块,然后替换旧内存块并更新容量。
我们如何知道应该移动还是复制对象?嗯,由于移动可能会破坏原始对象,我们只有在 T::operator=(T&&)
明确为 noexcept
的情况下才会这样做。std::is_nothrow_move_assignable<T>
特性是我们用来确定这一点(如果它不是,那么我们复制对象,这是安全的选择,因为它保留了原始对象)的工具:
// ...
public:
void resize(size_type new_cap) {
if (new_cap <= capacity()) return;
auto p = new T[new_cap];
if constexpr(std::is_nothrow_move_assignable_v<T>){
std::move(begin(), end(), p);
} else try {
std::copy(begin(), end(), p);
} catch (...) {
delete[] p;
throw;
}
delete[] elems;
elems = p;
cap = new_cap;
}
// ...
好吧,我同意这并不是简单的代码,但它也不是不可逾越的。记住,这仅仅是我们第一次草稿,并且对于大量类型来说,它将比 std::vector<T>
慢得多。
我们应该解决的这个容器的最后一个方面是如何向其中 insert()
元素以及如何从其中 erase()
元素。在工业级容器,如标准库中找到的容器,有一系列函数来执行这两个任务,所以我们将限制自己只做每个任务中的一个:在容器的指定位置插入一系列值,以及从容器的指定位置删除一个元素。
我们的insert()
成员函数将是一个模板,它接受一对源迭代器,命名为first
和last
,以及一个名为pos
的const_iterator
,它表示Vector<T>
对象内的一个位置。将其作为模板意味着我们可以使用任何容器的迭代器对作为插入值的来源,这确实是一个有用的特性。
在函数内部,我们将使用pos
的非const
等价物,命名为pos_
,但这仅仅是因为我们正在编写一个简化和不完整的容器,其中许多本应在const_iterator
对象上工作的成员函数缺失。
为了执行插入,我们将计算remaining
,这是容器中可用的空间(以对象数量表示),以及n
,它将是插入的对象数量。如果剩余空间不足,我们将通过我们的resize()
成员函数分配更多空间。当然,调用resize()
可能会导致pos_
变得无效(它指向旧的内存块,一旦resize()
完成任务,它将被另一个块替换),因此我们在调整大小之前会计算容器中的相对index
,并在调整大小后在新的内存块中重新计算pos_
的等价物。
插入过程中的一个有趣转折是,在执行pos_
位置插入n
个对象之前,我们希望将pos_
到end()
处的对象复制(或移动,但在这里我们将保持简单)到end()+n
的位置,但如果我们想避免在复制过程中覆盖一些我们试图复制的对象,那么这个复制必须向后(从最后一个到第一个)进行。std::copy_backward()
算法就是这样表达的:第三个参数表示复制的目标停止的位置,而不是开始的位置。
只有在这种情况下,我们才会复制由first
和last
确定的序列到pos_
位置,更新Vector<T>
对象中的元素数量,并返回标准所要求的(指向第一个插入元素的迭代器,或者在first==last
的情况下返回pos
,这意味着它们确定了一个空序列):
template <class It>
iterator insert(const_iterator pos, It first, It last) {
iterator pos_ = const_cast<iterator>(pos);
// deliberate usage of unsigned integrals
const std::size_t remaining = capacity() - size();
const std::size_t n = std::distance(first, last);
if (remaining < n) {
auto index = std::distance(begin(), pos_);
resize(capacity() + n - remaining);
pos_ = std::next(begin(), index);
}
std::copy_backward(pos_, end(), end() + n);
std::copy(first, last, pos_);
nelems += n;
return pos_;
}
我们的erase()
成员函数将接受一个名为pos
的const_iterator
参数,表示要从Vector<T>
对象中删除的元素的位置。我们再次求助于在函数内部使用一个名为pos_
的非const
迭代器的技巧。删除end()
是一个无操作(正如它应该的那样);否则,我们将从next(pos_)
到end()
进行线性复制到以pos_
开始的地址,有效地用其直接后继替换从该点开始的所有元素。
最后,我们将最后一个元素替换为某个默认值,这可能看起来不是必要的,但实际上是必要的,因为末尾的 T
对象可能正在持有需要释放的资源。例如,在一个使用 Vector<Res>
对象的程序中,其中 Res
是一个 RAII 类型,在析构时释放资源,如果不替换“位于末尾之后”的对象,可能会导致相关的资源仅在 Vector
对象被销毁时关闭,这可能会比客户端代码预期的晚得多。
然后我们更新 Vector<T>
对象中的元素数量。再次强调,这种实现意味着我们要求 T
提供一个默认构造函数,这并不是根本必要的(我们将在本章后面减轻这一要求):
iterator erase(const_iterator pos) {
iterator pos_ = const_cast<iterator>(pos);
if (pos_ == end()) return pos_;
std::copy(std::next(pos_), end(), pos_);
*std::prev(end()) = {};
--nelems;
return pos_;
}
我相信您一定在想我们如何做得更好,但我们会很快回到这个问题。在此期间,我们将研究如何实现一个基于节点的容器(类似于自制的 std::forward_list<T>
类型)。
编写自己的 forward_list<T>
替代方案
编写类似于 std::list
、std::unordered_map
、std::map
等基于节点的容器是一个有趣的练习,但在这个章节中,它有趣的事实并不一定会立即“闪耀”。这类类的兴趣点将在第十三章和第十四章中更为明显,但我们将仍然编写一个基本、简化的版本,以便在接下来的页面和章节中更清晰地展示我们的容器类型的发展。
前向列表是一种对精简的练习。我们希望类型尽可能小,并且能很好地完成其功能。一些前向列表在内存中只占用单个指针的大小(指向序列中第一个节点的指针);在我们的实现中,我们将为额外的整数(元素数量)付出代价,以获得 size()
成员函数的常数时间复杂度保证。
基于节点的容器的表示选择
在我们的实现中,ForwardList<T>
将持有节点,每个节点将包含一个由值(类型为 T
)和指向序列中下一个节点的指针组成的对。最后一个节点将具有空指针作为 next
节点。
ForwardList<T>
对象的表示将是一个 Node*
和一个无符号整数(表示列表中的元素数量)。我们的实现将非常简单,并展示一组小的成员函数。您可以随意扩展它,只要您限制自己只编写高效可写的函数。
ForwardList<T>
的实现
就像我们对 Vector<T>
所做的那样,我们将逐步分析我们的初始(天真)ForwardList<T>
实现。我们的第一步是通过标准库符合的类型别名定义我们的抽象,并选择我们的内部表示,这通常是容器的情况:
#include <cstddef>
#include <algorithm>
#include <utility>
#include <iterator>
#include <initializer_list>
#include <concepts>
template <class T>
class ForwardList {
public:
using value_type = T;
using size_type = std::size_t;
using pointer = T*;
using const_pointer = const T*;
using reference = T&;
using const_reference = const T&;
// ...
如前所述,ForwardList<T>::Node
对象将持有值和指向序列中下一个节点的指针。最初,下一个节点始终是一个空指针;组织节点是列表的责任,节点本身负责存储的值的所有权:
// ...
private:
struct Node {
value_type value;
Node *next = nullptr;
Node(const_reference value) : value { value } {
}
Node(value_type &&value)
: value { std::move(value) } {
}
};
Node *head {};
size_type nelems {};
// ...
ForwardList<T>
对象的默认状态将与空列表(head
为空指针且没有元素)相当。这对于大多数容器来说是一个合理的默认值,因为用户在实际中请求默认构造函数时通常期望一个空容器。
size()
和empty()
成员函数都很容易编写。我使用空头而不是零size()
来表达empty()
,因为在某些(合理的)ForwardList
实现中,大小会被计算而不是存储,这将使size()
成为一个线性复杂度的操作而不是常数时间的操作。在实践中,公开一个常数时间的size()
成员函数是一个好主意,因为它符合大多数用户的期望:
// ...
public:
size_type size() const { return nelems; }
bool empty() const { return !head; }
// ...
链表上的迭代器不能是原始指针,因为它存储的元素在内存中不是连续的。我们需要一个类,其实例可以遍历列表中的元素,并且可以考虑到元素的const
属性(或缺乏该属性)。
我们的(私有)ForwardList<T>::Iterator
类将是一个模板,其类型为U
,其中(在实践中)U
对于ForwardList<T>::iterator
将是T
,对于ForwardList<T>::const_iterator
将是const T
。
C++中的标准迭代器预期提供五个别名:
-
value_type
:被指向值的类型。 -
reference
:表示指向被指向值的引用的类型。 -
pointer
:表示指向被指向值的指针的类型。 -
difference_type
:表示此类型两个迭代器之间距离的类型(一个有符号整数)。 -
iterator_category
:截至 C++20,有六个类别,它们通过描述迭代器可以做什么来指导代码生成。在我们的情况下,因为我们将提供++
但不提供--
,我们将我们的迭代器描述为forward_iterator_category
的一部分。
迭代器是一个描述我们如何遍历值序列的对象,ForwardList<T>::Iterator
也不例外。迭代器暴露的关键操作可能是operator++()
(在序列中前进一个位置)、operator!=()
(比较两个迭代器以确定我们是否到达了序列的末尾),以及operator*()
和operator->()
(访问被指向的元素或其服务)。请注意,我们将ForwardList<T>
作为友元,因为这个类将负责节点的组织,当您对私有数据成员(如cur
)具有完全访问权限时,这会更容易完成:
// ...
private:
template <class U> class Iterator {
public:
using value_type =
typename ForwardList<T>::value_type;
using pointer = typename ForwardList<T>::pointer;
using reference = typename ForwardList<T>::reference;
using difference_type = std::ptrdiff_t;
using iterator_category =
std::forward_iterator_tag;
friend class ForwardList<T>;
private:
Node *cur {};
public:
Iterator() = default;
Iterator(Node *p) : cur { p } {
}
Iterator& operator++() {
cur = cur->next;
return *this;
}
Iterator operator++(int) {
auto temp = *this;
operator++();
return temp;
}
bool operator==(const Iterator &other) const {
return cur == other.cur;
}
// not needed since C++20
bool operator!=(const Iterator &other) const {
return !(*this == other);
}
U& operator*() { return cur->value; }
const U& operator*() const { return cur->value; }
U* operator->() { return cur->value; }
const U* operator->() const { return cur->value; }
};
public:
using iterator = Iterator<T>;
using const_iterator = Iterator<const T>;
// ...
之前提出的实现使用了一个基于元素类型 U
的模板,这些元素可以被 Iterator<U>
遍历。我们使用 U
而不是 T
,因为 T
是 ForwardList<T>
对象中值的类型。在 ForwardList<T>
中,我们通过 iterator
和 const_iterator
分别为 Iterator<T>
和 Iterator<const T>
类型创建别名。如果我们更喜欢那种方法,我们也可以编写两个不同的类型,但模板似乎更简洁。
begin()
和 end()
成员函数集实际上非常简单;begin()
返回列表的头部迭代器,而 end()
返回的“概念上位于末尾”的节点是一个空指针,这正是我们的 Iterator<U>
的默认构造函数所提供的:
// ...
iterator begin() { return { head }; }
const_iterator begin() const { return { head }; }
const_iterator cbegin() const { return begin(); }
iterator end() { return {}; }
const_iterator end() const { return {}; }
const_iterator cend() const { return end(); }
// ...
我们有时需要清除 ForwardList<T>
对象,这将导致我们销毁该容器的内容。在这个实现中,为了简单起见,我让析构函数调用 clear()
成员函数,但我们可以通过单独编写析构函数来节省一点处理时间(nelems
的重新初始化,在析构函数中不需要):
// ...
void clear() noexcept {
for(auto p = head; p; ) {
auto q = p->next;
delete p;
p = q;
}
nelems = 0;
}
~ForwardList() {
clear();
}
// ...
可能会让人感到诱惑的事情是编写一个 Node
析构函数,它会对它的 next
数据成员应用 delete
;如果我们这样做,clear()
将会简单地是 delete head;
(这将调用 delete head->next
并从该点递归地继续)然后是 nelems=0;
。然而,如果我是你,我不会这样做:原则上,ForwardList<T>
对象应该组织 ForwardList<T>
中的节点,而这个责任不应该交给众多的 Node
对象本身。然后,还有一个小的技术问题:调用 delete head;
将会调用 delete head->next;
,然后技术上会调用 delete
在 head->next->next;
上,依此类推。如果列表足够长,这会导致非常具体的栈溢出风险,而循环可以完全避免这种情况。
这里有一个简单的教训:当每个类都有一个单一责任时,生活会更简单。这已经有一段时间被知道为“单一责任原则”。这个原则是众所周知的面向对象编程 SOLID 原则中的 'S'。让容器处理基于节点的容器中的节点组织,并让节点存储值。
就构造函数而言,我们将为这个类实现一个小集合:
-
一个模拟空列表的默认构造函数
-
一个接受
std::initializer_list<T>
作为参数的构造函数 -
一个复制构造函数,它按顺序从源列表复制每个节点
-
一个移动构造函数
-
一个接受满足
std::input_iterator
概念的某些类型It
的两个对象的序列构造函数(本质上:允许你至少遍历一次序列并消费元素,这是我们完成工作所需的所有)
事实上,这个最后的构造函数可以被看作是对其他一些构造函数的泛化,并且只有默认构造函数和移动构造函数真正从单独编写中受益(如果我们不将工作委托给一个通用构造函数,我们可以更有效地计算序列的大小,所以如果这在你的代码库中有所区别,请随意这样做):
// ...
ForwardList() = default;
template <std::input_iterator It>
ForwardList(It b, It e) {
if(b == e) return;
try {
head = new Node{ *b };
auto q = head;
++nelems;
for(++b; b != e; ++b) {
q->next = new Node{ *b };
q = q->next;
++nelems;
}
} catch (...) {
clear();
throw;
}
}
ForwardList(const ForwardList& other)
: ForwardList(other.begin(), other.end()) {
}
ForwardList(std::initializer_list<T> other)
: ForwardList(other.begin(), other.end()) {
}
ForwardList(ForwardList&& other) noexcept
: head{ std::exchange(other.head, nullptr) },
nelems{ std::exchange(other.nelems, 0) } {
}
// ...
毫不奇怪,赋值可以通过我们在之前 Vector<T>
类型案例中应用的安全赋值习语来表示:
// ...
void swap(ForwardList& other) noexcept {
using std::swap;
swap(head, other.head);
swap(nelems, other.nelems);
}
ForwardList& operator=(const ForwardList& other) {
ForwardList{ other }.swap(*this);
return *this;
}
ForwardList& operator=(ForwardList&& other) {
ForwardList{ std::move(other) }.swap(*this);
return *this;
}
// ...
一些剩余的操作可以合理地被认为是微不足道的,例如,front()
、operator==()
和 push_front()
。正如你可以合理地假设对于前向列表,我们不会实现 back()
或 push_back()
成员函数,因为我们没有有效的算法来完成我们的表示选择(唯一合理的算法需要遍历整个结构以找到最后一个节点,从而导致线性复杂度的算法):
// ...
// precondition: !empty()
reference front() { return head->value; }
const_reference front() const { return head->value; }
bool operator==(const ForwardList &other) const {
return size() == other.size() &&
std::equal(begin(), end(), other.begin());
}
// can be omitted since C++20
bool operator!=(const ForwardList &other) const {
return !(*this == other);
}
void push_front(const_reference val) {
auto p = new Node{ val };
p->next = head;
head = p;
++nelems;
}
void push_front(T&& val) {
auto p = new Node{ std::move(val) };
p->next = head;
head = p;
++nelems;
}
// ...
作为将值插入容器的一个例子,考虑以下 insert_after()
成员函数,该函数在由 pos
指向的节点之后插入一个值为 value
的节点。使用此函数,我们可以轻松构建更复杂的函数,例如在列表中的某个位置之后插入一系列值的函数(试试看!):
// ...
iterator insert_after
(iterator pos, const_reference value) {
auto p = new Node{ value };
p->next = pos.cur->next;
pos.cur->next = p;
++nelems;
return { p };
}
// ...
提供向容器中添加元素的可能性确实是一个有用的功能,同样,提供从容器中删除元素的选择也是。例如,请参见以下 erase_after()
成员函数的实现:
// ...
iterator erase_after(iterator pos) {
if (pos == end() || std::next(pos) == end())
return end();
auto p = pos.cur->next->next;
delete pos.cur->next;
pos.cur->next = p;
return { p->next };
}
};
这应该就足够了这个类。在本章的其余部分,对于 ForwardList<T>
来说,改进的空间很小,但我们将回到这个类,特别是在 第十三章,以及更详细的 第十四章。
然而,对于 Vector<T>
,我们可以做得比迄今为止更好……但这需要一些额外的复杂性。但我们准备好了,不是吗?
更好的内存管理
因此,这位谦逊的作者声称我们优雅但简单的 Vector<T>
类型与 std::vector<T>
无法匹敌。这听起来可能是一个大胆的声明:毕竟,我们似乎已经完成了所需的工作,而且,我们不仅使用了算法而不是原始循环;我们还捕获了异常,因为我们希望异常安全,但仅限于清理资源……我们做错了什么?
如果你在一个 Vector<int>
对象和一个 std::vector<int>
对象之间运行比较基准测试,实际上,你可能会在两个测试的相应数字之间发现不了太大的差异。例如,尝试向每个这些容器中添加一百万个 int
对象(通过 push_back()
),你会认为我们的容器表现得很不错。酷!现在,将其改为 Vector<std::string>
和 std::vector<std::string>
之间的比较测试,你可能会有些失望,因为我们“落后在尘埃中”,正如他们所说。
关于小对象优化的一词
如果你添加的不是太短的字符串(至少 25 个字符,比如),这将显示更多,因为对于某些不确定的“短”字符串值,大多数标准库都会执行所谓的小字符串优化(SSO),这是小对象优化(SOO)的一个特例。通过这种优化,当存储在对象中的数据足够小的时候,实现将使用所谓的“控制块”(实际上是数据成员)的存储作为原始存储,从而完全避免动态内存分配。正因为如此,“小”字符串不会分配,并且在实践中非常非常快。
但为什么呢?
在这两个测试的元素类型中有一个线索:int
是一个平凡可构造的类型,而std::string
不是。这个线索表明std::vector
可能调用 fewer 构造函数,本质上比Vector<T>
在处理内存和其中的对象方面更有效率。
问题是什么?好吧,让我们看看Vector<T>
的一个构造函数,以了解我们实现中存在的问题。除了默认构造函数(在我们的实现中是默认的)和移动构造函数之外,任何构造函数都可以,所以让我们选择接受元素数量和初始值作为参数的那个。请特别注意以下高亮代码:
// ...
Vector(size_type n, const_reference init)
: elems{ new value_type[n] }, nelems{ n }, cap{ n } {
try {
std::fill(begin(), end(), init);
} catch(...) {
delete [] elems;
throw;
}
}
// ...
elems
数据成员的构造分配了一个足够容纳n
个类型为T
的对象的内存块,并为这些n
个元素中的每一个调用默认构造函数。显然,如果T
是平凡可构造的,那么这些默认构造函数并不是一个大的担忧来源,但如果你认为T
不是平凡可构造的,那么你可能会质疑这样做的好处。
仍然,你可能会争辩说对象需要被构造,但然后向前看,你会注意到std::fill()
将每个这些默认的T
对象替换为init
的一个副本,这表明对象的初始默认构造基本上是浪费时间(我们从未使用过这些对象!)这是std::vector<T>
比我们做得更好的那种类型:避免浪费操作,仅限于必要的事情。
我们现在将尝试了解我们如何能更接近std::vector<T>
在实践中实现的效果。
更高效的 Vector
更高效的Vector<T>
的关键是区分分配和构造,这是我们在这本书中多次讨论过的,并且,嗯,以适当的方式和受控的环境欺骗类型系统。是的,这本书中那些“邪恶”的早期章节现在会很有用。
我们不会在本页中重写整个Vector<T>
,但我们会查看选定的成员函数来突出需要完成的工作(完整的实现可以在本章开头提到的 GitHub 仓库中找到)。
我们可以尝试手动进行这项工作,使用我们已知的语言设施,例如 std::malloc()
,来分配一个原始内存块,并使用放置 new
来在该块中构造对象。使用相同的构造函数,该构造函数接受元素数量和初始值作为参数,我们就会得到以下内容:
// ...
Vector(size_type n, const_reference init)
// A
: elems{ static_cast<pointer>(
std::malloc(n * sizeof(value_type)
) }, nelems{ n }, cap{ n } {
// B
auto p = begin(); // note: we know p is a T*
try {
// C
for(; p != end(); ++p)
new(static_cast<void*>(p)) value_type{ init };
} catch(...) {
// D
for(auto q = begin(); q != p; ++q)
q->~value_type();
std::free(elems);
throw;
}
}
// ...
现在是…令人不愉快。请注意这个函数中标记为 A 到 D 的部分:
-
在
n
个类型为T
的对象中,但我们限制自己使用原始内存分配(在这个点上没有调用T
对象的构造函数),但我们保留一个指向该内存块的T*
以供我们自己的目的。我们的实现需要内部知道,在这一点上elems
指针的类型是不正确的。 -
在
begin()
中知道iterator
在我们的实现中与T*
是同一件事。如果我们的实现使用类而不是原始指针来模拟迭代器,我们就必须在这里做一些工作,以获取底层指针到原始存储的指针,以便实现函数的其余部分。 -
在我们分配的内存块内
T
对象的位置。由于那里没有对象可以替换,我们使用放置new
来构造这些对象,并利用我们对类型系统撒谎的事实(即我们使用了T*
,尽管我们分配了原始内存)来进行从对象到对象的指针算术操作。 -
在
T
对象中。因为我们是我们唯一知道其中存在T
个对象的人,就像我们是我们唯一知道我们未能构造的第一个对象确切位置的人一样,我们需要手动销毁这些对象,然后释放(现在为原始的)内存块并重新抛出异常。作为额外的好处,这种实现甚至不符合标准;我们应该按照构造的相反顺序销毁对象,而本例并没有这样做。
顺便说一下,这个例子清楚地说明了为什么你不能从析构函数中抛出异常:如果在 D 期间抛出异常,我们无法合理地希望恢复(至少不是不承担高昂的成本)。
你,亲爱的读者,现在可能正在想,这对非专业人士来说过于复杂,而且错误率极高,以至于他们无法希望以这种方式编写整个容器。确实,这种复杂性会渗透到大量成员函数中,使得质量控制比你所希望的更加困难。
但是等等,还有希望!正如你可能想象的那样,你的库供应商面临着与我们相同的挑战(以及更多!),因此标准库提供了低级设施,使得在自定义容器中处理原始内存成为一项合理可实现的任务,只要你了解,嗯,你在这本书中迄今为止所读到的内容。
使用低级标准设施
<memory>
标准库头文件是内存管理爱好者的一座宝库。我们已经在该头文件中讨论了定义的标准智能指针(见第五章以获取提醒),但如果你深入研究,你将看到一些用于操作原始内存的算法。
以 Vector<T>
构造函数为例,它接受一个元素数量和一个初始值作为参数,我们从相对简单的东西开始,它分配一个 T
对象的数组并通过调用 std::fill()
来替换它们,变成了一个显著更复杂的版本。原始版本既简单又低效(我们构建了不需要的对象只是为了替换它们);替换版本更高效(做最小的工作),但需要更多的技能来编写和维护。
我们现在将检查这些功能对我们分配成员函数实现的影响。我们将首先关注构造函数,因为它们是一个很好的起点。
对构造函数的影响
在实践中,当你想编写一个显式管理内存的自定义容器时,最好使用 <memory>
中找到的低级功能。以下是一个例子:
// ...
Vector(size_type n, const_reference init)
: elems{ static_cast<pointer>(
std:malloc(n * sizeof(value_type))
) }, nelems{ n }, cap{ n } {
try {
std::uninitialized_fill(begin(), end(), init);
} catch(...) {
std::free(elems);
throw;
}
}
// ...
这比我们完全自己编写的版本要好得多,不是吗?这个版本的两大亮点如下:
-
我们分配了一个适当大小的原始内存块,而不是
T
对象的数组,从而避免了初始版本中所有不必要的默认构造函数。 -
我们用
std::uninitialized_fill()
的调用替换了std::fill()
(在<algorithm>
中找到),它使用T::operator=(const T&)
并因此假设赋值操作左侧存在一个现有的对象,而std::uninitialized_fill()
则假设它正在遍历原始内存并通过放置new
来初始化对象。
这个算法(以及这个家族的其他算法)的美丽之处在于它是异常安全的。如果由 std::uninitialized_fill()
调用的任何一个构造函数最终抛出异常,那么在异常发生之前它成功创建的对象将会被销毁(按照构造的相反顺序,正如它们应该的那样),然后异常才会离开函数。
这实际上是我们手动(笨拙地)编写的。除了我们现在分配和释放原始内存之外,其余的代码与原始的简单版本非常相似。这可能会让你感觉好很多……而且确实应该如此。
对于其他构造函数也可以采取类似的方法。以复制构造函数为例:
// ...
Vector(const Vector& other)
: elems{ static_cast<pointer>(
std::malloc(n * sizeof(value_type))
) },
nelems{ other.size() }, cap{ other.size() } {
try {
std::uninitialized_copy(
other.begin(), other.end(), begin()
);
} catch (...) {
std::free(elems);
throw;
}
}
// ...
正如你所见,有了合适的算法,在原始内存上工作的快速实现与原始的、较慢的版本非常相似。
这里关键的是理解 API 的边界。例如,std::uninitialized_copy()
函数接受三个参数:源序列的开始和结束(这个序列假定包含对象)以及目标序列的开始(这个序列假定是适当对齐的,由原始内存组成,而不是对象)。如果函数因为满足其后置条件并在目标序列中构造了对象而完成其执行,那么目标序列包含对象。另一方面,如果函数未能满足其后置条件,那么目标序列中没有对象,因为无论函数构造了什么,它也会销毁。
可以用其他构造函数进行类似的操作,记住默认构造函数和移动构造函数实现得非常不同,因此需要不同的处理方式。
对析构函数的影响
在Vector<T>
的这个实现中,析构函数很有趣:当对象达到其生命周期的末尾时,我们不能简单地对其elems
数据成员调用delete[]
,因为它最初并没有通过new[]
分配,它是由一系列T
对象组成的,后面可能跟着一系列原始字节。我们不想对任意序列的字节调用T::~T()
,因为这可能会在我们的程序中造成相当大的破坏并引起UB。
唯一知道容器中有多少对象的是Vector<T>
对象本身,这意味着它将需要destroy()
剩余的对象,然后才能free()
(现在已无对象)的剩余内存块。在T
对象序列上应用std::destroy()
算法会在每个对象上调用T::~T()
,将对象序列转换为原始内存:
// ...
~Vector() {
std::destroy(begin(), end());
std::free(elems);
}
// ...
这些低级内存管理算法确实有助于阐明我们编写的代码的意图,正如你所看到的。
对每个元素插入函数的影响
在成员函数push_back()
和emplace_back()
中也发生了类似的情况,我们过去通过赋值替换数组末尾的一些现有对象;现在我们需要在数组的末尾构造一个对象,因为那里已经没有对象了(我们不无谓地构造对象;这就是我们努力的目标!)。
我们当然可以使用 placement new
来完成这个任务,但标准库提供了一个道德上的等价物,名为std::construct_at()
。这使得从源代码中我们的意图更加清晰:
// ...
void push_back(const_reference val) {
if (full())
grow();
std::construct_at(end(), val);
++nelems;
}
void push_back(T&& val) {
if (full())
grow();
std::construct_at(end(), std::move(val));
++nelems;
}
template <class ... Args>
reference emplace_back(Args &&...args) {
if (full())
grow();
std::construct_at(
end(), std::forward<Args>(args)...
);
++nelems;
return back();
}
对增长函数的影响
我们最初实现的grow()
函数在Vector<T>
上调用的是resize()
,但resize()
是用来初始化存储为对象的。为了在不使用对象初始化的情况下使分配的存储空间增长,我们需要一个不同的成员函数,即reserve()
。
关于resize()
和reserve()
之间的区别
简单来说,resize()
可能向容器中添加对象,因此它可以修改 size()
和 capacity()
。另一方面,reserve()
不会向容器添加任何对象,仅限于可能增加容器使用的存储空间;换句话说,reserve()
可以改变 capacity()
但不会改变 size()
。
沿着 std::vector<T>
设定的例子,我们的 Vector<T>
类将提供 resize()
和 reserve()
。以下是一个 resize()
的版本,它适应了我们部分对象、部分原始内存容器的现实情况,并伴随着一个适合 Vector<T>
的 reserve()
实现。我们将分别讨论 reserve()
和 resize()
:
// ...
private:
void grow() {
reserve(capacity()? capacity() * 2 : 16);
}
public:
void reserve(size_type new_cap) {
if(new_cap <= capacity()) return;
auto p = static_cast<pointer>(
std::malloc(new_cap * sizeof(T))
);
if constexpr(std::is_nothrow_move_assignable_v<T>) {
std::uninitialized_move(begin(), end(), p);
} else try {
std::uninitialized_copy(begin(), end(), p);
} catch (...) {
std::free(p);
throw;
}
std::destroy(begin(), end());
std::free(elems);
elems = p;
cap = new_cap;
}
// ...
reserve()
成员函数首先确保请求的新容量高于现有容量(否则就没有什么可做的)。如果是这样,它将分配一个新的内存块,并将 Vector<T>
对象的现有元素移动或复制到该新内存中(如果移动 T
对象可以抛出异常,则将进行复制:亲爱的读者,进行移动操作时 noexcept
是值得的!)使用构建对象到原始内存中的算法。
然后销毁 elems
中留下的 T
对象(即使它们已经被移动:它们仍然需要被最终化),并确保更新 cap
并使 elems
指向新的存储块。当然,size()
不会改变,因为容器中没有添加新对象。
对于 resize()
(如下所示)的过程类似,但不同之处在于,从 size()
索引开始的内存块中的位置被初始化为默认的 T
而不是保留在原始内存状态。因此,size()
被更新,导致与调用 reserve()
后获得的语义不同:
// ...
void resize(size_type new_cap) {
if(new_cap <= capacity()) return;
auto p = static_cast<pointer>(
std::malloc(new_cap * sizeof(T))
);
if constexpr(std::is_nothrow_move_assignable_v<T>) {
std::uninitialized_move(begin(), end(), p);
} else try {
std::uninitialized_copy(begin(), end(), p);
} catch (...) {
std::free(p);
throw;
}
std::uninitialized_fill(
p + size(), p + capacity(), value_type{}
);
std::destroy(begin(), end());
std::free(elems);
elems = p;
nelems = new_cap;
cap = new_cap;
}
// ...
我们正在实施的这种更复杂的结构显然会影响我们 insert()
或 erase()
元素的方式。
对元素插入和删除函数的影响
如预期的那样,成员函数如 insert()
和 erase()
必须更新以考虑我们对 Vector<T>
对象内部组织所做的更改。只要每个函数的语义从一开始就清晰,这并不痛苦(实际上,如果有任何更改,它们可能非常微小),但它确实需要小心。
例如,以 insert(pos,first,last)
为例,我们正在从 图 12.1 描述的简单模型转移到:
图 12.1 – 简单 Vector
在这里,在位置 pos
插入 first,last)
序列意味着(以相反顺序)复制 [pos,end())
中的元素到位置 pos + n
,然后使用 [first,last)
覆盖 [pos,pos+n)
中的元素,以更复杂的模型描述 图 12.2:
![图 12.2 – 当前 Vector
图 12.2 – 当前 Vector
理念是我们需要在 pos
位置插入 first,last)
,这意味着 [pos,pos+n)
中的元素必须被复制(或移动)。这将需要在原始内存中构造一些对象(如图中所示的灰色区域)并通过复制(或移动)赋值来替换一些其他对象。
这里需要考虑四个步骤:
-
应该从
[begin(),end())
序列中复制或移动多少个元素到容器末尾的原始内存块中,以及在这些块中应该在哪里构造结果对象。 -
如果需要从
[first,last)
序列中插入到原始内存中的元素(可能没有),应该有多少个?如果有这样的对象,它们将被插入到end()
。 -
如果需要从
[pos,end())
序列中复制或移动元素以替换容器中现有的对象(可能没有),应该有多少个?在这种情况下,目标范围的末尾将是end()
。 -
最后,从
[first,last)
序列中剩余要插入的部分将被复制到容器的pos
位置开始处。
一种可能的实现方式如下:
// ...
template <class It>
iterator insert(const_iterator pos, It first, It last) {
iterator pos_ = const_cast<iterator>(pos);
const auto remaining = capacity() - size();
const auto n = std::distance(first, last);
// we use cmp_less() here as remaining is an unsigned
// integral but n is a signed integral
if (std::cmp_less(remaining, n)) {
auto index = std::distance(begin(), pos_);
reserve(capacity() + n - remaining);
pos_ = std::next(begin(), index);
}
// objects to displace (move or copy) from the
// [begin(),end()) sequence into raw memory
const auto nb_to_uninit_displace =
std::min<std::ptrdiff_t>(n, end() - pos_);
auto where_to_uninit_displace =
end() + n - nb_to_uninit_displace;
if constexpr(std::is_nothrow_move_constructible_v<T>)
std::uninitialized_move(
end() - nb_to_uninit_displace, end(),
where_to_uninit_displace
);
else
std::uninitialized_copy(
end() - nb_to_uninit_displace, end(),
where_to_uninit_displace
);
// objects from [first,last) to insert into raw
// memory (note: there might be none)
const auto nb_to_uninit_insert =
std::max<std::ptrdiff_t>(
0, n - nb_to_uninit_displace
);
auto where_to_uninit_insert = end();
std::uninitialized_copy(
last - nb_to_uninit_insert, last,
where_to_uninit_insert
);
// objects to displace (copy or move) from the
// [pos,end()) sequence into that space (note:
// there might be none)
const auto nb_to_backward_displace =
std::max<std::ptrdiff_t>(
0, end() - pos_ - nb_to_uninit_displace
);
// note : end of destination
auto where_to_backward_displace = end();
if constexpr (std::is_nothrow_move_assignable_v<T>)
std::move_backward(
pos_, pos_ + nb_to_backward_displace,
where_to_backward_displace
);
else
std::copy_backward(
pos_, pos_ + nb_to_backward_displace,
where_to_backward_displace
);
// objects to copy from [first,last) to pos
std::copy(
first, first + n - nb_to_uninit_insert, pos
);
nelems += n;
return pos_;
}
确保您不要移动 [first,last)
中的元素:这将是对用户不友好的,因为它可能会破坏源范围内的数据!
至于我们最初以更天真方式编写的 erase()
成员函数,我们需要做出的关键调整是在处理被移除元素的方式上:您可能还记得,在我们的天真版本中,我们在容器的末尾将默认的 T
赋值给被移除的元素,并抱怨这增加了类型 T
中默认构造函数的可疑要求。在这个版本中,我们将简单地销毁这个对象,结束其生命周期,并将其底层存储转换回原始内存:
iterator erase(const_iterator pos) {
iterator pos_ = const_cast<iterator>(pos);
if (pos_ == end()) return pos_;
std::copy(std::next(pos_), end(), pos_);
std::destroy_at(std::prev(end()));
--nelems;
return pos_;
}
希望这能让您,亲爱的读者,对编写更严肃的自家 std::vector
类型的实现有更好的理解,并对您最喜欢的标准库提供者的工艺有更高的欣赏。要知道,他们为您的程序能够如此高效地运行付出了这一切和更多!
常量或引用成员和 std::launder()
在我们结束这一章之前,我们需要说几句关于那些奇怪的容器,这些容器持有 const
类型的对象,以及那些元素为具有 const
或引用成员的类型容器的奇事。
考虑这个看似无害的程序:
// ...
int main() {
Vector<const int> v;
for(int n : { 2, 3, 5, 7, 11 })
v.push_back(n);
}
就我们目前的实现而言,这将无法编译,因为我们的实现调用了一些低级函数(如 std::free()
、std::destroy_at()
、std::construct_at()
等),这些函数的参数是一个指向非 const
类型的指针。
如果我们要支持这样的程序,这意味着我们将在实现的一些地方“去除”const
属性。例如,替换以下行
std::free(elems); // illegal if elems points to const
with this:
using type = std::remove_const_t<value_type>*;
std::free(std::copy() or std::copy_backward() algorithms will not work on const objects or objects with const data members. You can make it work by replacing assignment with destruction followed by construction, but your code will be less exception-safe if the construction fails just after the destruction of the object that had to be replaced.
Of course, casting away `const`-ness leads us into tricky territory as we are bordering the frightening lands of undefined behavior. Standard library implementors can, of course, do what they want, having the ears of the compiler implementors, but we mere mortals do not share this privilege and, for that reason, must tread carefully.
A similar situation arises with composite objects that have data members of some reference type: you cannot make a container of references as references are not objects, but you sure can make a container of objects with reference-type data members. The problem, of course, is making sense of what happens when an object with a reference data member is being replaced.
Let’s take a simpler example than `Vector<T>` to explain this situation. Suppose we have the following class, made to hold a reference to some object of type `T`:
include <type_traits>
模板
struct X {
static_assert(std::is_trivially_destructible_v
T &r;
public:
X(T &r) : r{ r } {
}
T& value() { return r; }
const T & value() const { return r; }
};
// ...
As is, this class is simple enough and seems easy to reason about. Now, suppose we have the following client code:
// ...
include
include
int main() {
int n = 3;
X
h.value()++;
std::cout << n << '\n'; // 4
std::cout << h.value() << '\n'; // 4
int m = -3;
// h = X
X
std::cout << p->value() << '\n'; // -3
// UB (-3? 4? 其他什么?)
std::cout << h.value() << '\n';
std::cout << std::launder(&h)->value() << '\n'; // -3
}
Replacing an `X<int>` object through assignment is incorrect as having a reference data member deletes your assignment operator, at least by default (the default meaning would be ambiguous: should the reference be rebound to something else, or should the referred-to object be assigned to?).
One way to get around this problem is to destroy the original object and construct a new object in its place. In our example, since we ensured (through `static_assert`) that `T` was trivially destructible, we just constructed a new object where the previous one stood (ending the previous object’s lifetime). The bits are then all mapped properly to the new object... except that the compiler might not follow our reasoning.
In practice, compilers track the lifetime of objects the best they can, but we placed ourselves in a situation where the original `X<int>` object has never been explicitly destroyed. For that reason, this original `X<int>` object could still be considered to be there by the compiler, but the bits of the original object have been replaced by the new object placed at that specific address through very manual means. There might be a discrepancy between what the bits say and what the compiler understands from the source code, because (to be honest) we have been playing dirty tricks with the explicit construction of an object at a specific address that happens to have been occupied by another object.
Accessing `value()` through `p` will definitely give you `-3` as it’s obvious that `p` points to an `X<int>` object that holds a reference to `m`, and `m` has the value `-3` at that point. Accessing `value()` through `h` is undefined behavior (will the resulting code give you what the bits say or what the compiler thinks that code is saying?).
This sort of evil-seeming situation, where the code logic as understood by the compiler might not match the bits, happens with objects with `const` data members, objects with reference data members, and some `union` types crafted in weird ways, but these are the tools we use for the low-level manipulation of objects, and that can be found underneath `std::optional<T>`, `std::vector<T>`, and others. It’s our fault, in the end, for using these weird types, but it’s part of life.
When the bits do not necessarily align with what the compiler can understand, we have `std::launder()`. Use this cautiously: it’s an optimization barrier that states “just look at the bits, compiler; forget what you know about source code when looking at this pointed-to object.” Of course, this is a very dangerous tool and should be used with a lot of care, but sometimes it’s just what is needed.
Summary
Whew, this was a long chapter! We implemented a naïve `vector`-like container, then a naïve `forward_list`-like container, and then took another look at the `vector`-like container (we will return to the `forward_list`-like container in the next two chapters) to show how tighter control over memory can lead to more efficient containers.
Our implementations in this chapter were “manual,” in the sense that we did the memory management by hand. That involved writing a lot of code, something we will reconsider in [*Chapter 13*. In *Chapter 14*, we will examine how allocators interact with containers, and will use this opportunity to revisit our `forward_list`-like container as there will be interesting aspects to examine as we continue our adventure through memory management in C++.
第十三章:使用隐式内存管理编写泛型容器
在上一章中,我们在Vector<T>
中编写了一个工作(尽管简单)的类似std::vector<T>
类型的实现,以及在ForwardList<T>
中编写了一个工作(尽管,再次,简单)的类似std::forward_list<T>
类型的实现。不错!
在我们Vector<T>
类型的情况下,经过最初的努力,我们得到了一个工作但有时效率不高的实现,然后我们努力将分配与构造分离,这样做减少了运行时所需的冗余工作量,但代价是更复杂的实现。在这个更复杂的实现中,我们区分了底层存储中已初始化的部分和未初始化的部分,并且当然,对这两部分都进行了适当的操作(将对象视为对象,将原始内存视为原始内存)。例如,我们使用赋值(以及使用赋值运算符的算法)来替换现有对象的内容,但更倾向于使用 placement new
(以及依赖于此机制的算法)在原始内存中创建对象。
上一章中我们的Vector<T>
实现是一个用大量源代码表达出的类。这种情况的原因之一是我们所进行的显式内存管理。确实,我们使Vector<T>
对象负责管理底层内存块以及存储其中的对象,这种双重责任带来了成本。在本章中,我们将通过使内存管理隐式来重新审视这种设计,并将讨论这种新方法的影响。希望,亲爱的读者,这将引导你走向可能的简化和对编码实践的改进。
在本章中,我们的目标将是以下内容:
-
为了以这种方式适应手写的容器,如
Vector<T>
,从而显著简化其内存管理责任 -
为了理解我们的设计对源代码复杂性的影响
-
为了理解我们的设计对异常安全性的影响
我们将大部分精力放在重新审视Vector<T>
容器上,但我们也会重新审视ForwardList<T>
,看看我们是否可以将同样的推理应用于这两种容器类型。到本章结束时,至少在Vector<T>
的情况下,我们仍然有一个手写的容器,它能够有效地管理内存并将原始内存与构造对象区分开来,但我们的实现将比我们在第十二章中产生的实现简单得多。
注意,关于Vector<T>
,本章将比较两个版本。一个将被命名为“天真版本”,它将是使用底层存储中T
类型对象的初始实现。另一个将被命名为“复杂版本”,它将考虑底层存储由两个(可能为空)的“部分”组成,T
类型对象位于开始处,原始内存位于末尾。
技术要求
您可以在本书的 GitHub 仓库中找到本章的代码文件:github.com/PacktPublishing/C-Plus-Plus-Memory-Management/tree/main/chapter13
。
关于本章代码摘录的一些说明
本章将主要回顾和修改(希望简化!)第十二章中的代码示例,在过程中使用前几章(特别是第五章和第六章)中的想法。由于用于Vector<T>
和ForwardList<T>
的大部分代码不会改变,我们不会重写整个类,以避免不必要的重复。
相反,我们将专注于对那些类先前版本所做的最有意义的修改,有时会比较修改“之前”和“之后”的实现。当然,GitHub 仓库中的代码示例是完整的,可以用来“完善画面”。
为什么显式内存管理使我们的实现复杂化
让我们暂时看看Vector<T>
的一个构造函数,正如在第十二章中所写的。为了简单起见,我们将使用接受元素数量和这些元素的初始值的构造函数。如果我们只限于elems
指向T
对象序列的简单版本,并暂时不考虑elems
指向在开始处包含T
对象和末尾包含原始内存的内存块的更复杂版本,我们就有以下内容:
// naïve version with elems of type T*
Vector(size_type n, const_reference init)
: elems{ new value_type[n] }, nelems{ n }, cap{ n } {
try {
std::fill(begin(), end(), init);
} catch (...) {
delete [] elems;
throw;
}
}
// ...
此构造函数分配一个T
对象的数组,通过一系列赋值初始化它们,处理异常等。try
块及其相应的catch
块是我们实现的一部分,但并非因为我们想处理T
对象构造函数抛出的异常。实际上:如果我们不知道T
是什么,我们怎么知道它可能会抛出什么异常呢?我们插入这些块是因为如果我们想避免泄漏,我们需要显式地分配和销毁数组。如果我们查看区分分配和构造的更复杂版本,情况会变得更加复杂:
// sophisticated version with elems of type T*
Vector(size_type n, const_reference init)
: elems{ static_cast<pointer>(
std::malloc(n * sizeof(value_type))
) }, nelems{ n }, cap{ n } {
try {
std::uninitialized_fill(begin(), end(), init);
} catch (...) {
std::free(elems);
throw;
}
}
// ...
正如我们所见,我们这样做是因为我们决定Vector<T>
将是那个内存的所有者。我们完全有权利这样做!但是,如果我们让其他东西负责我们的内存会怎样呢?
使用智能指针的隐式内存管理
在 C++中,将我们的Vector<T>
实现从手动管理内存更改为隐式管理内存的最简单方法是通过智能指针。这里的想法本质上是将Vector<T>
的elems
数据成员的类型从T*
更改为std::unique_ptr<T[]>
。我们将从两个角度来探讨这个问题:
-
这种变化如何影响
Vector<T>
的原始版本?作为提醒,我们来自第十二章的原始版本没有在底层存储中区分对象和原始内存,因此只存储对象。这导致了一个更简单的实现,但也是一个在许多场合不必要地构建对象,并且对于非平凡构造类型来说比更复杂的实现慢得多的实现。 -
这种变化如何影响避免了在实现上稍微复杂的情况下构建不必要的对象的性能陷阱的
Vector<T>
的复杂版本?
在这两种情况下,我们将检查一些成员函数,这些函数可以表明这种变化的影响。Vector<T>
的原始和复杂实现的完整实现都可以在本书相关的 GitHub 仓库中查看和使用。
对原始Vector<T>
实现的影响
如果我们的简化工作基于最初的、原始的第十二章版本,其中elems
简单地指向一个连续的T
对象序列,这将相当简单,因为我们可以改变:
// naïve implementation, explicit memory management
// declaration of the data members...
pointer elems{};
size_type nelems{}, cap{};
…变为:
// naïve implementation, implicit memory management
// declaration of the data members...
std::unique_ptr<value_type[]> elems;
size_type nelems{}, cap{};
…然后改变begin()
成员函数的实现,如下所示:
// naïve implementation, explicit memory management
iterator begin() {
return elems; // raw pointer to the memory block
}
const_iterator begin() const {
return elems; // raw pointer to the memory block
}
…更改为以下内容:
// naïve implementation, implicit memory management
iterator begin() {
return elems.get(); // raw pointer to the beginning
// of the underlying memory block
}
const_iterator begin() const {
return elems.get(); // likewise
}
仅做这一点就足以显著简化Vector<T>
类型的实现,因为释放内存将变得隐式。例如,我们可以通过完全删除异常处理来简化每个构造函数,例如,将以下实现更改为以下内容:
// naïve implementation, explicit memory management
Vector(size_type n, const_reference init)
: elems{ new value_type[n] }, nelems{ n }, cap{ n } {
try {
std:: fill(begin(), end(), init);
} catch (...) {
delete [] elems;
throw;
}
}
// ...
…变为这个显著更简单的版本:
// naïve implementation, implicit memory management
Vector(size_type n, const_reference init)
: elems{ new value_type[n] }, nelems{ n }, cap{ n } {
std:: fill(begin(), end(), init);
}
// ...
这种简化的原因如下:
-
如果
Vector<T>
对象负责分配的内存,那么在调用析构函数时将隐式地删除数组,但是为了调用析构函数,需要有一个要销毁的对象:Vector<T>
构造函数必须成功!这就解释了为什么我们需要捕获抛出的任何异常,手动删除数组,并重新抛出抛出的任何异常:直到达到析构函数的结束括号,没有Vector<T>
对象可以销毁,所有资源管理都必须显式完成。 -
另一方面,如果
elems
是智能指针,那么一旦智能指针本身被构造,它就负责被指向的对象,这发生在Vector<T>
构造函数的开括号之前。这意味着一旦elems
被构造,如果发生异常导致构造函数退出,它将被销毁,从而释放即将成为Vector<T>
对象的任务,不再需要销毁数组。为了明确:当我们到达Vector<T>
构造函数的开括号时,*this
的数据成员已经被构造,因此,即使*this
本身的构造没有完成,如果抛出异常,它们也将被销毁。C++ 的对象模型在这种情况下确实很奇妙。
亲爱的读者,你们中更有洞察力的人可能会注意到,即使你为一家不允许或反对使用异常的公司编写代码,使用智能指针所获得的异常安全性仍然存在。我们(隐式地)编写了异常安全的代码,而没有使用 try
或 catch
语句。
通过引入隐式内存管理来简化的一些其他示例包括移动操作和 Vector<T>
的析构函数,这将从以下内容变为:
// naïve implementation, explicit memory management
Vector(Vector &&other)
: elems{ std::exchange(other.elems, nullptr) },
nelems{ std::exchange(other.nelems, 0) },
cap{ std::exchange(other.cap, 0) } {
}
Vector& operator=(Vector &&other) {
Vector{ other }.swap(*this);
return *this;
}
~Vector() {
delete [] elems;
}
// ...
…简化如下:
// naïve implementation, implicit memory management
Vector(Vector&&) = default;
Vector& operator=(Vector&&) = default;
~Vector() = default;
// ...
将移动操作设置为 =default
之所以有效,是因为类型 std::unique_ptr
在移动时“做正确的事情”,并将所有者的所有权从源传递到目的地。
需要注意的事情
通过将移动操作设置为 =default
,我们在 Vector<T>
实现中引起了一点点语义变化。C++ 标准建议移动后的对象处于有效但未指定的状态,但并未详细说明“有效”的含义。我们编写的移动操作将移动后的对象恢复到与默认构造的 Vector<T>
对象等效的状态,但“默认”的移动操作将移动后的对象留下一个空的 elems
,但可能具有非零的大小和容量。只要用户代码在使用移动后的对象之前将其重新赋值,这在实践中仍然有效,但这是一个值得认可的语义变化。
另一种有趣的简化方法将是实现 resize()
成员函数。在原始的、天真的 Vector<T>
实现中,我们有以下内容:
// naïve implementation, explicit memory management
void resize(size_type new_cap) {
if(new_cap <= capacity()) return;
auto p = new T[new_cap];
if constexpr(std::is_nothrow_move_assignable_v<T>) {
std::move(begin(), end(), p);
} else try {
std::copy(begin(), end(), p);
} catch (...) {
delete[] p;
throw;
}
delete[] elems;
elems = p;
cap = new_cap;
}
在这里,我们再次面临从 T
对象到 T
对象的复制赋值操作中抛出异常的可能性,并需要处理异常以避免资源泄露。从显式资源管理到隐式资源管理,我们得到以下内容:
// naïve implementation, implicit memory management
void resize(size_type new_cap) {
if(new_cap <= capacity()) return;
auto p = std::make_unique<value_type[]>(new_cap);
if constexpr(std::is_nothrow_move_assignable_v<T>) {
std::move(begin(), end(), p.get());
} else {
std::copy(begin(), end(), p.get());
}
elems.reset(p.release());
cap = new_cap;
}
如您所见,整个异常处理代码已经消失。对象 p
拥有新的数组,并在函数执行结束时销毁它。一旦复制(或移动,取决于类型 T
的移动赋值是否标记为 noexcept
)完成,elems
通过 reset()
放弃之前拥有的数组(同时销毁它)并通过 release()
“窃取”由 p
释放的数组所有权。请注意,编写 elems = std::move(p);
会有类似的效果。
将这个简化过程应用到 Vector<T>
中,源代码逐渐减少,对于一个像 Vector<T>
的原始版本这样的容器,它只包含对象,没有底层存储末尾的原始内存块,我们可以节省大约 25%的源代码行数(对于这个学术实现,从大约 180 行减少到 140 行)。试试看,看看你自己能发现什么!
对复杂的 Vector 实现的影响
将相同的技巧应用到更复杂的 Vector<T>
上将需要更多的工作,因为 std::unique_ptr<T[]>
类型对象的析构函数的默认行为是将 operator delete[]
应用于它拥有的指针。正如我们所知,我们的复杂实现可以概念化为由两个(可能为空)的“部分”组成:一个由 T
对象手动放置到原始内存中的初始部分,后面跟着一个未初始化的、没有对象的原始内存部分。因此,我们需要以不同的方式处理每个“部分”。
我们仍然会使用 std::unique_ptr<T[]>
对象来管理内存,但我们需要使用一个 自定义删除器
对象(在第五章和第六章中讨论过)来考虑我们实现的特定细节。这个对象需要了解它将伴随的 Vector<T>
对象的运行时状态,因为它必须知道底层存储的每个“部分”从哪里开始以及在哪里结束,而这些都是随着代码执行而变化的。
这个实现的一个重要观点,这是一个反复出现但可能我们没有足够坚持的观点,是我们希望我们的实现向客户端代码暴露相同的接口,无论实现有何变化。这有时可能是不可能的或不合理的,但无论如何,这是一个有意义且值得追求的目标。这包括我们选择内部公共类型:例如,我们使用智能指针来管理底层内存的事实并不改变指向元素的指针是一个 T*
的事实:
// ...
template <class T>
class Vector {
public:
using value_type = T;
using size_type = std::size_t;
using pointer = T*;
using const_pointer = const T*;
using reference = T&;
using const_reference = const T&;
// ...
现在,由于我们希望将 elems
定义为一个智能指针,它拥有并管理底层存储,而不是一个原始指针,因此我们需要定义一个将被该智能指针使用的自定义删除器。
这个问题的一个重要方面是,自定义删除器需要知道Vector<T>
对象的状态,以便知道底层存储中哪些部分持有对象。因此,我们的std::unique_ptr<T[]>
的自定义删除器将是状态化的,并存储一个名为source
的Vector<T>
对象的引用。通过source
,deleter
对象的函数调用操作符将能够访问容器中的对象序列(从source.begin()
到source.end()
的半开序列)并在释放底层存储之前destroy()
这些对象:
// ...
private:
struct deleter {
Vector& source;
void operator()(value_type* p) {
std::destroy(std::begin(source),
std::end(source));
std::free(static_cast<void*>(p));
}
};
std::unique_ptr<value_type[], deleter> elems;
size_type nelems{},
cap{};
// ...
elems
数据成员知道自定义删除器的类型将是deleter
,但实际上将扮演删除器角色的对象必须知道它将与之交互的Vector<T>
对象。Vector<T>
的构造函数将负责提供此信息,并且我们需要小心地实现我们的移动操作,以确保我们不会传递删除器对象的状态并使我们的代码不一致。
如同在简单版本中提到的,我们需要调整begin()
成员函数,以考虑到elems
是一个智能指针,但我们的iterator
接口依赖于原始指针:
// ...
using iterator = pointer;
using const_iterator = const_pointer;
iterator begin() { return elems.get(); }
const_iterator begin() const { return elems.get(); }
// ...
我们的构造函数需要适应这样一个事实,即我们有一个自定义删除器,它会在发生任何不良情况或程序正常结束时进行清理。以下是Vector<T>
构造函数的三个示例:
// ...
constexpr Vector()
: elems{ nullptr, deleter { *this } } {
}
Vector(size_type n, const_reference init)
: elems{ static_cast<pointer>(
std::malloc(n * sizeof(value_type))
), deleter{ *this }
} {
std::uninitialized_fill(begin(), begin() + n, init);
nelems = cap = n;
}
Vector(Vector&& other) noexcept
: elems{ std::exchange(
other.elems.release()), deleter{ *this }
},
nelems{ std::exchange(other.nelems, 0) },
cap{ std::exchange(other.cap, 0) } {
}
// ...
请注意,我们在这里没有使用=default
来表示移动构造函数,因为我们不希望传递自定义删除器,我们的实现已经将此对象与特定的Vector<T>
对象关联起来。
在这里需要一个小注解:我们在deleter
对象的构造函数中传递了*this
,但是我们在*this
的构造完成之前就进行了这一操作,所以deleter
对象在*this
构造完成(在其构造函数的闭合括号之前)所做的任何操作都值得注意和关注。
在我们的情况下,如果类型T
的对象的构造函数抛出异常,deleter
对象将发挥作用。我们需要确保在deleter
对象可能介入的情况下,*this
的数据成员的值始终保持一致。
在我们的情况下,由于begin()
和end()
成员函数返回定义对象半开范围的迭代器,并且正如我们所知,std::uninitialized_fill()
调用构造函数(如果抛出异常)则销毁已构造的对象,我们必须确保nelems==0
直到所有对象都构造完成。请注意,我们定义了从begin()
到begin()+n
的范围进行初始化,并在调用std::uninitialized_fill()
之后改变nelems
:这样,如果抛出异常,则begin()==end()
,并且deleter
对象不会尝试销毁“非对象”。
类Vector<T>
的其他构造函数同样被简化;我们在这里不会展示它们,所以请将它们视为不那么令人畏惧的“留给读者的练习。”
Vector<T>
的简化通过一些现在需要我们付出很少或几乎不需要努力的特设成员函数变得明显。在这方面值得注意的是析构函数,现在它可以被默认;如本节前面提到的移动构造函数,我们不默认移动赋值操作,以避免转移自定义删除器的内部状态,如下面的代码片段所示:
// ...
~Vector() = default;
void swap(Vector& other) noexcept {
using std::swap;
swap(elems, other.elems);
swap(nelems, other.nelems);
swap(cap, other.cap);
}
Vector& operator=(const Vector& other) {
Vector{ other }.swap(*this);
return *this;
}
Vector& operator=(Vector&& other) {
Vector{ std::move(other) }.swap(*this);
return *this;
}
reference operator[](size_type n) { return elems[n]; }
const_reference operator[](size_type n) const {
return elems[n];
}
成员函数swap()
和operator[]
已被证明表明std::unique_ptr<T[]>
在许多方面表现得像T
对象的“常规”数组。Vector<T>
的许多其他成员函数保持不变,例如front()
、back()
、operator==()
、operator!=()
、grow()
、push_back()
和emplace_back()
。请参阅第十二章以了解这些函数的详细信息。
通过使用智能指针,reserve()
和resize()
函数也可以简化,因为我们可以消除显式的异常管理,同时由于std::unique_ptr<T[]>
是一个RAII类型,它会为我们处理内存,所以我们仍然保持异常安全。
在reserve()
的情况下,我们现在使用智能指针p
来持有分配的内存,然后将elems
中的对象通过move()
或copy()
操作移动到p
。一旦完成这些操作,我们就destroy()
掉elems
中剩余的对象,之后p
放弃其指针并将其转移到elems
,剩下的唯一事情就是更新容器的容量:
// ...
void reserve(size_type new_cap) {
if (new_cap <= capacity()) return;
std::unique_ptr<value_type[]> p{
static_cast<pointer>(
std::malloc(new_cap * sizeof(T))
)
};
if constexpr (std::is_nothrow_move_assignable_v<T>) {
std::uninitialized_move(begin(), end(), p.get());
} else {
std::uninitialized_copy(begin(), end(), p.get());
}
std::destroy(begin(), end());
elems.reset(p.release());
cap = new_cap;
}
在resize()
的情况下,我们现在使用智能指针p
来持有分配的内存,然后将elems
中的对象通过move()
或copy()
操作移动到p
,并在内存块的剩余部分构造默认的T
对象。一旦完成这些操作,我们就destroy()
掉elems
中剩余的对象,之后p
放弃其指针并将其转移到elems
,剩下的唯一事情就是更新容器的容量:
// ...
void resize(size_type new_cap) {
if (new_cap <= capacity()) return;
std::unique_ptr<value_type[]> p =
static_cast<pointer>(
std::malloc(new_cap * sizeof(T))
);
if constexpr (std::is_nothrow_move_assignable_v<T>) {
std::uninitialized_move(begin(), end(), p.get());
} else {
std::uninitialized_copy(begin(), end(), p.get());
}
std::uninitialized_fill(
p.get() + size(), p.get() + new_cap, value_type{}
);
std::destroy(begin(), end());
elems.reset(p.release());
nelems = cap = new_cap;
}
// ...
所有这一切的魔力,或者说,可以这样讲,就是我们的其他成员函数,如insert()
和erase()
,是建立在基本抽象如reserve()
、begin()
、end()
等之上的,这意味着它们不需要修改以考虑这种表示变化。
重新设计的后果
这种“重新设计”的后果是什么?它们在过程中已经提到,但让我们总结一下:
-
对于用户代码来说,后果基本上没有:
Vector<T>
类型的对象在内存管理实现中占据相同的空间,与显式内存管理实现(其中自定义删除器是状态化的)几乎占据相同的空间,并且每个都公开了相同的接口。 -
由于在 第五章 中解释的原因,基本上没有速度成本:在除基本、专为调试编译的优化级别之外编译的代码中,通过
std::unique_ptr<T>
进行操作,由于函数内联调用,将导致与通过T*
进行操作一样高效的代码。 -
实现变得更加简单:指令更少,没有显式的异常处理代码,更多的成员函数可以省略默认值…
-
这个隐式内存管理实现的 重要方面在于,即使在没有显式的
try
和catch
块的情况下,它也是异常安全的。这可能在许多情况下都会产生影响:例如,你可能处于不允许异常的情况,但发现自己正在使用一个可能抛出异常的库…或者可以在内存受限的情况下简单地调用operator new()
。在我们的隐式内存管理实现中,在这种情况下将是安全的,但一个采用手动内存管理方法且没有异常处理代码的实现则不会这么“幸运”。
在 Vector<T>
中实现自定义删除器的努力似乎是一个值得的投资。现在,你可能想知道这种情况是否与基于节点的容器相似,因此我们将通过回顾 第十二章 中的原始 ForwardList<T>
实现来探索这个问题。
推广到 ForwardList?
我们现在知道我们可以调整 Vector<T>
的实现,将其从显式内存管理模型转换为隐式模型,并且这样做有很多优点。将同样的方法应用于其他容器很诱人,但在开始这样的冒险之前,分析问题可能更明智。
我们在 第十二章 中实现了名为 ForwardList<T>
的具有显式内存管理的基于节点的容器。尝试改变这个容器的实现以使其更加隐式会有什么影响?
尝试 - 使每个节点对其后续节点负责
在我们探索如何使基于节点的容器中的内存管理更加隐式的方法中,一个可能的方法是改变 ForwardList<T>::Node
的定义,使得 next
数据成员变为 std::unique_ptr<Node>
而不是 Node*
。
概括来说,我们会得到以下结果:
template <class T>
class ForwardList {
public:
// ...
private:
struct Node {
value_type value;
std::unique_ptr<Node> next; // <--
Node(const_reference value) : value{ value } {
}
Node(value_type&& value) : value{ std::move(value) }{
}
};
Node* head{};
size_type nelems{};
// ...
初看起来,这似乎是一个改进,因为它将ForwardList<T>
析构函数简化为以下内容:
// ...
~ForwardList() {
delete head; // <-- lots of work starts here!
}
// ...
这种简化将引发一种“多米诺效应”:由于节点的next
数据成员成为列表中其后继节点的所有者,并且这对于链中的每个节点(除了head
本身)都是真的,因此销毁第一个节点确保了其后继节点以及其后继节点的销毁,依此类推。
这种明显的简化隐藏了一个棘手的事实:在这个实现下调用delete head;
时,我们可能会引发堆栈溢出。确实,我们用一个本质上相当于递归调用的东西替换了逐个节点应用delete
的循环,这意味着对堆栈使用的影响从固定变为与列表中节点数量成比例。这确实是个不愉快的消息!
在这个阶段,亲爱的读者,你可能正在想:“嗯,我本来只是打算用这个ForwardList<T>
类型来处理小列表,所以我不担心。”如果这反映了你的思考方式,那么也许我们应该探索一下在ForwardList<T>
类中这个实现决策的其他潜在影响。
其中一个影响是迭代器会变得稍微复杂一些:我们不希望遍历节点的迭代器成为该节点的唯一所有者。这确实会破坏结构,因为当我们在列表中遍历节点时,节点会被销毁。因此,ForwardList<T>::Node<U>
(其中U
是T
或const T
)仍然有一个T*
数据成员,这意味着例如operator++()
需要获取每个节点中std::unique_ptr<T>
数据成员的底层指针:
// ...
template <class U> class Iterator {
public:
// ...
private:
Node* cur{};
public:
// ...
Iterator& operator++() {
cur = cur->next.get(); // <--
return *this;
}
// ...
这只是略微增加了复杂性,但并不是无法管理的。
在第十二章中,我们将大多数ForwardList<T>
构造函数收敛到更通用的序列构造函数,该构造函数接受一对某种类型It
的前向迭代器作为参数。这个构造函数将变得部分复杂,因为现在连接节点需要我们知道每个节点内部使用了智能指针,但抛出异常时的清理只需要删除头节点并让上述“多米诺效应”发生:
// ...
template <std::forward_iterator It>
ForwardList(It b, It e) {
try {
if (b == e) return;
head = new Node{ *b };
auto q = head;
++nelems;
for (++b; b != e; ++b) {
q->next = std::make_unique<Node>(*b); // <--
q = q->next.get(); // <--
++nelems;
}
} catch (...) {
delete head; // <--
throw;
}
}
// ...
ForwardList<T>
的大多数成员函数将保持不变。例如,push_front()
这样的操作会有细微的调整:
// ...
void push_front(const_reference val) {
auto p = new Node{ val };
p->next = std::unique_ptr<Node>{ head }; // <--
head = p;
++nelems;
}
void push_front(T&& val) {
auto p = new Node{ std::move(val) };
p->next = std::unique_ptr<Node>{ head }; // <--
head = p;
++nelems;
}
// ...
如所见,我们需要区分使用head
数据成员的代码和使用链中其他节点的代码。类似的调整将适用于任何修改列表结构的成员函数,包括,值得注意的是,插入和删除操作。
一个更有趣、也许更有启发的成员函数将是insert_after()
成员函数,它在列表中给定迭代器之后插入一个元素。让我们详细看看这个函数:
// ...
iterator
insert_after(iterator pos, const_reference value) {
auto p = std::make_unique<Node>(value); // <-- A
p->next.reset(pos.cur->next.get()); // <-- B
pos.cur->next.release(); // <-- C
pos.cur->next.reset(p.get()); // <-- D
p.release(); // <-- E
++nelems;
return { pos.cur->next.get() }; // <-- F
}
// ...
嗯,这是相当多的更新文本!这个函数怎么会变得这么复杂?看看“字母注释”,我们有以下内容:
-
在行 A 上,我们为要插入的值创建一个名为
p
的std::unique_ptr<Node>
对象。我们知道新创建的节点不会是列表中的第一个节点,因为函数是insert_after()
,需要一个指向现有“之前”节点的迭代器(在这里命名为pos
),所以这是有意义的。同样地,我们也知道pos
不是end()
,根据定义,它不会指向容器中的有效节点。 -
在行 B 上,我们做必要的操作,使
p
的后继成为pos
的后继。这需要一些小心,因为pos.cur->next
保证是一个std::unique_ptr<Node>
(显然它不能是head
,因为pos.cur
是“在”pos.cur->next
之前),我们使p
成为一个std::unique_ptr<Node>
。我们正在将pos.cur
的后继节点的责任转移到p->next
,实际上是在p
之后插入pos->next
(尽管方式复杂)。 -
在线 C 上,我们确保
pos.cur
放弃对pos.cur->next
的责任。这是很重要的,因为我们如果不这样做,那么替换那个std::unique_ptr<Node>
将会破坏其指针指向的对象。行 B 确保了pos.cur->next
和p->next
将指向同一个对象,如果我们就此停止(两个对象负责同一个指针指向是一个我们不希望出现的语义问题)。 -
一旦
pos.cur->next
被断开连接,我们就转到行 D,在那里让它指向p
下的原始指针。这又会再次导致对Node
的共享责任,所以我们继续到行 E,在那里将p
从其基础指针断开连接。 -
行 F 通过返回一个指向原始(因此非所有者)指针的预期迭代器来结束这个函数的工作。
那是…复杂的。这个函数之所以复杂的主要原因是我们在这个函数中的大部分努力都是所有权的转移。毕竟,std::unique_ptr<T>
对象代表了对 T*
的唯一所有权,在一个链表中,每个插入或删除操作都需要移动指针,从而在节点之间转移所有权。我们通过在类型的大多数操作中增加复杂性来简化偶尔的情况(节点的删除)。那是…悲哀的。
关于意义和责任语义
智能指针都是关于在类型系统中编码意义和责任。简化用户代码很重要,但这不是这些类型的主要目的。在 ForwardList<T>
对象中,T
对象的真正所有者是 ForwardList<T>
对象,而 ForwardList<T>::Node<U>
对象(从 ForwardList<T>
对象的角度来看)基本上是一个存储设施。尝试改变这一点可以使其工作,但随之而来的复杂性表明有些可疑。
当编写一个类,尤其是容器类时,我们必须清楚地了解每种类型的预期角色。我们知道迭代器本质上是非拥有的(然而,在某些用例中,我们可以设想shared_ptr<T>
对象与指针共同拥有)。至于容器及其底层表示,重要的是每种类型的责任需要明确,如果我们的设计要可管理。
好吧,所以让节点负责其后续节点并没有奏效。仅仅让ForwardList<T>
对象的head
成员负责列表中的其他节点,会让我们过得更好吗?
尝试:让头指针负责其他节点
如前节所述,让每个节点负责其后续节点在语义上是不正确的。这会导致复杂、繁琐且容易出错的代码,而通过这种转换简化的实现方面通常被其他地方增加的复杂性所抵消。
也许仅仅让head
节点成为一个std::unique_ptr<Node>
对象,并使用一个自定义删除器负责删除整个列表会更有益?嗯,我们可以肯定地尝试这种方法。
作为摘要,我们现在会得到以下内容:
template <class T>
class ForwardList {
// ...
struct Node {
value_type value;
Node* next = nullptr;
Node(const_reference value) : value{ value } {
}
Node(value_type&& value) : value{ std::move(value) }{
}
};
struct deleter { // <--
void operator()(Node* p) const {
while (p) {
Node* q = p->next;
delete p;
p = q;
}
}
};
std::unique_ptr<Node, deleter> head;
ForwardList<T> type that, when an object of that type is destroyed, implicitly ensures that the nodes in the list are destructed. The entire list remains built from raw pointers, such that nodes are not responsible for memory management, which is probably an upgrade from the previous attempt.
With this implementation, we would get a defaulted `ForwardList<T>` destructor, which is a good thing. There would be a tiny complexity increase in `clear()` where we need to distinguish the `head` smart pointer from the underlying pointer:
// ...
void clear() noexcept {
for (auto p = head.get(); p; ) { // <--
auto q = p->next;
delete p;
p = q;
}
nelems = 0;
}
// ...
The iterator interface needs to be adapted somewhat since `head` is not a `Node*` anymore, but iterators trade in non-owning resources:
// ...
iterator begin() { return { head.get() }; } // <--
const_iterator begin() const {
return { head.get() }; // <--
}
// ...
The `ForwardList<T>` constructor that takes a pair of iterators and towards which most other constructors converge requires slight modifications:
// ...
template <std::forward_iterator It>
ForwardList(It b, It e) {
if(b == e) return;
head.reset(new Node{ *b }); // <--
auto q = head.get(); // <--
++nelems;
for(++b; b != e; ++b) {
q->next = new Node{ *b };
q = q->next;
++nelems;
}
}
// ...
The exception handling side of this member function is indeed simplified, being made implicit from the fact that, should any constructor of a `T` object throw an exception, the previously created nodes will be destroyed.
As in the previous version, our `push_front()` member functions will require some adjustment as they interact with the `head` data member:
// ...
void push_front(const_reference val) {
auto p = new Node{ val };
p->next = head.get(); // <--
head.release(); // <--
head.reset(p); // <--
++nelems;
}
void push_front(T&& val) {
auto p = new Node{ std::move(val) };
p->next = head.get(); // <--
head.release(); // <--
head.reset(p); // <--
++nelems;
}
// ...
On the upside, no member function that does not interact with the `head` data member requires any modification.
Is this “implicitness” worth it? It probably depends on the way in which you approach writing code. We did gain something of value in implicit exception safety. There is value in separating concerns, and this implementation does free the container from the task of managing memory (for the most part). It is up to you, dear reader, to determine whether the reduced complexity “here” outweighs the added complexity “there.”
Summary
In this chapter, we reexamined containers written in *Chapter 12*, seeking to use implicit memory management tools in such a way as to make our implementations simpler and safer. We did reach an improvement in `Vector<T>` but the results obtained with our node-based `ForwardList<T>` container were… not absent, but arguably less conclusive depending on your perspective.
In the next chapter, we will introduce the idea of allocators, objects that inform containers as to how memory should be obtained or liberated, and examine how they impact the ways in which we write code.
第十四章:使用分配器支持编写泛型容器
自本书开始以来,我们已经走得很远了。最近几章探讨了如何编写内存高效的容器,描述了在显式进行内存管理时(在第十二章)以及通过智能指针隐式进行内存管理时(在第十三章)如何做到这一点。选择内存管理方法不是非此即彼的问题;每种方法都有其自身的用途,并解决根据应用领域而定的实际用例。
然而,我们之前所介绍的所有方法都不符合标准库容器的做法。实际上,标准库容器(以及许多其他可以动态分配内存的标准库类型)都是来自一个区域(参见第十章)或来自堆栈上的固定容量缓冲区的 std::vector
。
分配器在 C++98 中随着标准库容器一起正式成为 C++ 语言的一部分,但它们随着时间的推移而发展和多样化。使用 C++11,编写分配器变得显著简单,而 C++17 通过引入具有 多态内存资源(PMR)分配器和容器的全新内存分配方法。
在本章中,您将执行以下操作:
-
理解和使用传统分配器
-
为特定应用领域编写传统分配器
-
学习如何在容器移动或复制时管理分配器的生命周期
-
克隆分配器的类型
-
理解和使用 PMR 分配器和容器
拥有分配器和它们如何与容器交互的知识,本章将丰富您的内存管理工具箱,并开辟将数据组织与存储获取方式相结合的新途径。理解分配器甚至可能使编写新容器变得不那么必要;有时,与其尝试创建一个全新的容器,不如将合适的数据组织策略与合适的存储管理方法结合起来。
技术要求
您可以在此处找到本书中该章节的代码文件:github.com/PacktPublishing/C-Plus-Plus-Memory-Management/tree/main/chapter14
.
关于本章示例的一些建议
与第十三章的情况一样,本章将展示不完整的示例,以避免与之前找到的摘录重复,尤其是那些在第十二章中的。分配器改变了容器与内存管理设施交互的方式,但它们不需要完全重写容器,因此为给定容器编写的代码在内存管理方式如何的情况下仍然保持稳定。您在 GitHub 仓库中找到的代码当然是完整的。
还要注意,本章在容器的背景下讨论分配器,但这一想法可以扩展到许多需要动态分配内存的类型。有时这样做很困难;例如,C++17 中移除了std::function
中的分配器支持,因为没有任何已知的标准库实现能够使其工作。尽管如此,分配器可以被视为一个通用概念,而不仅仅是局限于容器,您可以在其他上下文中设想使用分配器。
为什么需要分配器?
分配器往往会让人感到害怕,包括一些专家在内,但您不会感到害怕,因为您已经掌握了大量的内存管理知识和技能(鉴于您正在阅读这本书,您可能对这一主题有更多的好奇心)。了解这一点后,我们首先需要解决的问题,甚至在表达分配器是什么之前,就是“为什么分配器存在?”。我们为什么要关心内存管理代码中额外的复杂性层次?
嗯,这是 C++,C++的一切都是关于给用户提供控制,所以我们的解释从这里开始。为了做一个类比,想想迭代器:为什么它们有用,以及它们如何使程序员的编程生活变得更好。它们将遍历序列元素的方式与元素在序列中的组织方式解耦,这样您就可以编写代码来计算诸如std::list<int>
或std::vector<short>
中值的总和,而无需知道在第一种情况下,您正在通过指针相互链接的节点进行导航,在第二种情况下,您正在遍历存储在连续内存中的对象。
迭代器的美妙之处在于迭代和数据组织之间的解耦。同样,分配器将数据组织与底层存储的获取或释放方式解耦。这使得我们可以独立于内存管理的属性来推理容器的属性,从而使容器在更多情况下变得有用。
非常,非常薄的一层…
对于容器来说,分配器(至少是我们即将讨论的“传统”模型中的那些)代表了对硬件的薄薄(非常薄)一层抽象。对于容器来说,分配器表达了诸如“地址是什么?”,“如何将对象放在某个地方?”,“如何销毁某个位置的对象?”等问题。从某种意义上说,对于容器来说,分配器本质上就是硬件。
传统分配器
如前所述,分配器已经成为了 C++几十年的支柱,但它们以几种不同的形式和形状存在。在本章中,我们将采用一种类似时间顺序的方法,从较早(更复杂)的分配器类型开始,逐步过渡到更简单(更灵活)的类型。
要理解这一章,一个关键的想法是记住,容器类型如 std::vector<T>
并不存在。真正存在的是 std::vector<T,A>
类型,其中默认情况下 A
是 std::allocator<T>
,它通过 ::operator new()
分配内存,通过 ::operator delete()
释放内存。我们所说的 传统分配器 指的是容器类型的一部分的分配器类型(这并不是今天编写分配器的唯一可能方法,正如我们在本章后面讨论 PMR 分配器时将会看到的)。
我们将首先检查在 C++11 之前编写分配器所需的内容,以及容器如 std::vector<T,A>
如何使用 A
类型的对象来抽象其内存分配任务。分配器表达方式的改进将在本章后面的部分中讨论。
在 C++11 之前
在 C++11 之前编写的传统分配器必须实现一系列成员,这使得编写分配器的任务对许多人来说似乎很艰巨。考虑一下那些日子人们需要编写的内容,并请注意,以下内容并非全部在撰写本文时仍然有效,因为分配器的 API 随时间而演变。
跟踪不断演变的 API 的难度
对分配器的要求随着 C++03 以来每个版本的 C++ 而变化,如今,编写编译为 C++11 的示例并不总是容易(或相关)。因此,我们将详细编写的示例将使用 C++11 分配器,以展示这实际上意味着什么,但将使用 C++17 标准编译,以便代码更易于阅读(和编写)。
我们将检查这样一个分配器,small_allocator<T>
,并以类似于 std::allocator<T>
的方式实现它,以突出在 C++11 时代编写分配器的意义,然后将其与标准更近版本的表达式进行比较。在我们的实现中,我们将使用 C++17 特性,因为我们不希望在已经微妙的话题中引入不必要的复杂性。
在介绍 small_allocator<T>
之后,我们将展示如何将来自 第十二章 和 第十三章 的 Vector<T>
进行增强,并成为 Vector<T,A>
,以及 A
可以是 std::allocator<T>
、small_allocator<T>
或任何其他符合规范的分配器类型。
类型别名
T
类型的分配器必须公开 value_type
、size_type
、difference_type
(从两个 pointer
对象相减得到的结果类型)、pointer
、const_pointer
、reference
和 const_reference
的类型别名。可以这样理解:对于一个容器来说,分配器代表底层内存,从而定义了最佳描述这些底层概念的类型。容器可以将自己的别名映射到分配器的别名上,以保持一致性。
在我们的 small_allocator<T>
类型中,这会转化为以下内容:
template <class T>
struct small_allocator {
using value_type = T;
using pointer = T*;
using const_pointer = const T*;
using reference = T&;
using const_reference = const T&;
using size_type = std::size_t;
using difference_type = std::ptrdiff_t;
// ...
实际上,对于 T
类型的分配器,我们可以在所有但最奇怪的情况下期望这些类型别名与 small_allocator<T>
中显示的类型别名相对应:只要 value_type
被定义,我们几乎总能推断出其他类型。
成员函数
T
类型的分配器必须公开一个成员函数 max_size()
,该函数应该返回分配器实际可以分配的最大块的大小。
实际上,这通常证明是不可实现的,因为对于某些操作系统,分配总是成功的(但如果程序分配过多,分配的内存的使用可能会失败),因此该函数通常在给定平台上以尽力而为的方式实现。一个可能的实现如下所示:
// ...
constexpr size_type max_size() const {
return std::numeric_limits<size_type>::max(); // bah
}
// ...
T
类型的分配器还必须公开两个函数重载,这些函数使用了作者的学生们“喜欢”的词汇(欣赏这种讽刺!)。考虑 pointer address(reference r)
以及 const
对象的等效函数,即 const_pointer address(const_reference r)
。这里的意图是抽象出获取对象地址的方式。
实现这些函数为 return &r;
可能很有诱惑力,但在实践中,这是危险的,因为用户被允许为他们自己的类型重载一元 operator&()
,这意味着这种实现会调用任意代码,这确实是一个令人恐惧的前景……除非你真的、真的有很好的理由这样做,否则请考虑解决你问题的其他替代方法!
一种更好的实现技术是通过 return std::addressof(r);
来表达这些函数,其中 std::addressof()
是来自 <memory>
的一个“神奇”标准库函数(即 constexpr
),它返回对象的地址,而不通过可重载的设施:
// ...
constexpr pointer address(reference r) const {
return std::addressof(r);
}
constexpr
const_pointer address(const_reference r) const {
return std::addressof(r);
}
// ...
显然,分配器需要公开成员函数来执行实际的内存分配。这些函数的签名是 allocate(size_type n)
和 deallocate(pointer p, size_type n)
。这两个函数的简单实现可能如下所示:
// ...
pointer allocate(size_type n) {
auto p = static_cast<pointer>(
malloc(n * sizeof(value_type))
);
if (!p) throw std::bad_alloc{};
return p;
}
void deallocate(pointer p, size_type) {
free(p);
}
// ...
allocate()
成员函数过去接受一个名为 hint
的 void*
类型的第二个参数,默认初始化为 nullptr
。这个参数的目的是通知分配器一个可能用于提供存储的位置,如果容器知道这样的位置。这个特性在实践中的使用似乎很少(如果有的话),并在 C++17 中被弃用,然后在 C++20 中被移除。
这两个函数是分配器存在本质的原因:allocate()
返回足够容纳 n
个连续 value_type
元素的内存块,在失败时抛出 bad_alloc
,而 deallocate()
释放足够容纳 n
个连续 value_type
元素的内存块。当编写一个分配器时,通常寻求为这个特定问题提供答案。
字节或对象
有趣的是,与接受字节数作为参数的 operator new()
相反,allocate()
和 deallocate()
都接受对象的数量作为参数。这是因为传统的分配器是类型感知的(毕竟它们是某种类型 T
的分配器),而 operator new()
和其相关函数(主要)是无类型感知的。你会在本章后面注意到,PMR 分配器(人们可能会称之为“退一步”)使用无类型感知的内存资源,如 malloc()
或 operator new()
。
allocate()
和 deallocate()
都故意向客户端代码撒谎:它们交易的是原始内存,既不创建也不销毁类型 T
的对象,但 allocate()
返回一个 pointer
(本质上是一个 T*
),而 deallocate()
接受一个 pointer
作为参数,尽管假设所有 T
对象在此之前都已销毁。
这些函数欺骗类型系统在某种程度上是好事,因为它减轻了容器执行此操作的负担。当然,容器必须了解这些函数的作用,并且不应该假设 allocate()
返回或传递给 deallocate()
的内存中存在对象。
最后,分配器必须公开成员函数以将原始内存转换为对象,反之亦然。construct(pointer p,const_reference r)
和 destroy(pointer p)
函数分别用于在位置 p
(假设之前已分配)处构造 r
的副本,并销毁位置 p
处的对象(不释放底层存储):
// ...
void construct(pointer p, const_reference r) {
new (static_cast<void*>(p)) value_type(r);
}
void destroy(const_pointer p) {
if(p) p->~value_type();
}
// ...
template <class U>
struct rebind {
using other = small_allocator<U>;
};
};
可以预期大多数实现将基本上做前面代码所做的事情。有其他选择,但在实践中很少遇到。
再次强调,这些函数欺骗了类型系统:construct()
接受一个 pointer
(实践中是一个 T*
)作为参数,但函数被调用时,该指针指向的是原始内存,而不是类型 T
的对象。
那重绑定(rebind)呢?
你会注意到我们没有讨论 rebind
公共模板类型,但这仅仅是因为当面对这个类型旨在解决的问题时,这个类型背后的想法更容易理解。我们将在本章后面通过我们的 ForwardList<T,A>
类讨论分配器感知的基于节点的容器时遇到这种情况。
超过这一点,对分配器的需求是定义不同类型的两个分配器对象是否相等。一个可能的实现如下:
// ...
template <class T, class U>
constexpr bool operator==(const small_allocator<T>&,
const small_allocator<U>&) {
return true;
}
template <class T, class U>
constexpr bool operator!=(const small_allocator<T>&,
const small_allocator<U>&) {
return false;
}
换句话说,两个针对不同类型的small_allocator
特化描述了相同的策略,因此被认为是相等的。“但是等等!”你说,“在这个计算中你是如何考虑分配器的状态的?”但是这里有一个启示:C++11 之前的分配器基本上被认为是无状态的。
好吧,它们并不是,但如果一个分配器与一个容器对象相关联,并且该对象被复制,那么会发生什么并不清楚。你看,如果一个分配器有状态,我们必须知道在分配器被复制时如何处理这个状态。这个状态是被复制了吗?是被共享了吗?在 C++11 之前,我们不知道在这种情况下应该怎么做,所以除非容器被用于不会复制的上下文中,比如函数局部向量以及与使用栈空间作为存储的分配器相关联的情况,否则大多数人完全避免使用有状态的分配器。
但有状态的分配器又如何呢?
正如暗示的那样,当时有状态的分配器是可能的(它们存在,并且在实际中得到了应用)。人们预期如何定义有状态分配器的分配器相等性(以及分配器的一般相等性)呢?一般想法是,如果从其中一个分配器分配的内存可以从另一个分配器释放,那么两个分配器应该相等。对于将分配任务委托给如std::malloc()
或::operator new()
等自由函数的分配器,相等性是显而易见的true
,但有状态的分配器要求我们思考如何定义这种关系。
在我们查看如何编写分配器感知容器之前,我们将退一步看看如何将第十二章和第十三章中使用的某些未初始化内存算法适应以使用分配器的服务。这将减少在后续过程中所需的重构工作量。
一些分配器感知支持算法
由于我们使用分配器来弥合原始存储和对象之间的差距,因此我们无法在我们的分配器感知实现中使用在第十二章和第十三章中看到的原始内存算法。
我们有选择在每个容器调用点详细编写这些算法的版本,但这会很繁琐(并且容易出错)。相反,我们将编写这些低级内存管理算法的简化版本,并使这些简化版本使用作为参数传递的分配器。通过这样做,我们将减少容器分配器感知对实现的影响。
这三个算法中的前三个将是初始化值范围的分配器感知版本的算法,以及一个销毁此类范围的算法。为了最小化对现有实现的影响,我们将基本上使用与它们的非分配器感知对应版本相同的签名,但添加一个引用分配器的参数。对于用某个值填充原始内存块块的算法,我们有以下内容:
template <class A, class IIt, class T>
void uninitialized_fill_with_allocator(
A& alloc, IIt bd, IIt ed, T init
) {
// bd: beginning of destination¸
// ed: end of destination
auto p = bd;
try {
for (; p != ed; ++p)
alloc.construct(p, init);
} catch (...) {
for (auto q = bd; q != p; ++q)
alloc.destroy(q);
throw;
}
}
然后,对于将值序列复制到原始内存块块的算法,我们有以下内容:
template <class A, class IIt, class OIt>
void uninitialized_copy_with_allocator(
A& alloc, IIt bs, IIt es, OIt bd
) {
// bs: beginning of source
// es: end of source
// bd: beginning of destination¸
auto p = bd;
try {
for (auto q = bs; q != es; ++q) {
alloc.construct(p, *q);
++p;
}
} catch (...) {
for (auto q = bd; q != p; ++q)
alloc.destroy(q);
throw;
}
}
对于将值序列移动到原始内存块块的算法,我们有以下内容:
template <class A, class IIt, class OIt>
void uninitialized_move_with_allocator(
A& alloc, IIt bs, IIt es, OIt bd
) {
// bs: beginning of source
// es: end of source
// bd: beginning of destination¸
auto p = bd;
try {
for (auto q = bs; q != es; ++q) {
alloc.construct(p, std::move(*q));
++p;
}
} catch (...) {
for (auto q = bd; q != p; ++q)
alloc.destroy(q);
throw;
}
}
最后,对于将对象序列转换为原始内存块块的算法,我们有以下内容:
template <class A, class It>
void destroy_with_allocator(A &alloc, It b, It e) {
for (; b != e; ++b)
alloc.destroy(b);
}
注意,在每种情况下,如果发生异常,则对象将按照构造的相反顺序销毁,这将使实现更加符合规范。请随意实现这个小的调整;这并不困难,但会在我们的示例中引入一些噪音。
我们将要重写的另一个标准设施是cmp_less()
,它允许在不被 C 语言的整数提升规则捕获的情况下比较有符号值和无符号值。它与内存直接相关,但我们需要它在我们的Vector<T>
实现中,并且这是一个 C++20 特性,这使得在为 C++17 编译时不可用:
template<class T, class U>
constexpr bool cmp_less(T a, U b) noexcept {
if constexpr (std::is_signed_v<T> ==
std::is_signed_v<U>)
return a < b;
else if constexpr (std::is_signed_v<T>)
return a < 0 || std::make_unsigned_t<T>(a) < b;
else
return b >= 0 && a < std::make_unsigned_t<U>(b);
}
std::is_signed<T>
特性以及std::make_unsigned<T>()
函数都可以在头文件<type_traits>
中找到。
条件编译和特征测试宏
作为旁注,如果你发现自己必须维护代码,其中可能或可能没有std::cmp_less()
这样的功能,例如有时为 C++20 编译有时为 C++17 编译的源文件,考虑通过测试相关的特征测试宏条件包含你的“自制解决方案”版本。
对于这个特定的情况,可以通过使用#ifndef __cpp_lib_integer_comparison_functions
将我们个人版本的cmp_less()
函数定义包裹起来,以确保只有在没有标准库实现提供版本的情况下才提供它。
现在,让我们看看这些分配器和我们的支持算法如何被容器使用,首先是一个使用连续存储的容器(我们的Vector<T,A>
类),然后是一个基于节点的容器(我们的ForwardList<T,A>
类)。
分配器感知的Vector<T,A>
类
现在我们准备看看在连续内存使用的容器(更具体地说,我们的Vector<T>
类)中引入分配器意识如何影响该容器的实现。请注意,我们将使用第第十二章中明确的内存管理方法作为基准,因为我们想探索分配器意识的影响,这将帮助我们更明显地做出实现更改。如果你愿意,可以自由地根据隐式内存管理方法调整本章中的代码。
从模板的签名本身开始,我们现在有一个双类型模板,其中T
是元素类型,A
是分配器类型,但为A
提供了一个合理的默认类型,这样普通用户就不需要担心这样的技术细节:
template <class T, class A = std::allocator<T>>
class Vector : A { // note: private inheritance
public:
using value_type = typename A::value_type;
using size_type = typename A::size_type;
using pointer = typename A::pointer;
using const_pointer = typename A::const_pointer;
using reference = typename A::reference;
using const_reference = typename A::const_reference;
private:
// deliberately self-exposing selected members
// of the private base class as our own
using A::allocate;
using A::deallocate;
using A::construct;
using A::destroy;
// ...
注意以下技术:
-
由于我们期望
A
是无状态的,我们使用了私有继承,并使A
成为Vector<T,A>
的基类,从而实现了空基优化。或者,我们也可以在每个Vector<T,A>
对象内部使用类型为A
的数据成员(可能带来轻微的大小惩罚)。 -
我们从其分配器的类型别名推导出容器的类型别名。在实践中,这可能与我们在前几章中使用的别名没有太大变化,但
A
可能在进行一些“花哨的技巧”(永远不能太过小心)。 -
在我们类的私有部分,我们将基类的一些选定成员公开为我们的成员。这将使代码在以后变得更加简洁,例如,我们可以编写
allocate(n)
而不是this->A::allocate(n)
。
我们类中非分配成员没有变化,这是预料之中的。数据成员保持不变,基本访问器如size()
、empty()
、begin()
、end()
、front()
、operator[]
等也是如此。默认构造函数也没有变化,因为它不分配内存,因此不需要与它的分配器交互。
需要一个新的构造函数,它接受一个分配器作为参数。这个构造函数在状态化分配器的情况下特别有用:
// ...
Vector(A &alloc) : A{ alloc } {
}
// ...
当然,当遇到需要分配内存的构造函数时,情况变得更加有趣。以一个接受元素数量和初始值的构造函数为例:
// ...
Vector(size_type n, const_reference init)
: A{},elems{ allocate(n) },
nelems{ n }, cap{ n } {
try {
uninitialized_fill_with_allocator(
*static_cast<A*>(this), begin(), end(), init
);
} catch (...) {
deallocate(elems, capacity());
throw;
}
}
// ...
这里有很多要说的:
-
将作为我们容器底层存储的内存块是通过调用基类的
allocate()
成员函数分配的。记住,尽管这产生了一个指针
(一个T*
),但这是一种谎言,新分配的块中没有T
对象。 -
我们通过我们自制的分配器感知版本的
std::uninitialized_fill()
(见_with_allocator
后缀)来填充那个未初始化的内存块,用T
对象填充。注意我们如何将分配器作为参数传递给算法:Vector<T,A>
和A
之间的继承关系是private
,但派生类知道这一点,并且可以通过static_cast
使用这些信息。 -
如果在初始化该内存块的过程中使用的任何一个构造函数抛出异常,算法将像往常一样销毁它所创建的对象,然后我们拦截该异常,在重新抛出该异常之前释放存储,以实现异常中立。
在其他分配构造函数中,也使用了类似的操作,但用于初始化分配存储的算法不同。移动构造函数和swap()
成员函数不分配内存,因此保持不变,赋值运算符也是如此:它们是由其他成员函数构建的,并且不需要自己分配或释放内存。
如您可能已经猜到的,我们容器的析构函数将使用分配器来销毁对象并释放底层存储:
// ...
~Vector() {
destroy_with_allocator(
*static_cast<A*>(this), begin(), end()
);
deallocate(elems, capacity());
}
// ...
push_back()
和emplace_back()
成员函数本身不分配内存,而是委托给我们的私有grow()
成员函数,该函数反过来委托给reserve()
进行分配,但它们确实需要在容器的末尾construct()
一个对象:
// ...
void push_back(const_reference val) {
if (full()) grow();
construct(end(), val);
++nelems;
}
void push_back(T&& val) {
if (full()) grow();
construct(end(), std::move(val));
++nelems;
}
template <class ... Args>
reference emplace_back(Args &&...args) {
if (full()) grow();
construct(end(), std::forward<Args>(args)...);
++nelems;
return back();
}
// ...
我们类中内存分配的主要工具可能是reserve()
和resize()
。在这两种情况下,算法保持不变,但底层内存管理任务被委托给分配器。对于reserve()
,这导致以下情况:
// ...
void reserve(size_type new_cap) {
if (new_cap <= capacity()) return;
auto p = allocate(new_cap);
if constexpr (std::is_nothrow_move_assignable_v<T>) {
uninitialized_move_with_allocator(
*static_cast<A*>(this), begin(), end(), p
);
} else {
auto src_p = begin();
auto b = p, e = p + size();
try {
uninitialized_copy_with_allocator(
*static_cast<A*>(this), begin(), end(), p
);
} catch (...) {
deallocate(p, new_cap);
throw;
}
}
deallocate(elems, capacity());
elems = p;
cap = new_cap;
}
// ...
而对于resize()
,我们现在有以下情况:
// ...
void resize(size_type new_cap) {
if (new_cap <= capacity()) return;
auto p = allocate(new_cap);
if constexpr (std::is_nothrow_move_assignable_v<T>) {
uninitialized_move_with_allocator(
*static_cast<A*>(this), begin(), end(), p
);
} else {
uninitialized_copy_with_allocator(
*static_cast<A*>(this), begin(), end(), p
);
}
try {
uninitialized_fill_with_allocator(
*static_cast<A*>(this),
p + size(), p + new_cap, value_type{}
);
destroy_with_allocator(
*static_cast<A*>(this), begin(), end()
);
deallocate(elems, capacity());
elems = p;
nelems = cap = new_cap;
} catch(...) {
destroy_with_allocator(
*static_cast<A*>(this), p, p + size()
);
deallocate(p, new_cap);
throw;
}
}
// ...
在Vector<T>
类的先前实现中,我们为insert()
和erase()
各自实现了一个版本,因为实现所有这些函数会使这本书变得过于庞大。由于这两个函数都涉及已初始化和未初始化的内存,它们需要调整以使用分配器的服务而不是进行自己的内存管理。
在insert()
的情况下,需要调整函数的关键方面是那些将对象复制或移动到原始内存中的方面:
// ...
template <class It>
iterator insert(const_iterator pos, It first, It last) {
iterator pos_ = const_cast<iterator>(pos);
const auto remaining = capacity() - size();
const auto n = std::distance(first, last);
// if (std::cmp_less(remaining, n)) { // needs C++20
if(cmp_less(remaining, n)) {
auto index = std::distance(begin(), pos_);
reserve(capacity() + n - remaining);
pos_ = std::next(begin(), index);
}
const auto nb_to_uninit_displace =
std::min<std::ptrdiff_t>(n, end() - pos_);
auto where_to_uninit_displace =
end() + n - nb_to_uninit_displace;
if constexpr (
std::is_nothrow_move_constructible_v<T>
)
uninitialized_move_with_allocator(
*static_cast<A*>(this),
end() - nb_to_uninit_displace, end(),
where_to_uninit_displace
);
else
uninitialized_copy_with_allocator(
*static_cast<A*>(this),
end() - nb_to_uninit_displace, end(),
where_to_uninit_displace
);
// note : might be zero
const auto nb_to_uninit_insert =
std::max<std::ptrdiff_t>(
0, n - nb_to_uninit_displace
);
auto where_to_uninit_insert = end();
uninitialized_copy_with_allocator(
*static_cast<A*>(this),
last - nb_to_uninit_insert, last,
where_to_uninit_insert
);
// note : might be zero
const auto nb_to_backward_displace =
std::max<std::ptrdiff_t>(
0, end() - pos_ - nb_to_uninit_displace
);
auto where_to_backward_displace = end();
if constexpr (std::is_nothrow_move_assignable_v<T>)
std::move_backward(
pos_, pos_ + nb_to_backward_displace,
where_to_backward_displace
);
else
std::copy_backward(
pos_, pos_ + nb_to_backward_displace,
where_to_backward_displace
);
std::copy(
first, first + n - nb_to_uninit_insert, pos_
);
nelems += n;
return pos_;
}
// ...
在erase()
的情况下,我们执行的操作是将被删除对象之后的所有对象“向左”移动一个位置;在此复制操作完成后,序列末尾的对象必须被销毁,为此,我们需要使用分配器的服务。以下是一个示例:
// ...
iterator erase(const_iterator pos) {
iterator pos_ = const_cast<iterator>(pos);
if (pos_ == end()) return pos_;
std::copy(std::next(pos_), end(), pos_);
destroy(std::prev(end()));
--nelems;
return pos_;
}
};
如您此时可能已经收集到的,我们可以以多种方式优化或简化这些函数,例如以下方式:
-
reserve()
和resize()
之间存在共同的核心功能,因此我们可以说resize()
在很大程度上类似于reserve()
后跟一个未初始化的填充,并以此表达。 -
在
erase()
的情况下,在编译时,我们可以测试std::is_nothrow_move_assignable_v<T>
特性的值,如果该条件成立,则将std::copy()
的调用替换为std::move()
的调用。 -
我们可以使
insert()
和erase()
比现在更异常安全,尽管这会使这本书的代码稍微长一些。
到目前为止,我们有一个分配器感知的容器,它管理连续内存。现在将很有趣地看到分配器感知对基于节点的容器的影响,我们将通过 ForwardList<T>
类的分配器感知版本来解决这个问题。
一个分配器感知的 ForwardList<T,A>
类
当编写分配器感知的基于节点的容器时,会发生一件有趣的事情。请注意我们的 ForwardList<T,A>
类的开始部分:
template <class T, class A = std::allocator<T>>
class ForwardList {
public:
using value_type = typename A::value_type;
// likewise for the other aliases
private:
struct Node {
value_type value;
Node *next = nullptr;
Node(const_reference value) : value { value } {
}
Node(value_type &&value)
: value { std::move(value) } {
}
};
Node *head {};
size_type nelems {};
// ...
你注意到类型 A
的有趣之处了吗?想想看…
是的,就是这样:A
是错误类型!像 ForwardList<T,A>
这样的基于节点的容器永远不会分配类型 T
的对象:它分配 节点,这些节点(很可能)包含 T
对象和其他东西,例如,在这种情况下,指向序列中下一个 Node
的指针。
了解这一点后,如果我们提供了一些类似于我们在 第十章 中用于 Orc
对象的竞技场分配策略的 A
分配器,使分配器了解 T
(因此,了解 sizeof(T)
),这将导致管理错误大小对象的竞技场。这可不是什么好事!
我们面临一个有趣的困境:用户代码提供给我们一个分配器,因为它希望我们的容器能够充分利用 分配策略。这种分配策略作为容器模板参数出现,这就是为什么它与元素的类型相关联(在我们容器类的定义这一点上,我们不知道节点将是什么)。只有在我们定义了容器中的节点是什么之后,我们才能真正地说出需要分配什么,但那时 A
已经存在,并且已经与 T
相关联,而不是我们真正需要的类型,即 ForwardList<T,A>::Node
。
注意,我们已经实例化了类型 A
,但尚未构造该类型的任何对象。幸运的是,那样会非常浪费(我们永远不会使用它!)。我们真正需要的是一个与 A
类似的类型,但能够分配我们的 Node
类型对象,而不是类型 T
的对象。我们需要一种方法来 克隆 A
描述的分配策略 并将其应用于另一个类型。
这正是 rebind
的用途。记住,我们在之前编写 small_allocator<T>
时提到了这个模板类型,但说我们会等到可以用到它的时候再回来?现在我们就在这里,亲爱的读者。作为提醒,在分配器的上下文中,rebind
表现如下:
template <class T>
class small_allocator { // for example
// ...
template <class U>
struct rebind {
using other = small_allocator<U>;
};
// ...
};
你可以将 rebind
视为某种奇怪的诗意代码:这是分配器说“如果你想要与我相同类型但应用于某些 U
类型而不是 T
,那么这个类型会是什么样子”的一种方式。
返回到我们的 ForwardList<T,A>
类,既然我们已经知道了 rebind
的用途,我们就可以创建我们自己的内部分配器类型,Alloc
。这将类似于分配器类型 A
,但应用于 Node
而不是 T
,并创建一个该类型的对象(在我们的实现中偶然命名为 alloc
),我们将使用它来执行容器中的内存管理任务:
// ...
using Alloc = typename A::rebind<Node>::other;
Alloc alloc;
// ...
这是个不错的技巧,不是吗?记住,我们克隆的是 策略,即类型,而不是一个实际的对象,所以一些假设的 A
对象可能拥有的任何状态不一定是我们新的 Alloc
类型的一部分(至少不是不进行一些非平凡的杂技表演的情况下)。这又是另一个提醒,按照传统的分配器设计,复制和移动分配器状态是一个复杂的问题。
就像从 Vector<T>
转换到 Vector<T,A>
一样,我们的大部分 List<T>
实现涉及不到内存分配,因此不需要随着 List<T,A>
而改变。这包括 size()
、empty()
、begin()
、end()
、swap()
、front()
和 operator==()
成员函数,以及其他许多 List<T,A>::Iterator<U>
类定义。由于我们的 ForwardList<T,A>
实现有时需要访问迭代器的私有数据成员 cur
,我们给它 friend
权限:
// ...
template <class U> class Iterator {
// ...
private:
Node *cur {};
friend class ForwardList<T,A>;
// ...
};
// ...
当然,ForwardList<T,A>
有一些使用内存分配机制的成员函数。其中之一是 clear()
,其作用是销毁容器中的节点。Node
对象的销毁和重新分配必须通过分配器执行,用一对函数调用替换对 operator delete()
的调用:
// ...
void clear() noexcept {
for(auto p = head; p; ) {
auto q = p->next;
alloc.destroy(p);
alloc.deallocate(p, 1);
p = q;
}
nelems = 0;
}
// ...
在 ForwardList<T>
中,我们将所有分配构造函数汇聚到一个接受一对迭代器(类型 It
)作为参数的单个序列构造函数中。这将 ForwardList<T,A>
中构造函数所需的变化局部化到那个单一函数中,这简化了我们的任务。
在 ForwardList<T>
中,我们通过 std::forward_iterator
概念约束了模板参数 It
,但概念是 C++20 的特性,而我们在这个实现中编译的是 C++17,所以(遗憾的是)我们将暂时放弃这个约束。
必须分步骤执行分配和构造使我们的实现稍微复杂一些,但我认为你们尊贵的读者不会觉得这是不可逾越的:
// ...
template <class It> // <std::forward_iterator It>
ForwardList(It b, It e) {
if(b == e) return;
try {
head = alloc.allocate(1);
alloc.construct(head, *b);
auto q = head;
++nelems;
for(++b; b != e; ++b) {
auto ptr = alloc.allocate(1);
alloc.construct(ptr, *b);
q->next = ptr;
q = q->next;
++nelems;
}
} catch (...) {
clear();
throw;
}
}
// ...
我们还为 ForwardList<T>
编写了插入成员函数,因此这些函数也需要适应使用 ForwardList<T,A>
中的分配器。我们有两个 push_front()
的重载版本:
// ...
void push_front(const_reference val) {
auto p = alloc.allocate(1);
alloc.construct(p, val);
p->next = head;
head = p;
++nelems;
}
void push_front(T&& val) {
auto p = alloc.allocate(1);
alloc.construct(p, std::move(val));
p->next = head;
head = p;
++nelems;
}
// ...
我们还为 insert_after()
提供了两个重载版本,一个用于插入单个值,另一个用于插入半开区间内的元素。在后一种情况下,由于我们正在为 C++17 编译,我们需要再次放宽对类型 It
的 std::forward_iterator
约束:
// ...
iterator
insert_after(iterator pos, const_reference value) {
auto p = alloc.allocate(1);
alloc.construct(p, value);
p->next = pos.cur->next;
pos.cur->next = p;
++nelems;
return { p };
}
template <class It> // <std::input_iterator It>
iterator insert_after(iterator pos, It b, It e) {
for(; b != e; ++b)
pos = insert_after(pos, *b);
return pos;
}
// ...
我们的 erase_after()
成员函数也进行了类似的调整:
// ...
iterator erase_after(iterator pos) {
if (pos == end() || std::next(pos) == end())
return end();
auto p = pos.cur->next->next;
alloc.destroy(pos.cur->next);
alloc.deallocate(pos.cur->next, 1);
--nelems;
pos.cur->next = p;
return { p->next };
}
};
这就完成了我们将 ForwardList<T>
转换为分配器感知的 ForwardList<T,A>
类型的转换。我希望,亲爱的读者,这个过程并没有像一些人担心的那样困难:鉴于我们对本书中提出的原理和基本技术的理解,在这个阶段,将分配器感知集成到容器中应该对大多数人来说是有意义的。
现在我们已经看到了如何编写“传统”迭代器,以及如何使容器分配器感知的示例,你可能想知道使用分配器的优点。我们知道分配器让我们能够控制容器管理内存的方式,但我们可以从这种控制中获得什么?
示例用法 – 顺序缓冲区分配器
分配器使用的经典例子是,不是从自由存储中分配内存,而是管理预分配的内存块。这些内存不必来自线程的执行栈,但在实践中通常是这样做的,所以我们的示例代码也将这样做。
在阅读以下示例之前,你需要知道的是:
-
这种类型的分配器是专门为特定用户设计的工具。我们期望用户知道自己在做什么。
-
在我们的例子中,由分配器管理的预分配缓冲区必须适当地对齐以存储其中的对象。如果你想要将这个例子修改为处理任何自然对齐对象的内存分配,需要做额外的工作(你希望分配器提供
std::max_align_t
边界对齐的地址,而我们的示例分配器并不这样做)。 -
如果客户端代码尝试“过度分配”,请求比管理缓冲区能提供的更多内存,那么需要特别注意。在这个例子中,我们将像往常一样抛出
std::bad_alloc
,但存在其他替代方案。
当 bad_alloc 不是一个选项时…
对于某些应用,抛出异常或其他方式失败分配不是一种选择。对于这些应用,如果专门的分配器无法满足分配请求,不应该抛出异常,因为抛出异常意味着“我无法满足这个函数的后置条件。”
当顺序缓冲区分配器耗尽内存时,一些应用程序会简单地调用::operator new()
并承受不确定的分配时间“打击”,但会在某个地方留下痕迹(可能是日志),表明发生了这种情况。这意味着程序将泄漏内存,但对于某些应用程序(比如每天都会重新启动的股票市场交易程序),可以预期这些泄漏的数量相对较少,而且有痕迹表明发生了泄漏将让程序员在第二天之前查看问题并(希望)修复它。“两害相权取其轻”,正如有些人所说。
我们的顺序缓冲区分配器将看起来像这样:
#include <cstdint>
template <class T>
struct seq_buf_allocator {
using value_type = T;
// pointer, reference and other aliases are as
// usual, and so is max_size()
private:
char *buf;
pointer cur;
size_type cap;
public:
seq_buf_allocator(char *buf, size_type cap) noexcept
: buf{ buf }, cap{ cap } {
cur = reinterpret_cast<pointer>(buf);
}
// ...
如你所见,这个分配器的状态类似于我们在第十章中为基于大小的区域所做的:我们知道要管理的缓冲区从哪里开始(buf
),有多大(cap
),以及我们在顺序分配过程中的位置(cur
)。
我们将cur
设为一个pointer
类型的对象,以便在之后的allocate()
成员函数中简化计算,但这只是一个便利,并非必需。
在某种意义上,allocate()
成员函数非常简单,因为它执行的是常数时间的计算,从底层存储中返回连续分配的对象,甚至在内存释放后也不需要重新使用该内存。allocate()
中完成的部分工作需要避免过度分配,为此,我们将比较指针,但可能需要比较分配内存块内的指针与块外的指针(这完全取决于我们的参数值)。这可能会导致未定义的行为,这是我们想要避免的,因此我们将指针转换为std::intptr_t
对象,并比较得到的整数值。
如果我的平台上没有提供std::intptr_t
呢?
在 C++中,std::intptr_t
和std::uintptr_t
类型是条件支持的,这意味着可能存在不提供这些类型别名的供应商。如果你发现自己处于这种不太可能但并非不可能的情况,你可以简单地跟踪分配的对象数量,并将其与cap
数据成员进行比较,以达到相同的效果。
我们最终得到以下allocate()
实现,伴随着相应的deallocate()
成员函数,在这种情况下,实际上是一个空操作:
// ...
// rebind, address(), construct() and destroy()
// are all as usual
pointer allocate(size_type n) {
auto
request = reinterpret_cast<
std::intptr_t
>(cur + n),
limit = reinterpret_cast<
std::intptr_t
>(buf + cap);
if(request >= limit)
throw std::bad_alloc{};
auto q = cur;
cur += n;
return q;
}
void deallocate(pointer, size_type) {
}
};
// ...
由于这个分配器是有状态的,我们需要考虑分配器的等价性。在这种情况下,我们将这样做:
template <class T, class U>
constexpr bool operator==(const seq_buf_allocator<T> &a,
const seq_buf_allocator<U> &b){
return a.cur == b.cur; // maybe?
}
template <class T, class U>
constexpr bool operator!=(const seq_buf_allocator<T> &a,
const seq_buf_allocator<U> &b){
return !(a == b);
}
这些等价运算符只在特定时刻有意义,但这个分配器类型实际上并不打算在实践中进行复制;如果你计划使用这样的缓冲区并共享其内部状态,你需要考虑原始副本和副本之间如何共享内部状态并保持一致性——在这种情况下我们不需要这样做。
正如你所见,我们在分配时测试溢出,如果分配请求会导致缓冲区溢出,则抛出std::bad_alloc
,但这只是我们之前在本章中讨论的多种选择之一:
#include <chrono>
#include <utility>
template <class F, class ... Args>
auto test(F f, Args &&... args) {
using namespace std;
using namespace std::chrono;
auto pre = high_resolution_clock::now();
auto res = f(std::forward<Args>(args)...);
auto post = high_resolution_clock::now();
return pair{ res, post - pre };
}
#include <iostream>
#include <vector>
struct Data { int n; };
int main() {
using namespace std::chrono;
enum { N = 500'000 };
{
std::vector<Data> v;
auto [r, dt] = test([](auto & v) {
v.reserve(N);
for(int i = 0; i != N; ++i)
v.push_back({ i + 1 });
return v.back();
}, v);
std::cout << "vector<Data>:\n\t"
<< v.size()
<< " insertions in "
<< duration_cast<microseconds>(dt).count()
<< " us\n";
}
{
alignas(Data) char buf[N * sizeof(Data)];
seq_buf_allocator<Data> alloc{ buf, sizeof buf };
std::vector<Data, seq_buf_allocator<Data>> v(alloc);
auto [r, dt] = test([](auto & v) {
v.reserve(N);
for(int i = 0; i != N; ++i)
v.push_back({ i + 1 });
return v.back();
}, v);
std::cout
<< "vector<Data, seq_buf_allocator<Data>>:\n\t"
<< v.size()
<< " insertions in "
<< duration_cast<microseconds>(dt).count()
<< " us\n";
}
// do the same replacing std::vector with Vector
}
在这一点上,你可能需要注意以下几点:
-
测试代码无论选择哪种分配器都是相同的。
-
当使用有状态的分配器时,我们需要使用一个参数化构造函数,该构造函数接受分配器作为参数。
-
使用
seq_buf_allocator<T>
时,缓冲区的大小和对齐的责任落在用户代码的(隐喻性)肩膀上。再次提醒,这是一个专业工具,因此预期用户知道自己在做什么。 -
如果你在一个符合规范的编译器上运行这个测试,你可能会注意到顺序缓冲区分配器的一些有趣的性能,你可能会注意到
Vector<T,A>
比std::vector<T,A>
表现更好,但Vector<T,A>
并不像它的std::
对应物那样完整和严谨。在实践中,请优先使用标准设施。 -
由于堆栈空间是一种有限的资源(通常总共有一到两个兆字节,所以我们可用的空间少于这个),提供给顺序缓冲区分配器的缓冲区大小有限制。尽管如此,这项技术是有用的,并且在实践中被用于低延迟系统中。
-
如果你使用基于节点的容器列表
ForwardList<T,A>
来应用这种分配器,请记住,每个节点都有一个大小开销,因此请相应地计划缓冲区的大小。
当然,那是一个遵守 C++17 标准的实现。自那时以来,关于分配器的变化有哪些?
传统分配器与当代标准
如前所述,将分配器类型封装在相关容器类型中的传统方法仍然存在,但分配器本身的表达方式随着时间的推移而发生了变化,并且上一节中的分配器,无论是small_allocator<T>
还是seq_buf_allocator<T>
,在 C++20 编译器上按原样编写是无法编译的。在认为这是令人难过的事情之前,要知道我们仍然可以编写这些分配器,但我们必须以更简单的方式编写它们。呼!
简化与基于特质的实现的出现
分配器简化工作的第一步是认识到,在大多数情况下,分配器中编写的代码中很大一部分是我们所说的“样板代码”,即从类到类相同的代码,可以被认为是“噪音”。
为了达到这个目的,C++11 引入了std::allocator_traits<A>
。其想法是,给定某些typename A::value_type
类型,只要提供了allocate()
和deallocate()
的实现,就可以为大多数分配器服务(包括类型别名,如pointer
或size_type
)生成合理且高效的默认实现。
以small_allocator<T>
为例,我们现在可以用以下方式简单地表达整个分配器类型:
template <class T>
struct small_allocator {
using value_type = T;
T* allocate(std::size_t n) {
auto p = static_cast<T*>(
malloc(n * sizeof(value_type))
);
if (!p) throw std::bad_alloc{};
return p;
}
void deallocate(T *p, std::size_t) {
free(p);
}
};
// ... insert the equality operators here
如你所见,这是一个相当简化的表示!这样,一个容器如Vector<T,A>
现在可以在引用某些分配器A
的成员时使用std::allocator_traits<A>
而不是直接使用A
。由于特性是一个非常薄的抽象层,几乎不带来任何运行时成本,它们对某些成员M
所做的是“如果A
公开了成员M
,则使用A::M
;否则,这里有一些合理的默认实现。”当然,在实践中这里不会有分支,因为所有内容都是在编译时确定的。
例如,基于我们之前的small_allocator<T>
类型,考虑到small_allocator<T>::allocate()
返回T*
,那么我们可以确定std::allocator_traits<small_allocator<T>>::pointer
将等同于T*
,并且一个容器如Vector<T,A>
将使其pointer
类型别名对应于std::allocator_traits<A>::pointer
所表示的类型。
举例来说,seq_buf_allocator<T>
现在可以这样表示:
template <class T>
struct seq_buf_allocator {
using value_type = T;
using pointer = T*;
using size_type = std::size_t;
char* buf;
pointer cur;
size_type cap;
seq_buf_allocator(char* buf, size_type cap) noexcept
: buf{ buf }, cap{ cap } {
cur = reinterpret_cast<pointer>(buf);
}
pointer allocate(size_type n) {
auto request =
reinterpret_cast<std::intptr_t>(cur + n),
limit =
reinterpret_cast<std::intptr_t>(buf + cap);
if (request > limit) {
throw std::bad_alloc{};
}
auto q = cur;
cur += n;
return q;
}
void deallocate(pointer, size_type) {
}
};
// ... insert equality operators here
在这种情况下,即使不是必需的,类型seq_buf_allocator<T>
也公开了pointer
和size_type
别名,这意味着对于此类型,std::allocator_traits
将使用分配器提供的版本,而不是尝试合成一个替代方案。正如你所看到的,当代基于特性的分配器方法非常方便。
类型std::allocator_traits<A>
究竟提供了哪些服务?嗯,正如预期的那样,此类型公开了value_type
的常用类型别名(它本身是A::value_type
的别名),pointer
,const_pointer
,size_type
和difference_type
。为了方便,它还公开了别名allocator_type
(相当于A
):void_pointer
和const_void_pointer
(在大多数情况下分别相当于void*
和const void*
)。记住,特性可以被特化,因此,这些看似明显的类型别名有时可能会映射到更复杂的结构。
类型std::allocator_traits<A>
还公开了分配器的传统服务,但以static
成员函数的形式,这些函数将分配器作为第一个参数,包括construct()
,destroy()
,allocate()
,deallocate()
和max_size()
。C++23 向这个集合中添加了另一个static
成员函数:allocate_at_least()
。此函数返回一个由分配的指针和实际分配的块的大小(以对象数量表示)组成的std::allocation_result
对象,尽管在分配完成后,该内存块中没有对象。
rebind
机制通过类型std::rebind_alloc<A>
和std::rebind_traits<T>
来表示。当克隆一个分配策略(对于节点容器来说主要是这样)时,通过这些设施提供的typename A::rebind<T>::other
的等效表示有些冗长:
// ...
typename std::allocator_traits<
A
>::template rebind_alloc<Node>;
// ...
注意到存在template
关键字,这是为了语法歧义。是的,我知道你现在在想什么:这是一个多么复杂的语言!但在实践中,我们很少需要使用这个关键字,只有在那些编译器会混淆地看到后面的<
而不知道它是模板签名的一部分还是小于运算符的情况下。
除了std::allocator_traits<A>
带来的新功能外,还有一些处理分配器生命周期管理的新功能,这是我们多年来学会做的:
-
三个类型别名,告知容器在容器生命周期的关键时刻应该对分配器做什么。这些类型是
propagate_on_container_copy_assignment
(也称为propagate_on_container_move_assignment
,也称为propagate_on_container_swap
,也称为constexpr
函数,返回true
或false
(默认情况下,它们等同于std::false_type
,因为默认情况下,分配器不应该被复制或移动)。例如,如果一个分配器公开类型别名 POCMA,等同于std::true_type
,那么使用该分配器的容器应该将分配器与分配的数据一起移动。请注意,在这三种情况下,此特性等同于std::true_type
意味着分配器的复制、移动或交换操作(分别)是noexcept
的。 -
类型别名
is_always_equal
;这意味着该类型的分配器将不考虑要分配的对象类型进行比较(这减轻了对operator==()
和operator!=()
的需求,它们比较相同模板但不同value_type
别名的两个分配器)。不过,不要在这个问题上花费太多时间;它已经在 C++23 中被弃用,并且很可能会在 C++26 中被移除。 -
select_on_container_copy_construction()
成员函数。这是一个static
成员函数,它接受一个分配器,如果其分配器特性表明这是正确的事情,则复制它,否则返回原始分配器。
好吧,这种分配器生命周期管理是新的,可能令人惊讶。我们该如何处理这些信息?
管理传统分配器生命周期
容器在移动或复制操作中应该对分配器做什么?好吧,这里有一些细节。
在容器的复制构造函数中,最好的做法可能是使用select_on_container_copy_construction()
。毕竟,这是该函数的目的。请勿在其他地方使用该函数:它真正适用于容器的复制构造函数。一旦正在构建的容器获得了其分配器,就可以使用该分配器来执行剩余的内存分配任务。
在容器的移动构造函数中,要做的就是移动构造分配器,并从源容器中窃取资源。
在容器的复制赋值运算符中,如果类型别名propagate_on_container_copy_assignment
等同于std::true_type
并且两个分配器比较不等,目标容器首先必须释放所有内存(这可能在后续过程中不可能)。超过这个点,如果propagate_on_container_copy_assignment
等同于std::true_type
,那么分配器应该被复制赋值。只有完成所有这些,元素才应该被复制。
容器的移动赋值运算符更复杂(记住,移动是一种优化,我们希望它能带来回报!)我们面临的选择如下:
-
类型别名
propagate_on_container_move_assignment
等同于std::true_type
。在这种情况下,要执行的步骤是(a)确保目标容器释放其责任下的所有内存(它可能无法在稍后做到这一点),(b)移动赋值分配器,然后(c)从源容器将内存所有权转移到目标容器。 -
类型别名
propagate_on_container_move_assignment
等同于std::false_type
并且分配器比较相等。在这种情况下,你可以执行与上一个案例相同的步骤,但不要移动容器。 -
类型别名
propagate_on_container_move_assignment
等同于std::false_type
并且分配器比较不等。在这种情况下,实际上无法转移所有权,所以最好的办法是将对象本身从源容器移动到目标容器。
幸运的是,所有这些分配器属性都可以在编译时进行测试,因此决策过程不需要产生任何运行时成本。
我们为了简洁所做的事情...
你会注意到我们的Vector<T,A>
和ForwardList<T,A>
类型没有执行整个“分配器生命周期管理舞蹈”,以使我们的示例保持合理长度,并且因为我们对分配器复制和移动的管理方式是一个有趣的设计方面,这可能会要求在这本已经相当大的书中至少增加一章。请读者宽容,亲爱的读者。
在分配器感知容器中使用基于特质的分配器
在基于特质的传统分配器中,剩余的问题是:容器如何使用它们?
我们首先需要做的是调整我们对标准未初始化内存算法的分配器感知适配。例如,我们个人对std::uninitialized_copy()
的适配如下:
template <class A, class IIt, class OIt>
void uninitialized_copy_with_allocator
(A &a, IIt bs, IIt es, OIt bd) {
auto p = bd;
try {
for (auto q = bs; q != es; ++q) {
std::allocator_traits<A>::construct(a, p, *q);
++p;
}
} catch (...) {
for (auto q = bd; q != p; ++q)
std::allocator_traits<A>::destroy(a, q);
throw;
}
}
正如你所见,我们现在使用std::allocator_traits<A>
而不是直接使用A
,这为定制提供了机会,并且由于std::allocator_traits<A>
的成员函数都是静态的,所以将分配器作为第一个参数传递。相同的调整可以应用于我们编写的其他分配器感知算法的版本,具有相同的调用模式和将分配器作为第一个参数传递。
然后,我们到达了Vector<T,A>
类型。我们如何调整其实现以使用基于特性的现代分配器?首先要做的事情是调整容器的类型别名来源:
template <class T, class A = std::allocator<T>>
class Vector : A { // note: private inheritance
public:
using value_type =
typename std::allocator_traits<A>::value_type;
using size_type =
typename std::allocator_traits<A>::size_type;
using pointer =
typename std::allocator_traits<A>::pointer;
using const_pointer =
typename std::allocator_traits<A>::const_pointer;
using reference = value_type&;
using const_reference = const value_type&;
// ...
你可能会惊讶,类型别名reference
和const_reference
并不是从std::allocator_traits<A>
中获取的,但这是有原因的。在 C++中,正如本文所述,我们可以设计出类似“智能指针”的行为的类型(我们甚至在这本书中也这样做过;参见第六章),因此抽象在分配器提供非原始指针的情况下是有用的,但目前还没有已知的方法来编写“智能引用”(这将需要能够重载operator.()
,并且关于这一点的提案至今未能被接受)。
唯一的行为类似于T
的引用类型的类型是…嗯,T&
。因此,这些类型别名在 C++17 中被弃用,并在 C++20 中被移除。我们仍然可以提供它们来澄清我们的类型成员函数签名,但它们不再是标准所要求的。
对于Vector<T,A>
的成员函数而言,一般思路是将对A
成员函数的所有调用替换为对std::allocator_traits<A>
的static
成员函数的调用,该函数以对A
对象的引用作为参数(记住,在我们的Vector<T,A>
实现中,A
是容器的private
基类)。以下是一个示例:
Vector(size_type n, const_reference init)
: A{},
elems{ std::allocator_traits<A>::allocate(
static_cast<A&>(*this), n)
},
nelems{ n }, cap{ n } {
try {
uninitialized_fill_with_allocator(
static_cast<A&>(*this), begin(), end(), init
);
} catch (...) {
std::allocator_traits<A>::deallocate(
static_cast<A&>(*this), elems, capacity()
);
throw;
}
}
如果你对于在数据成员初始化器中使用*this
感到不适,你可以放心,因为我们只使用了*this
的A
部分,并且在那个点上基类子对象已经被完全初始化。这是*this
的一个安全部分来使用。
同样的调整必须应用于整个容器(在数十个地方)并且显然会使源代码更加冗长,但好消息是这为我们获得了一个零运行时成本的抽象层,并帮助了所有实际编写分配器的开发者。
对于像ForwardList<T,A>
这样的基于节点的容器,情况类似但略有不同。一方面,类型别名很棘手;其中一些是为用户代码设计的,应该根据容器的value_type
来表示,而其他则应该基于通过其特性表示的分配器类型:
template <class T, class A = std::allocator<T>>
class ForwardList {
public:
// note: these are the forward-facing types, expressed
// in terms where T is the value_type
using value_type = T;
using size_type =
typename std::allocator_traits<A>::size_type;
using pointer = value_type*;
using const_pointer = const value_type*;
using reference = value_type&;
using const_reference = const value_type&;
// ...
在容器内部,我们需要将A
重新绑定到我们内部Node
类型的分配器:
// ...
private:
struct Node {
value_type value;
Node *next = nullptr;
Node(const_reference value) : value { value } {
}
Node(value_type &&value) : value{ std::move(value) }{
}
};
using Alloc = typename std::allocator_traits<
A
>::template rebind_alloc<Node>;
Alloc alloc;
// ...
在这一点之后,我们将使用std::allocator_traits<Alloc>
类型的static
成员函数来执行内存管理任务,将alloc
数据成员作为参数传递,如下例所示:
// ...
void clear() noexcept {
for(auto p = head; p; ) {
auto q = p->next;
std::allocator_traits<Alloc>::destroy(alloc, p);
std::allocator_traits<Alloc>::deallocate(
alloc, p, 1
);
p = q;
}
nelems = 0;
}
template <std::forward_iterator It>
ForwardList(It b, It e) {
if(b == e) return;
try {
head = std::allocator_traits<
Alloc
>::allocate(alloc, 1);
std::allocator_traits<Alloc>::construct(
alloc, head, *b
);
auto q = head;
++nelems;
for(++b; b != e; ++b) {
auto ptr = std::allocator_traits<
Alloc
>::allocate(alloc, 1);
std::allocator_traits<
Alloc
>::construct(alloc, ptr, *b);
q->next = ptr;
q = q->next;
++nelems;
}
} catch (...) {
clear();
throw;
}
}
// ...
当然,同样的技术需要应用于整个容器,但复杂性保持不变。
现在我们已经看到,传统的分配器,其位于容器的类型中,已经从其原始的(相当复杂)合同演变为当代基于特性和简化的实现(容器有些冗长),这让人想到我们已经达到了某种形式的优化。这是对也是错。
传统分配器的烦恼
传统方法在运行时对分配器是最佳的,因为可以无开销地调用这种分配器的服务,如果分配器是无状态的,那么在容器中引入分配器在空间上没有成本。还不错!
当然,没有运行时成本并不意味着没有成本:
-
由于额外的(编译时)分层,容器的实现可能会变得相当复杂,编写、理解和维护源代码都有成本。这种专业知识并非普遍存在;当然,亲爱的读者,您拥有它,但其他人并不一定与您分享这种优势。
-
在本质上几乎在所有方面都相同,但在管理内存的方式上不同的两个容器(使用不同分配器的两个容器)在实践中将是不同类型,这可能会在具有多个容器-分配器组合的程序中减慢编译时间。
-
一些可能应该是简单的操作变得更为复杂。例如,如果试图比较容器
v0
和v1
的相等性,并且如果v0
是Vector<T,A0>
而v1
是Vector<T,A1>
,那么就需要编写一个operator==()
函数来处理两种不同的类型……即使容器的分配器可能不是其显著属性之一,并且在这种情况下,在比较两个容器的大小和值时,分配器不应该是关注的焦点。
同样的推理也适用于许多其他与容器相关的操作:在传统方法中,分配器(allocator)是容器类型的一部分,但许多操作与value_type
相关,与分配器无关。我们是运行时最优的,但我们在代码生成复杂性方面有额外的成本(这可能导致更大的二进制文件,可能会影响运行速度),并且增加维护工作量(包括理解代码的源代码)也有代价。
即使像使分配器类型感知(毕竟,传统的分配器是某些类型T
的分配器T
)这样看似简单的事情有时也是具有争议的。毕竟,低级内存分配函数如std::malloc()
或::operator new()
是在处理原始字节,所以这是否意味着我们的传统分配器模型是可完善的?
多态内存资源分配器
在 C++17 中,C++ 语言添加了所谓的 PMR 分配器。PMR 容器将分配器信息存储为运行时值,而不是其类型的编译时部分。在这个模型中,PMR 容器包含一个指向 PMR 分配器的指针,减少了所需的类型数量,但在使用内存分配服务时增加了虚拟函数调用。
这再次不是无成本的决策,并且与传统模型相比存在权衡:
-
这种新的分配器模型假设容器存储一个指向分配策略的指针,这通常(但不总是)使得 PMR 容器比它们的非 PMR 对应物更大。有趣的是,这也意味着
std::pmr::vector<T>
与std::vector<T>
是不同的容器,这有时会导致非常真实的不便。例如,没有隐式的方法可以将std::pmr::string
的内容复制到std::string
中,但幸运的是,编写这样的函数非常简单。 -
每次分配或释放服务调用都会产生多态间接成本。在调用函数执行一些重要计算的程序中,这可能是微不足道的,但当调用函数执行的计算很少时,相同的成本可能会很痛苦。
-
PMR 容器在内存资源上参数化,PMR 内存资源以字节为单位进行交易,而不是以对象为单位。这不清楚这是好事还是坏事(这可能是视角的问题),但两种方法都有效,但以字节(最简单的共同分母)进行交易使得减少程序中的类型数量更容易。
PMR 方法也有其优势:
-
容器的类型不受其分配器类型的影响。所有 PMR 容器仅持有指向所有 PMR 内存资源基类
std::pmr::memory_resource
的指针。 -
实现 PMR 分配器所需的工作非常小,因为只需要重写三个虚拟成员函数。这为表达可重用分配器库开辟了途径,例如。
在 PMR 模型下,一个 std::pmr::polymorphic_allocator<T>
对象使用一个 std::pmr::memory_resource*
来确定内存是如何管理的。在大多数情况下,当设计内存分配策略时,你所做的是编写一个专门化 std::memory_resource
的类,并确定使用该策略分配或释放内存的含义。
让我们看看一个简单的 PMR 容器示例,它具有顺序缓冲区内存资源,正如我们刚刚使用传统分配器实现了这样的机制:
#include <print>
#include <vector>
#include <string>
#include <memory_resource>
int main() {
enum { N = 10'000 };
alignas(int) char buf[N * sizeof(int)]{};
std::pmr::monotonic_buffer_resource
res{ std::begin(buf), std::size(buf) };
std::pmr::vector<int> v{ &res };
v.reserve(N);
for (int i = 0; i != N; ++i)
v.emplace_back(i + 1);
for (auto n : v)
std::print("{} ", n);
std::print("\n {}\n", std::string(70, '-'));
for (char * p = buf; p != buf + std::size(buf);
p += sizeof(int))
std::print("{} ", *reinterpret_cast<int*>(p));
}
这相当简单,不是吗?你可能需要注意以下几点:
-
该程序旨在在线程执行栈上的字节数组中“分配”对象。由于这些对象是
int
类型,我们确保缓冲区buf
适当对齐,并且足够大,可以容纳要存储的对象。 -
一个名为
res
的std::pmr::monotonic_buffer_resource
对象知道要管理的缓冲区从哪里开始以及有多大。它代表了对连续内存的视角。 -
在这个程序中使用的
std::pmr::vector<int>
了解res
并使用该资源来分配和释放内存。
就这些了。实际上,这个程序甚至没有从自由存储中分配一个字节来存储 int
对象。与过去为了达到类似效果所必须做的事情相比,这可能会让人感到有些欣慰。在程序结束时,遍历字节数组和遍历容器会产生相同的结果。
这工作得很好,并且几乎不需要编写代码,但如果我们想表达类似 string
对象的向量,同时希望向量和它存储的 string
对象都使用相同的分配策略怎么办?
嵌套分配器
嗯,碰巧 PMR 分配器默认会传播分配策略。考虑以下示例:
#include <print>
#include <vector>
#include <string>
#include <memory_resource>
int main() {
auto make_str = [](const char *p, int n) ->
std::pmr::string {
auto s = std::string{ p } + std::to_string(n);
return { std::begin(s), std::end(s) };
};
enum { N = 2'000 };
alignas(std::pmr::string) char buf[N]{};
std::pmr::monotonic_buffer_resource
res{ std::begin(buf), std::size(buf) };
std::pmr::vector<std::pmr::string> v{ &res };
for (int i = 0; i != 10; ++i)
v.emplace_back(make_str("I love my instructor ", i));
for (const auto &s : v)
std::print("{} ", s);
std::print("\n {}\n", std::string(70, '-'));
for (char c : buf)
std::print("{} ", c);
}
此示例也使用堆栈上的缓冲区,但该缓冲区既用于 std::pmr::vector
对象及其元数据,也用于其中的 std::string
对象。从封装容器到封装容器的分配策略传播是隐式的。
请注意,该程序中的 make_str
lambda 表达式用于将格式化后以整数结尾的 std::string
转换为 std::pmr::string
。如前所述,从 std
命名空间和 std::pmr
命名空间中集成类型有时需要一点努力,但这两个命名空间中类的 API 足够相似,使得这种努力仍然是合理的。
如果你使用这个程序,你会注意到 std::pmr::string
对象包含预期的文本,但你也许也会从最后一个循环中注意到缓冲区 buf
包含(以及其他事物)字符串中的文本。这是因为我们的字符串相当短,并且在大多数标准库实现中,std::pmr::string
并不是单独分配的。这清楚地表明,由我们的 std::pmr::monotonic_buffer_resource
类型的对象表示的相同分配策略已经从 std::pmr::vector
对象传播到了封装的 std::pmr::string
对象。
作用域分配器和传统模型
尽管我们在这本书中没有这样做,但使用传统的分配器方法,仍然可以使用作用域分配器系统。如果你好奇,可以自由地探索类型 std::scoped_allocator_adapter
以获取更多信息。
我们现在将查看最后一个示例,该示例使用分配器来跟踪内存分配过程。
分配器和数据收集
正如我们在第八章中编写我们自己的谦逊但功能性的泄漏检测器时所看到的,内存管理工具通常用于收集信息。对于非详尽的列表,要知道一些公司使用它们来跟踪内存碎片化或评估对象在内存中的位置,可能是在寻求优化缓存使用。其他人想要评估在程序执行过程中何时何地发生分配,以了解代码重组是否可能导致更好的性能。当然,检测泄漏是有用的,但我们已经知道了这一点。
作为 PMR 分配使用的第三个也是最后一个示例,我们将实现一个跟踪资源,也就是说,我们将跟踪容器从分配和释放请求,以了解该容器所做的某些实现选择。为了这个示例,我们将使用标准库的std::pmr::vector
并尝试理解它在尝试向满容器插入对象时增加其容量的方法。记住,标准要求操作如push_back()
具有摊销常数复杂度,这意味着容量应该很少增长,并且大多数插入操作应该花费常数时间。然而,它并没有强制特定的增长策略:例如,一个实现可能以 2 的倍数增长,另一个可能以 1.5 的倍数增长,另一个可能更倾向于 1.67。其他选项也存在;每个选项都有其权衡,每个库都做出自己的选择。
我们将把这个工具表示为类tracing_resource
,它从std::pmr::memory_resource
派生,正如std::pmr
容器所期望的那样。这使得我们能够展示如何轻松地将内存资源类型添加到这个框架中:
-
基类公开了三个需要重写的成员函数:
do_allocate()
,它旨在执行分配请求,do_deallocate()
,其角色是,不出所料,释放通过do_allocate()
分配的内存,以及do_is_equal()
,它旨在让用户代码测试两个内存资源是否相等。请注意,在这种意义上的“相等”意味着从一个分配的内存可以从另一个中释放。 -
由于我们想要跟踪分配请求,但又不想自己实现实际的内存分配策略,我们将使用一个
upstream
资源,它会为我们进行分配和释放。在我们的测试实现中,这个资源将是一个全局资源,通过std::pmr::new_delete_resource()
获得,该资源调用::operator new()
和::operator delete()
来实现这一目标。 -
因此,我们的分配函数将简单地“记录”(在我们的情况下,打印)请求的分配和释放大小,然后将分配工作委托给
upstream
资源。
完整的实现如下:
#include <print>
#include <iostream>
#include <vector>
#include <string>
#include <memory_resource>
class tracing_resource : public std::pmr::memory_resource {
void* do_allocate(
std::size_t bytes, std::size_t alignment
) override {
std::print ("do_allocate of {} bytes\n", bytes);
return upstream->allocate(bytes, alignment);
}
void do_deallocate(
void* p, std::size_t bytes, std::size_t alignment
) override {
std::print ("do_deallocate of {} bytes\n", bytes);
return upstream->deallocate(p, bytes, alignment);
}
bool do_is_equal(
const std::pmr::memory_resource& other
) const noexcept override {
return upstream->is_equal(other);
}
std::pmr::memory_resource *upstream;
public:
tracing_resource(std::pmr::memory_resource *upstream)
noexcept : upstream{ upstream } {
}
};
int main() {
enum { N = 100 };
tracing_resource tracer{
std::pmr::new_delete_resource()
};
std::pmr::vector<int> v{ &tracer };
for (int i = 0; i != N; ++i)
v.emplace_back(i + 1);
for (auto s : v)
std::print("{} ", s);
}
如果你运行这个非常简单的程序,你将能够对标准库 std::pmr::vector
实现的增长策略有一个直观的认识。
优点和成本
正如我们所看到的,PMR 模型有很多值得称赞的地方。它使用简单,相对容易理解,并且易于扩展。在许多应用领域,它的速度足够快,可以满足大多数程序员的需。
当然,也有一些领域需要传统分配器模型提供的对执行时间和运行时行为的增加控制:没有来自模型的间接引用,没有对象大小方面的开销……有时,你只需要尽可能多的控制。这意味着这两种模型都有效,并且都有其存在的合理理由。
PMR 分配器的一个非常实际的优点是,它们使得构建可以组合和构建的分配器和资源库变得更容易。标准库从 <memory_resource>
头文件提供了一些有用的示例:
-
我们已经看到了函数
std::pmr::new_delete_resource()
,它提供了一个系统范围内的资源,其中分配和释放是通过::operator new()
和::operator delete()
实现的,就像我们看到的std::pmr::monotonic_buffer_resource
类,它正式化了在现有缓冲区内部进行顺序分配的过程。 -
std::pmr::synchronized_pool_resource
和std::pmr::unsynchronized_pool_resource
类模拟从某些大小的块池中分配对象。当然,对于多线程代码,使用同步的版本。 -
有
std::pmr::get_default_resource()
和std::pmr::set_default_resource()
函数,分别获取或替换程序的默认内存资源。默认内存资源,正如预期的那样,与std::pmr::new_delete_resource()
函数返回的内容相同。 -
此外,还有一个函数
std::pmr::null_memory_resource()
,它返回一个永远不会分配资源的对象(其do_allocate()
成员函数在调用时抛出std::bad_alloc
异常)。这作为一个“上游”措施是很有趣的:考虑一个通过std::pmr::monotonic_buffer_resource
实现的顺序缓冲区分配器系统,其中对内存分配的请求可能导致缓冲区溢出。由于默认情况下,内存资源的“上游”使用另一个调用::operator new()
和::operator delete()
的资源,这种潜在的溢出将导致实际的分配,这可能会对性能产生不良影响。为“上游”资源选择std::pmr::null_memory_resource
确保不会发生此类分配。
正如我们所看到和执行的,通过 PMR 模型添加到这个小集合的内存资源并定制容器的行为以适应你的需求是很容易的。
摘要
这确实是一个充满事件的一章,不是吗?在第十二章和第十三章中探讨了显式和隐式内存分配实现之后,本章探讨了分配器以及这些设施如何让我们定制容器中内存分配的行为以满足我们的需求。
我们看到了一个传统的分配器,它嵌入在其封装容器的类型中,是如何实现和使用的。我们使用了一个以连续内存为交易条件的容器,以及一个基于节点的容器。我们还探讨了编写(和使用)此类分配器的任务是如何随着时间演变,最终成为当代基于特性的分配器,这些分配器隐式地综合了大多数分配器服务的默认实现。
我们随后研究了较新的 PMR 分配器模型,它代表了内存分配的不同观点,并讨论了其优点和缺点。凭借本章的知识,你应该有了关于如何定制容器以满足你需求的想法。
我们的旅程即将结束。在下一章(也是最后一章)中,我们将探讨 C++中内存分配的一些当代问题,并开始思考近未来等待我们的是什么。
第十五章:当代问题
我们即将结束这段旅程,亲爱的读者。在这本书的过程中,我们探讨了 C++ 对象模型的基本方面,并讨论了低级编程的危险之处。我们通过 RAII 习语研究了 C++ 中资源管理的根本,了解了智能指针的使用方法,并探讨了如何编写此类类型。我们还掌握了可用的内存分配函数(我们以多种方式做到了这一点!),并编写了能够自行管理内存以及通过其他对象或类型(包括分配器)来管理内存的容器。
那是一次相当的经历!
我们还需要讨论什么?嗯,很多……但是一本书能包含的内容是有限的。因此,为了总结我们对 C++ 内存管理的讨论,我想我们可以聊一聊(是的,亲爱的读者,就你和我)一些当代 C++ 内存管理中的有趣话题。是的,一些最近才标准化(截至本书写作时)的事情,大多数(如果不是所有)库还没有实现它们,以及标准委员会正在积极工作的内容。
重要的是要看看 C++ 当前的样子以及它可能近期的样子,因为该语言仍在不断进化,而且速度相当快:每三年就会发布一个新的 C++ 标准版本,这种情况自 2011 年以来一直如此。C++ 的进化对于一些人来说太慢,对于另一些人来说又太快,但它是不懈的(我们称这种发布节奏为“火车模型”,以强调其持续的步伐),并为我们所热爱的这种语言带来了定期的进步和创新。
截至 2025 年初,C++23 是一个新采用的标准,于 2024 年 11 月正式化(是的,我知道:ISO 流程确实需要一些时间),委员会正在讨论旨在 C++26(是的,已经!)和 C++29 的提案。
本章我们将讨论的与内存管理相关的话题,要么是 C++23 标准中我们尚未在本书中讨论的方面,要么是随着本章的编写正在进行讨论的,即将到来的标准中的某些话题。亲爱的读者,请注意,你现在将读到的内容可能会以你将读到的方式成为现实,但也可能在 C++ 标准委员会的讨论和辩论后以另一种形式出现……或者最终可能永远不会出现。
即使这些话题最终没有以最初讨论的形式进入 C++ 标准,你也会知道它们已经被讨论过,以及它们旨在解决的问题,并且这些特性可能在某个时刻成为语言的一部分。谁知道呢;也许你会有顿悟,找到将这些想法中的一个转化为提案,然后 C++ 标准委员会将讨论并采纳。
在本章中,我们将涵盖以下主题:
-
明确地开始一个或多个对象的生命周期,而不使用它们的构造函数
-
简单重定位:它的含义以及标准委员会试图以何种方式解决它
-
类型感知的分配和释放函数:它们会做什么以及如何从中受益
本章我们将通过解决我们试图解决的问题的视角来介绍这些新特性(或即将推出的特性)。这种方法的意图是清楚地表明这些特性解决实际问题,并将帮助真正的程序员更好地完成工作。
我希望这一章能给您提供一个有趣的(尽管不是详尽的)当代内存管理和相关设施问题的见解,这些问题与 C++相关。
关于本章代码示例的说明
如果您尝试编译本章的示例,尊敬的读者,您可能会因为一些示例无法编译而感到沮丧,而其他示例可能需要一段时间才能编译,或者永远无法编译。对于这样一个章节来说,这种情况是正常的:我们将讨论最近添加到 C++语言中的特性组合(最近到以至于在撰写本书时尚未实现)以及 C++标准委员会正在讨论的特性。因此,将这些示例作为说明,并根据特性的更正式形式进行调整。
技术要求
您可以在本书的 GitHub 仓库中找到本章的代码文件:github.com/PacktPublishing/C-Plus-Plus-Memory-Management/tree/main/chapter15
.
不使用构造函数开始对象的生命周期
考虑一个程序,它从流中消费序列化数据并试图从该数据中创建对象的情况。以下是一个示例:
#include <fstream>
#include <cstdint>
#include <array>
#include <memory>
#include <string_view>
struct Point3D {
float x{}, y{}, z{};
Point3D() = default;
constexpr Point3D(float x, float y, float z)
: x{ x }, y{ y }, z{ z } {
}
};
// ...
// reads at most N bytes from file named file_name and
// writes these bytes into buf. Returns the number of
// bytes read (postcondition: return value <= N)
//
template <int N>
int read_from_stream(std::array<unsigned char, N> &buf,
std::string_view file_name) {
// ...
}
// ...
如您所见,在这个例子中,我们有Point3D
类。此类对象代表一组x, y, z坐标。我们还有一个read_from_stream<N>()
函数,它从文件中读取字节。该函数然后将最多N
字节存储到通过引用传递的参数buf
中,并返回读取的字节数(可能为零,但永远不会超过N
)。
为了这个例子,我们将假设我们计划从中读取的文件已知包含序列化的Point3D
对象的二进制形式,相当于按组三序列化的float
类型对象。现在,考虑以下程序,它从名为some_file.dat
的文件中消费最多四个Point3D
类型对象的字节表示:
// ...
#include <print>
#include <cassert>
using namespace std::literals;
int main() {
static constexpr int NB_PTS = 4;
static constexpr int NB_BYTES =
NB_PTS * sizeof(Point3D);
alignas(Point3D)
std::array<unsigned char, NB_BYTES> buf{};
if (int n = read_from_stream<NB_BYTES>(
buf, "some_file.dat"sv
); n != 0) {
// print out the bytes: 0-filled left, 2
// characters-wide, hex format
for (int i = 0; i != n; ++i)
std::print("{:0<2x} ", buf[i]);
std::println();
// if we want to treat the bytes as Point3D objects,
// we need to start the lifetime of these Point3D
// objects. If we do not, we are in UB territory (it
// might work or it might not, and even if it works
// we cannot count on it)
const Point3D* pts =
std::start_lifetime_as_array(buf.data(), n);
assert(n % 3 == 0);
for (std::size_t i = 0;
i != n / sizeof(Point3D); ++i)
std::print("{} {} {}\n",
pts[i].x, pts[i].y, pts[i].z);
}
}
这个示例程序从文件中读取字节到足够容纳四个Point3D
类型对象字节的std::array
对象中,首先确保如果这个数组要容纳该类型的对象,其对齐方式是适当的。这种对齐考虑是至关重要的,因为我们计划在读取这些字节后将其作为该类型的对象来处理。
这个示例的目的是,一旦读取了字节,程序员可以确信(嗯,尽可能确信)对于一些假设的Point3D
对象,所有的字节都是正确的,但仍然不能使用这些对象,因为它们的生命周期尚未开始。
这种情况通常会让许多 C 程序员微笑,而一些 C++程序员则会皱眉:C++对象模型对程序施加了约束,使得程序在对象的生命周期之外使用对象成为UB(见第二章),即使所有字节都是正确的,并且对齐约束得到了遵守,而 C 语言则不那么严格。为了使用我们刚才用来从该文件读取内容的缓冲区的内容,我们的传统选项如下:
-
要遍历字节数组,将适当大小的字节子集写入
float
类型的对象中,然后调用Point3D
对象的构造函数并将它们放入另一个容器中。 -
将字节数组
reinterpret_cast
为Point3D
对象数组,并寄希望于最好,这可能导致可能或可能不工作的代码,并且由于是 UB,因此无论如何都不具有可移植性(甚至不是在给定编译器的版本之间)。使用我们的Point3D
对象,它可能会给出人们希望得到的结果,但将这些替换为,比如说,来自标准库的std::complex<float>
对象(这种类型可能具有与我们的Point3D
类型相似的内结构)……嗯,谁知道会发生什么呢? -
将字节数组
std::memcpy()
到自身,将返回值转换为Point3D*
类型,并使用得到的指针作为Point3D
对象数组来使用。这实际上是有效的(std::memcpy()
函数是允许启动对象生命周期的函数集的一部分)。当然,存在创建实际字节副本的风险(这将浪费执行时间);据说某些标准库能够识别该模式,并且表现得就像调用是一个 no-op 一样,但这是一个可以启动对象生命周期的特殊类型的 no-op。
然而,这些选项似乎都不真正令人满意,因此需要一个更干净、不依赖于编译器特定优化的解决方案。为此,C++23 标准引入了一组constexpr
函数(附带一些重载),它们被称为std::start_lifetime_as_array<T>(p,n)
和std::start_lifetime_as<T>(p)
。这两个都是告知编译器字节是正确的,并且要考虑引用对象的生存期已开始的便携式魔法 no-op 函数。
当然,如果出于某种原因,指针的目标有非平凡的析构函数,你应该确保你的代码在适当的时候调用这些析构函数。预期这种情况很少见且不寻常。由于我们从某些数据源中消耗了原始字节并将这些字节转换成了对象,因此结果对象拥有资源的可能性相对较小。当然,一旦它们的生命周期开始,这些对象可以获取资源。让我们坦诚地说,亲爱的读者;如果 C++程序员不是富有创造力的,那还有什么?
这套std::start_lifetime_...
函数预计将成为网络程序员的福音,尤其是这些人。他们经常接收到格式良好的字节序列的数据帧,他们需要将其转换为对象以进行进一步处理。这些函数也预计将对从文件中消耗字节以形成聚合的程序有用。许多程序员认为,只需将字节读取到字节数组中并将该数组转换为预期的类型(或其数组)就足以访问其中的(假设的)对象(或对象),当他们的 C++代码开始出现意外行为时感到惊讶。C++是一种系统编程语言,由这些std::start_lifetime_...
函数组成的集合填补了可能表现不佳的空白。
当然,由于涉及的风险,这些函数形成了一个非常锋利的工具集:以这种方式开始生命周期的非平凡可析构对象尤其可疑,你必须高度信任提供字节以手动和显式启动对象生命周期的任何设施。因此,这些设施应该非常小心地使用。
关于本节的注意事项:截至本文撰写时,还没有主要的编译器实现这些函数,尽管它们已经被标准化,并且是 C++23 的一部分。也许在你读到这篇文章的时候,它们已经被实现了,谁知道呢?
简单重定位
如您所知,亲爱的读者,C++在编程社区中是那些我们需要从计算机或任何感兴趣的硬件平台上获取最大性能的语言之一。该语言的一些信条可以概括为“你不应为未使用的东西付费”和“不应有更低级语言的空间(除了偶尔的汇编代码)”,毕竟。后者解释了上一节中std::start_lifetime_...
函数的重要性。
这可能就是为什么,当显而易见我们可以比现在做得更好,在执行速度方面,这成为 C++程序员社区普遍感兴趣的话题,特别是 C++标准委员会的成员。我们都把语言的这些核心信条铭记在心。
我们可以做得更好的一个例子是,当我们遇到可以实际用 std::memcpy()
调用替换将源对象移动到目标对象,然后销毁原始对象的类型时:直接复制字节数组比执行一系列移动和析构函数更快(即使它不是,可能需要在您的 std::memcpy()
实现上做一些工作),尽管移动赋值和析构函数组合起来速度很快。
结果表明,有许多类型可以考虑这种优化,包括 std::string
、std::any
和 std::optional<T>
(取决于 T
的类型),例如前一部分中的 Point3D
类,任何未定义六个特殊成员函数的类型(包括基本类型),等等。
为了理解其影响,考虑以下名为 resize()
的自由函数,它模拟了某些容器 C
的 C::resize()
成员函数,该容器管理连续内存,例如本书中看到的各种形式的我们的 Vector<T>
类型。此函数将 arr
从 old_cap
(旧容量)调整大小到 new_cap
(新容量),并在末尾填充默认的 T
对象。该函数中高亮显示的行是我们这里感兴趣的部分:
//
// This is not a good function interface, but we want to
// keep the example relatively simple
//
template <class T>
void resize
(T *&arr, std::size_t old_cap, std::size_t new_cap) {
//
// we could deal with throwing a default constructor
// but it would complicate our code a bit and these
// added complexities, worthwhile as they are, are
// besides the point for what we are discussing here
//
static_assert(
std::is_nothrow_default_contructible_v<T>
);
//
// sometimes, there's just nothing to do
//
if(new_cap <= old_cap) return arr;
//
// allocate a chunk of raw memory (no object created)
//
auto p = static_cast<T*>(
std::malloc(new_cap * sizeof(T))
);
if(!p) throw std::bad_alloc{};
// ...
在这个阶段,我们已经准备好复制(或移动)对象:
// ...
//
// if move assignment does not throw, be aggressive
//
if constexpr(std::is_nothrow_move_assignable_v<T>) {
std::uninitialized_move(arr, arr + old_cap, p);
std::destroy(arr, arr + old_cap);
} else {
//
// since move assignment could throw, let's be
// conservative and copy instead
//
try {
std::uninitialized_copy(arr, arr + old_cap, p);
std::destroy(arr, arr + old_cap);
} catch (...) {
std::free(p);
throw;
}
}
//
// fill the remaining space with default objects
// (remember: we statically asserted that T::T() is
// non-throwing)
//
std::uninitialized_default_construct(
p + old_cap, p + new_cap
);
//
// replace the old memory block (now without objects)
// with the new one
//
std::free(arr);
arr = p;
}
观察该函数中高亮显示的行,尽管 std::uninitialized_move()
后跟 std::destroy()
的组合提供了一条快速路径,但我们甚至可以比这更快,用一个 std::memcpy()
调用替换一系列移动赋值运算符和一系列析构函数调用。
我们如何实现这一点?嗯,Arthur O’Dwyer、Mingxin Wang、Alisdair Meredith 和 Mungo Gill 等人提出了许多相互竞争的提案。每个提案都有其优点,但这些提案有以下共同因素:
-
在编译时提供一种测试类型是否具有“简单可重新定位性”的方法,例如,一个
std::is_trivially_relocatable_v<T>
特性。 -
提供一个实际重新定位对象的函数,例如
std::relocate()
或std::trivially_relocate()
,该函数接受源指针和目标指针作为参数,并将源对象重新定位到目标位置,结束原始对象的生命周期,然后开始新对象的生命周期 -
提供一种方法在编译时标记类型为简单可重新定位,例如通过关键字或属性
-
提供规则在编译时推断类型的简单可重新定位性
具体细节可能因方法而异,但如果我们假设这些工具,相同的 resize()
函数可以通过对之前提出的实现进行轻微调整从简单的重新定位中受益:
template <class T>
void resize
(T * &arr, std::size_t old_cap, std::size_t new_cap) {
static_assert(
std::is_nothrow_default_contructible_v<T>
);
if(new_cap <= old_cap) return arr;
auto p = static_cast<T*>(
std::malloc(new_cap * sizeof(T))
);
if(!p) throw std::bad_alloc{};
//
// this is our ideal case
//
if constexpr (std::is_trivially_relocatable_v<T>) {
// equivalent to memcpy() plus consider the
// lifetime of objects in arr, arr + old_cap)
// finished and the lifetime of objects in
// [p, p + old_cap) started
//
// note: this supposes that the trait
// std::is_trivially_relocatable<T>
// implies std::is_trivially_destructible<T>
std::relocate(arr, arr + old_cap, p);
//
// if move assignment does not throw, be aggressive
//
} else if constexpr(
std::is_nothrow_move_assignable_v<T>
){
std::uninitialized_move(arr, arr + old_cap, p);
std::destroy(arr, arr + old_cap);
} else {
// ... see previous code example for the rest
}
}
这种看似简单的优化已被报道提供了相当大的好处,有些人声称在常见情况下速度提高了高达 30%,但这是一项实验性工作,如果提议(正如我们预期的那样)合并成将被集成到 C++标准中的东西,我们预计会有更多的基准测试出现。
这种潜在的速度提升是 C++语言旨在实现的目标的一部分,因此我们可以合理地预期微小的迁移性将在可预见的未来成为现实。问题是“如何”:编译器应该如何检测微小的迁移性属性?当默认的微小迁移性推导规则不适用时,程序员应该如何在自己的类型上表明这种属性?
截至 2025 年 2 月,标准委员会投票将微小的迁移纳入将成为 C++26 标准的范畴。这意味着我们可以预期,一些用 C++语言先前标准编译的程序,在用 C++26 重新编译后,可以仅通过不修改任何源代码行就能运行得更快。
类型感知的分配和释放函数
我们在本章的最后讨论了关于内存管理和与对象生命周期相关的优化机会的新方法,即类型感知的分配和释放函数。这是一种针对用户代码可能希望以某种方式使用有关正在进行的分配(以及最终构建)的类型信息来指导分配过程的分配函数的新方法。
当我们在[第九章中描述T::operator delete()
将T*
作为参数传递而不是抽象的void*
时,我们看到了这些特性的一个方面,这是因为它因此负责对象的最终化和其底层存储的释放。我们看到,在某些情况下,这揭示了有趣的优化机会。
对于 C++26 正在讨论的是一组新的operator new()
和operator delete()
成员函数,以及接受std::type_identity<T>
对象作为第一个参数的免费函数,对于某些类型T
,这些函数将引导选定的操作符针对该类型T
执行一些特殊行为。请注意,这些类型感知的分配函数实际上是分配函数:它们不执行构造,它们的释放对应函数也不执行最终化。
std::type_identity<T>
特性是什么?
表达式typename std::type_identity<T>::type
对应于T
。好吧,这似乎很简单。那么,这个特性在当代 C++编程中扮演什么角色呢?实际上,特性std::type_identity<T>
,自 C++20 引入以来,是一种通常用于在泛型函数中提供对参数类型推导额外控制的工具。
例如,具有函数签名 template <class T> void f(T,T)
,您可以调用 f(3,3)
,因为两个参数都是同一类型,但不能调用 f(3,3.0)
,因为 int
和 double
是不同的类型。但话虽如此,通过将任一参数类型替换为 std::type_identity_t<T>
,您可以调用 f(3,3.0)
,并且由于 T
将根据另一个参数(类型为 T
的参数)推导出来,因此该类型将用于另一个参数(类型为 std::type_identity_t<T>
的参数)。这将导致两个参数都是 int
或 double
,具体取决于哪个参数是 T
类型。
使用 std::type_identity<T>
(而不是 std::type_identity_t<T>
)而不是 T
作为类型感知分配函数中第一个参数的类型,是为了清楚地表明我们正在使用这个特定的特殊重载的 operator new()
,并且这不是一个意外或调用此分配函数的其他特殊形式,例如在第九章中描述的那些。
这意味着您可以通过以下函数签名为特定类 X
提供专门的分配函数:
#include <new>
#include <type_traits>
void* operator new(std::type_identity<X>, std::size_t n);
void operator delete(new X, for example, the specialized form will be preferred to the usual form of operator new() and operator delete(), being assumed to be more appropriate unless the programmer takes steps to prevent it.
It also means that, given a specialized allocation algorithm that applies to type `T` only if `special_alloc_alg<T>` is satisfied, you could provide allocation functions that use this specialized algorithm for type `T` through the following function signatures:
包含 <new>
包含 <type_traits>
模板
void* operator new(std::type_identity
模板
void operator delete(X and Y, but that algorithm does not apply to other classes, such as Z:
#include <concepts>
#include <type_traits>
class X { /* ... */ };
class Y { /* ... */ };
class Z { /* ... */ };
template <class C>
concept cool_alloc_algorithm =
std::is_same_v<C, X> || std::is_same_v<C, Y>;
template <class T> requires cool_alloc_algorithm<T>
void* operator new(std::type_identity<T>, std::size_t n){
// apply the cool allocation algorithm
}
template <class T> requires cool_alloc_algorithm<T>
void operator delete(std::type_identity<T>, void* p) {
// apply the cool deallocation algorithm
}
#include <memory>
int main() {
// uses the "cool" allocation algorithm
auto p = std::make_unique<X>();
// uses the standard allocation algorithm
auto q = std::make_unique<Z>();
} // uses the standard deallocation algorithm for q
// uses the "cool" deallocation algorithm for p
类型感知分配函数也可以是成员函数重载,导致算法适用于定义这些函数的类,以及这些类的派生类。
考虑以下示例,该示例灵感来源于在wg21.link/p2719
中找到的更复杂示例,该示例描述了该特性的提案:
class D0; // forward class declaration
struct B {
// i)
template <class T>
void* operator new(std::type_identity<T>, std::size_t);
// ii)
void* operator new(std::type_identity<D0>, std::size_t);
};
// ...
如所述,i)
适用于 B
及其派生类,但 ii)
适用于已声明的类 D0
的特定情况,并且只有在 D0
确实是 B
的派生类时才会使用。
继续这个示例,我们现在添加三个从 B
派生的类,其中 D2
添加了 iii)
,这是一个非类型感知的 operator new()
成员函数重载:
// ...
struct D0 : B { };
struct D1 : B { };
struct D2 : B {
// iii)
void *operator new(std::size_t);
};
// ...
给定这些重载,以下是一些调用重载 i)
、ii)
和 iii)
的表达式示例:
// ...
void f() {
new B; // i) where T is B
new D0; // ii)
new D1; // i) where T is D1
new D2; // iii)
::new B; // uses appropriate global operator new
}
正如您所看到的,亲爱的读者,如果类型感知分配函数被纳入 C++标准,它们将提供新的方法来控制将使用哪种内存分配算法(根据情况而定),同时仍然让用户代码保持控制,使其能够根据需要推迟到全局 operator new()
函数,正如前一个示例中 f()
函数的最后一行所示。
与 C++20 的销毁删除功能相反,该功能同时执行对象的最终化和底层存储的分配,类型感知版本的operator new()
和operator delete()
只是分配函数,截至本文撰写时,没有计划提供销毁删除的类型感知版本。
摘要
在本章中,我们通过std::start_lifetime_...
函数窥见了 C++23 的未来,但这些函数尚未被任何主要编译器实现。我们还研究了 C++未来的可能(但尚未官方)部分,包括对平凡可重定位性的潜在支持以及引入类型感知版本的operator new()
和operator delete()
的可能性。
随着每一步的迈进,C++成为了一个更丰富、更通用的语言,我们可以用它做更多的事情,并以更精确的方式表达我们的想法。C++是一种提供对我们程序行为更多控制的编程语言。尽管 C++今天如此强大,它让像我们这样的程序员如此强大,但本章表明我们仍然可以继续进步。
我们已经到达了旅程的终点,至少目前是这样。希望这次旅行对您来说既有趣又愉快,尊敬的读者,并且您在旅途中学到了一些东西。我还希望这里讨论的一些想法能帮助您完成任务,丰富您对 C++编程的视角。
感谢您一直陪伴我。希望您未来的旅程愉快,就像我希望这本书能让您的工具箱更完善,并且您会继续独立探索。一路顺风。
附录:
您应该知道的事情
本书假设读者具备一些技术背景,这些背景可能不被一些人认为是“常识”。在以下章节中,您可能会找到有助于您充分利用本书的补充信息。根据需要参考,并享受阅读!
如果您认为您已经很好地了解了以下章节的内容,可以随意浏览,对于那些您不太熟悉的章节,可以仔细研究。您甚至可以跳过这个整个章节,在阅读本书时意识到这些主题并不是您想象中那么熟悉的情况下再回来。
总体目标是在阅读完这本书后获得最大收益!
结构体和类
在 C++中,struct
和class
这两个词基本上意味着相同的东西,以下代码是完全合法的:
struct Drawable {
virtual void draw() = 0;
virtual ~Drawable() = default;
};
class Painting : public Drawable {
void draw() override;
};
这里有一些需要注意的细节:
- C++没有像其他一些语言那样的
abstract
关键字。C++中的抽象成员函数是virtual
的,并且用=0
代替定义。virtual
关键字意味着可以被派生类特化(=0
部分本质上意味着必须被特化…)。我们经常谈论virtual
成员函数。必须被重写的函数被称为纯****虚函数。
为纯虚函数提供默认实现
可以为抽象成员函数提供一个定义:这不是典型情况,但这是可能的。这在基类想要提供一个服务的默认实现,但要求派生类至少考虑提供它们自己的情况下可能很有用。以下是一个示例:
#``include <iostream>
struct X { virtual int f() const =
0; };
int X::f() const { return
3; }
struct D : X { int f() const
override {
return X::f() +
1; }
};
void g(X &x) { std::cout << x.f() << '\``n'; }
int
main() {
D d;
// X x; // 非法:X 有一个纯虚成员函数
g(d);
}
-
C++ 类具有析构函数,用于处理对象生命周期结束时发生的情况。与许多其他流行的语言不同,C++ 中的自动和静态对象具有确定的生存期,并且在该语言中有效地使用析构函数是惯用的。在
virtual
成员函数中,通常会有一个virtual
析构函数(这里为virtual ~Drawable()
),以表明在以下情况中,通过间接方式(如p
)使用的对象被销毁时,应该有效地销毁指向的对象(Painting
),而不是指针静态类型表示的对象(Drawable
):// // the following supposes that Painting is a public // derived class of Drawable as suggested earlier in // this section // Drawable *p = new Painting; // ... delete p; // <-- here
-
一个
class
可以从struct
继承,就像struct
可以从class
继承一样,因为它们在结构上是等效的。主要区别在于,对于struct
,继承默认是public
(但可以使用protected
或private
来更改),成员也是如此,而对于class
,继承和成员默认是private
(但同样,也可以更改)。
顺便提一下,在 C++ 中,在基类(例如,Drawable::draw()
,它是 public
)和派生类(例如,Painting::draw()
,它是 private
)中有一个具有访问限定符的成员函数是完全正常的。一些其他流行的语言不允许这样做。
std::size_t
类型 std::size_t
是某些无符号整型的一个别名,但实际类型可能因编译器而异(可能是 unsigned int
、unsigned long
、unsigned long long
等)。
讨论容器大小和对象在内存中占用的空间时,经常会遇到类型 std::size_t
,这是通过 sizeof
运算符表示的。
sizeof 运算符
sizeof 运算符返回对象或类型的字节大小。它在编译时进行评估,并且在本书中将被广泛使用,因为我们需要这些信息来正确分配内存块:
auto s0 = sizeof(int); // s0 is the number of bytes in an
// int (parentheses required)
int n;
auto s1 = sizeof n; // s1 is the number of bytes occupied
// by s1, which is identical to s0.
// Note: for objects, parentheses are
// allowed but not mandated
对象大小是内存管理的关键组成部分之一,它影响着程序执行的速度。因此,这一点在本书中反复出现。
断言
“int
占用四个字节的存储空间。”在后一种情况下,我们有一个基于不可移植假设编写的程序,我们必须接受这个选择,但我们不希望我们的代码在那些假设不成立的平台上编译。
对于动态断言,通常使用 <cassert>
头文件中的 assert()
宏。该宏将布尔表达式作为参数,如果它评估为 false
,则停止程序执行:
void f(int *p) {
assert(p); // we hold p != nullptr to be true
// use *p
}
注意,许多项目在产品代码中禁用了 assert()
,这可以通过在编译前定义 NDEBUG
宏来实现。因此,请确保不要在 assert()
中放置有副作用的表达式,因为它可能会被编译器选项移除:
int *obtain_buf(int);
void danger(int n) {
int *p; // uninitialized
assert(p = obtain_buf(n)); // dangerous!!!
// use *p, but p might be uninitialized if assert()
// has been disabled. This is very bad
}
与库宏 assert()
相反,static_assert
是一种语言特性,如果其条件不满足,则阻止编译。基于前面提到的例子,其中一家公司可能基于不可移植的假设(如 sizeof(int)==4
)构建了软件,我们可以确保代码在这些实际上不支持的平台上的编译(和做坏事):
static_assert(sizeof(int)==4); // only compiles if the
// condition holds
在发布软件产品之前修复错误对于开发者和用户来说都远远优于软件发布到“野外”后修复错误。因此,static_assert
可以被视为交付更高品质产品的强大工具。
在这本书中,我们将经常使用 static_assert
:它没有运行时成本,并以可验证的方式记录我们的断言。这是一种基本上没有缺点特性。
未定义行为
未定义行为,通常简称为 UB,是由于标准没有规定特定行为的情况。在 C++ 标准中,UB 是没有要求的行为。它可能导致问题被忽略,也可能导致诊断或程序终止。关键思想是,如果你的程序有未定义行为,那么它没有按照语言的规则行事,是错误的;它的行为在你的平台上没有保证,它不能在不同的平台或编译器之间移植,也不能依赖。
一个正确编写的 C++ 程序没有未定义行为。当面对包含未定义行为的函数时,编译器可以对那个函数中的代码做几乎所有的事情,这使得从源代码中进行推理基本上是不可能的。
未定义行为是列在 第二章 中需要小心处理的“事项”之一。努力避免未定义行为:如果你留下它,它总是会反过来咬你。
类型特性
多年来,C++程序员已经开发出各种技术来推理他们类型的属性,大多数是在编译时。推断诸如“类型T
是const
吗?”或“类型T
是否可以平凡复制?”等问题非常有用,尤其是在泛型代码的上下文中。这些技术产生的结构被称为<type_traits>
头文件。
标准类型特性表达的方式随着时间的推移而标准化,从像std::numeric_limits<T>
这样的复杂野兽,它为类型T
提供了许多不同的服务,到更具体的服务,如std::is_const<T>
(类型T
实际上是const
吗?)或std::remove_const<T>
(请给我一个类似于T
的类型,如果有的话,不要const
修饰),它们产生一个单一的类型或一个单一值。实践表明,产生类型(命名为type
)或编译时已知值(命名为value
)的小型、单一类型特性可以被认为是“最佳实践”,并且大多数当代类型特性(包括标准特性)都是这样编写的。
自 C++14 以来,产生类型的特性有了以_t
结尾的别名(例如,不再需要写相当痛苦的typename std::remove_const<T>::type
咒语,现在可以写std::remove_const_t<T>
),而自 C++17 以来,产生值的特性有了以_v
结尾的别名(例如,不再需要写std::is_const<T>::value
,现在可以写std::is_const_v<T>
)。
那么,概念呢?
类型特性是一种 C++几十年来就有的编程技术,但自从 C++20 以来,我们有了概念,概念有点像特性(通常,它们是通过特性表达的),但在意义上更强,因为它们是类型系统的一部分。这本书并没有大量使用概念,但你(作为一个程序员)真的应该熟悉它们。它们非常强大,对当代 C++编程非常有用。
std::true_type 和 std::false_type 特性
当表达类型特性时,标准库采用了使用type
作为类型名称和value
作为值名称的常见做法,如下例所示:
// hand-made is_const<T> and remove_const<T> traits
// (please use the standard versions from <type_traits>
// instead of writing your own!)
template <class> struct is_const {
static constexpr bool value = false; // general case
};
// specialization for const types
template <class T> struct is_const<const T> {
static constexpr bool value = true;
};
// general case
template <class T> struct remove_const {
using type = T;
};
// specialization for const T
template <class T> struct remove_const<const T> {
using type = T;
};
事实上,许多类型特性都有布尔值。为了简化编写此类特性的任务并确保这些特性的形式是一致的,你将在<type_traits>
头文件中找到std::true_type
和std::false_type
类型。这些类型可以被视为类型系统中的常量true
和false
的对应物。
使用这些类型,我们可以将特性如is_const
重写如下:
#include <type_traits>
// hand-made is_const<T> (prefer the std:: versions...)
template <class> struct is_const : std::false_type {
};
template <class T>
struct is_const<const T> : std::true_type {
};
这些类型既是便利,也是更清晰地表达思想的方式。
std::conditional<B,T,F>特性
有时根据编译时已知的条件在两种类型之间进行选择是有用的。考虑以下示例,其中我们试图实现某种类型T
的两个值的比较,该类型对于浮点类型和“其他”类型(如int
)的行为不同,所有这些类型都为了简单起见而组合在一起:
#include <cmath>
// we will allow comparisons between exact representations
// or floating point representations based on so-called tag
// types (empty classes used to distinguish function
// signatures)
struct floating {};
struct exact {};
// the three-argument versions are not meant to be called
// directly from user code
template <class T>
bool close_enough(T a, T b, exact) {
return a == b; // fine for int, short, bool, etc.
}
template <class T>
bool close_enough(T a, T b, floating) {
// note: this could benefit from more rigor, but
// that's orthogonal to our discussion
return std::abs(a - b) < static_cast<T>(0.000001);
}
// this two-argument version is the one user code is
// meant to call
template <class T>
bool close_enough(T a, T b) {
// OUR GOAL: call the "floating" version for types
// float, double and long double; call the "exact"
// version otherwise
}
你可能会注意到,在我们的close_enough()
函数中,我们没有为类型exact
和floating
命名参数。这没关系,因为我们根本没使用这些对象;这些参数的原因是确保两个函数具有不同的签名。
<type_traits>
头文件中有一个std::is_floating_point<T>
特性,对于浮点数其值为true
,否则为false
。如果没有这个特性,我们可以自己编写:
// we could write is_floating_point<T> as follows
// (but please use std::is_floating_point<T> instead!
template <class> struct is_floating_point
: std::false_type {}; // general case
// specializations
template <> struct is_floating_point<float>
: std::true_type {};
template <> struct is_floating_point<double>
: std::true_type {};
template <> struct is_floating_point<long double>
: std::true_type {};
// convenience to simplify user code
template <class T>
constexpr bool is_floating_point_v =
is_floating_point<T>::value;
我们可以使用这个特性来做出决定。然而,我们不想在这里做出运行时决定,因为类型T
的本质在编译时是完全已知的,而且没有人愿意为比较整数时的一条分支指令付费!
可以使用std::conditional<B,T,F>
特性来做出这样的决定。如果我们自己编写,它可能看起来像这样:
// example, home-made conditional<B,T,F> type trait
// (prefer the std:: version in <type_traits>)
// general case (incomplete type)
template <bool, class T, class F> struct conditional;
// specializations
template < class T, class F>
struct conditional<true, T, F> {
using type = T; // constant true, picks type T
};
template < class T, class F>
struct conditional<false, T, F> {
using type = F; // constant true, picks type F
};
// convenience to simplify user code
template <bool B, class T, class F>
using conditional_t = typename conditional<B,T,F>::type;
给定这个特性,我们可以在编译时根据编译时布尔值选择两种类型中的一种,这正是我们试图做到的:
// ...
// this version will be called from user code
template <class T>
bool close_enough(T a, T b) {
return close_enough(
a, b, conditional_t<
is_floating_point_v<T>,
floating,
exact
> {}
);
}
这样理解这个调用:close_enough()
调用中的第三个参数(在我们的双参数用户界面close_enough()
函数中找到)将是一个floating
类型的对象或一个exact
类型的对象,但确切类型将在编译时根据is_floating_point_v<T>
编译时常量的值来选择。最终结果是实例化这两个空类中的一个对象,调用适当的算法,让函数内联来完成其余工作并优化整个框架。
算法
C++标准库包含了许多精华,其中之一是一组算法。这些函数中的每一个都执行一个非常好的循环所能完成的任务,但具有特定的名称、复杂度保证和优化。因此,让我们说我们编写以下代码:
int vals[]{ 2,3,5,7,11 };
int dest[5];
for(int i = 0; i != 5; ++i)
dest[i] = vals[i];
在 C++中,编写以下代码是惯例:
int vals[]{ 2,3,5,7,11 };
int dest[5];
[begin,end), meaning that for all algorithms, the beginning iterator (here, begin(vals)) is included and the ending iterator (here, end(vals)) is excluded, making [begin,end) a half-open range. All algorithms in <algorithm> and in its cousin header, <numeric>, follow that simple convention.
What about ranges?
The `<ranges>` library is a major addition to the C++ standard library since C++20 and can sometimes be used to lead to even better code than the already tremendous `<algorithm>` library. This book does not use ranges much, but that does not mean this library is not wonderful, so please feel free to use it and investigate ways through which it can be used to make your code better.
Functors (function objects) and lambdas
It is customary in C++ to use **functors**, otherwise called **function objects**, to represent stateful computations. Think, for example, of a program that would print integers to the standard output using an algorithm:
include
include
include
using namespace std;
void display(int n) { cout << n << ' '; }
int main() {
int vals[]{ 2,3,5,7,11 };
for_each(begin(vals), end(vals), display);
}
This small program works fine, but should we want to print elsewhere than on the standard output, we would find ourselves in an unpleasant situation: the `for_each()` algorithm expects a unary function in the sense of “function accepting a single argument” (here, the value to print), so there’s no syntactic space to add an argument such as the output stream to use. We could “solve” this issue through a global variable, or using a different function for every output stream, but that would fall short of a reasonable design.
If we replace the `display` function with a class, which we’ll name `Display` to make them visually distinct, we end up with the following:
include
include
include
include
using namespace std;
class Display {
ostream &os;
public:
Display(ostream &os) : os{ os } {
}
void operator()(int n) const { os << n << ' '; }
};
int main() {
int vals[]{ 2,3,5,7,11 };
// 在标准输出上显示
for_each(begin(vals), end(vals), Display{ cout });
ofstream out{"out.txt"};
// 将内容写入文件 out.txt
for_each(begin(vals), end(vals), Display{ out });
}
This leads to nice, readable code with added flexibility. Note that, conceptually, lambda expressions are functors (you can even use lambdas as base classes!), so the previous example can be rewritten equivalently as follows:
include
include
include
include
using namespace std;
int main() {
int vals[]{ 2,3,5,7,11 };
// 在标准输出上显示
for_each(begin(vals), end(vals), [](int n) {
cout << n << ' ';
});
ofstream out{"out.txt" };
// write to file out.txt
for_each(begin(vals), end(vals), &out {
out << n << ' ';
});
}
Lambdas are thus essentially functors that limit themselves to a constructor and an `operator()` member function, and this combination represents the most common case by far for such objects. You can, of course, still use full-blown, explicit functors if you want more than this.
Friends
C++ offers an access qualifier that’s not commonly found in other languages and is often misunderstood: the `friend` qualifier. A class can specify another class or a function as one of its friends, giving said `friend` qualifier full access to all of that class’s members, including those qualified as `protected` or `private`.
Some consider `friend` to break encapsulation, and indeed it can do this if used recklessly, but the intent here is to provide privileged access to specific entities rather than exposing them as `public` or `protected` members that were not designed to that end, leading to an even wider encapsulation breakage.
Consider, for example, the following classes, where `thing` is something that is meant to be built from the contents of a file named `name` by a `thing_factory` that’s able to validate the file’s content before constructing the `thing`:
class thing {
thing(string_view); // note: private
// ... various interesting members
// thing_factory can access private members of
// class thing
friend class thing_factory;
};
// in case we read an incorrect file
class invalid_format{};
class thing_factory {
// ... various interesting things here too
string read_file(const string &name) const {
ifstream in{ name };
// consume the file in one fell swoop, returning
// the entire contents in a single string
return { istreambuf_iterator
istreambuf_iterator
}
bool is_valid_content(string_view) const;
public:
thing create_thing_from(const string &name) const {
auto contents = read_file(name);
if(!is_valid_content(contents))
throw invalid_format{};
// note: calls private thing constructor
return { contents };
}
};
We do not want the whole world to be able to call the `private`-qualified `thing` constructor that takes an arbitrary `string_view` as an argument since that constructor is not meant to handle character strings that have not been validated in the first place. For this reason, we only let `thing_factory` use it, thus strengthening encapsulation rather than weakening it.
It is customary to put a class and its friends together when shipping code as they go together: a friend of a class, in essence, is an external addition to that class’s interface. Finally, note that restrictions apply to friendship. Friendship is not reflexive; if `A` declares `B` to be its friend, it does not follow that `B` declares `A` to be its friend:
class A {
int n = 3;
friend class B;
public:
void f(B);
};
class B {
int m = 4;
public:
void f(A);
};
void A::f(B b) {
// int val = b.m; // no, A is not a friend of B
}
void B::f(A a) {
int val = a.n; // Ok, B is a friend of A
}
Friendship is not transitive; if `A` declares `B` to be its friend and `B` declares `C` to be its friend, it does not follow that `A` declares `C` to be its friend:
class A {
int n = 3;
friend class B;
};
class B {
friend class C;
public:
void f(A a) {
int val = a.n; // Ok, B is a friend of A
}
};
class C {
public:
void f(A a) {
// int val = a.n; // no, C is not a friend of A
}
};
Last but not least, friendship is not inherited; if `A` declares `B` to be its friend, it does not follow that if `C` is a child class of `B`, `A` has declared `C` to be its friend:
class A {
int n = 3;
friend class B;
};
class B {
public:
void f(A a) {
int val = a.n; // Ok, B is a friend of A
}
};
class C : B {
public:
void f(A a) {
// int val = a.n; // no, C is not a friend of A
}
};
Used judiciously, `friend` solves encapsulation problems that would be difficult to deal with otherwise.
The decltype operator
The type system of C++ is powerful and nuanced, offering (among other things) a set of type deduction facilities. The best-known type deduction tool is probably `auto`, used to infer the type of an expression from the type of its initializer:
const int n = f();
auto m = n; // m is of type int
auto & r = m; // r is of type int&
const auto & cr0 = m; // cr0 is of type const int&
auto & cr1 = n; // cr1 is of type const int&
As you might notice from the preceding example, by default, `auto` makes copies (see the declaration of variable `m` ), but you can qualify `auto` with `&`, `&&`, `const`, and so on if needed.
Sometimes, you want to deduce the type of an expression with more precision, keeping the various qualifiers that accompany it. That might be useful when inferring the type of an arithmetic expression, the type of a lambda, the return type of a complicated generic function, and so on. For this, you have the `decltype` operator:
template
T& pass_thru(T &arg) {
return arg;
}
int main() {
int n = 3;
auto m = pass_thru(n); // m is an int
++m;
cout << n << ' ' << m << '\n'; // 3 4
decltype(pass_thru(n)) r = pass_thru(n); // r is an int&
++r;
cout << n << ' ' << r << '\n'; // 4 4
}
The use of `auto` has become commonplace in C++ code since C++11, at least in some circles. The `decltype` operator, also part of C++ since C++11, is a sharper tool, still widely used but for more specialized use cases.
When the types get painful to spell
In the preceding `decltype` example, we spelled `pass_thru(n)` twice: once in the `decltype` operator and once in the actual function call. That’s not practical in general since it duplicates the maintenance effort and… well, it’s just noise, really. Since C++14, one can use `decltype(auto)` to express “the fully qualified type of the initializing expression.”
Thus, we would customarily write `decltype(auto) r = pass_thru(n);` to express that `r` is to have the fully qualified type of the expression `pass_thru(n)` .
Perfect forwarding
The advent of variadic templates in C++11 has made it necessary to ensure there is a way for the semantics at the call site of a function to be conveyed throughout the call chain. This might seem abstract but it’s quite real and has implications on the effect of function calls.
Consider the following class:
include
struct X {
X(int, const std::string&); // A
X(int, std::string&&); // B
// ... other constructors and various members
};
This class exposes at least two constructors, one that takes an `int` and `const string&` as argument and another that takes an `int` and a `string&&` instead. To make the example more general, we’ll also suppose the existence of other `X` constructors that we might want to call while still focusing on these two. If we called these two constructors explicitly, we could do so with the following:
X x0{ 3, "hello" }; // calls A
string s = "hi!";
X x1{ 4, s }; // also calls A
X x2{ 5, string{ "there" } }; // calls B
X x3{ 5, "there too"s }; // also calls B
The constructor of `x0` calls `A`, as `"hello"` is a `const char(&)[6]` (including the trailing `'\0'`), not a `string` type, but the compiler’s allowed to synthesize a temporary `string` to pass as a `const string&` in this case (it could not if the `string&` was non-`const` as it would require referring to a modifiable object).
The constructor of `x1` also calls `A`, as `s` is a named `string` type, which means it cannot be implicitly passed by movement.
The constructors of `x2` and `x3` both call `B`, which takes a `string&&` as an argument, as they are both passed temporary, anonymous `string` objects that can be implicitly passed by movement.
Now, suppose we want to write a factory of `X` objects that relays arguments to the appropriate `X` constructor (one of the two we’re looking at or any other `X` constructor) after having done some preliminary work; for the sake of this example, we’ll simply log the fact that we are constructing an `X` object. Let’s say we wrote it this way:
template <class ... Args>
X makeX(Args ... args) {
clog << "Creating a X object\n";
return X(args...); // <-- HERE
}
In this case, arguments would all have names and be passed by value, so the constructor that takes a `string&&` would never be chosen.
Now, let’s say we wrote it this way:
template <class ... Args>
X makeX(Args &... args) {
clog << "Creating a X object\n";
return X(args...); // <-- HERE
}
In this case, arguments would all be passed by reference, and a call that passed a `char` array such as `"hello"` as an argument would not compile. What we need to do is write our factory function in such a way that each argument keeps the semantics it had at the function’s call site, and is forwarded by the function with the exact same semantics.
The way to express this in C++ involves `std::forward<T>()` (from `<utility>`), which behaves as a cast. A forwarding reference superficially and syntactically looks like the `rvalue` references used for move semantics, but their impact on argument semantics is quite different. Consider the following example:
// v passed by movement (type vector
void f0(vector
// v passed by movement (type vector
// for some type T)
template
void f1(vector
// v is a forwarding reference (type discovered by
// the compiler)
template
f2():
// T is vector<int>&& (pass by movement)
f2(vector<int>{ 2,3,5,7,11 });
vector<int> v0{ 2,3,5,7,11 };
f2(v0); // T is vector<int>& (pass by reference)
const vector<int> v1{ 2,3,5,7,11 };
X objects, in this case, the appropriate signature for makeX() would be as follows:
template <class ... Args>
X makeX(Args ... args) {
clog << "Creating a X object\n";
return X(args...); // <-- HERE (仍然不正确)
}
This version of our function almost works. The signature of `makeX()` is correct as each argument will be accepted with the type used at the call site, be it a reference, a reference to `const`, or an `rvalue` reference. What’s missing is that the arguments we are receiving as `rvalue` references now have a name within `makeX()` (they’re part of the pack named `args`!), so when calling the constructor of `X`, there’s no implicit move involved anymore.
What we need to do to complete our effort is to *cast back each argument to the type it had at the call site*. That type is inscribed in `Args`, the type of our pack, and the way to perform that cast is to apply `std::forward<T>()` to each argument in the pack. A correct `makeX()` function, at long last, would be as follows:
template <class ... Args>
X makeX(Args &&... args) {
clog << "Creating a X object\n";
return X(std::forward
}
Whew! There are simpler syntaxes indeed, but we made it.
The singleton design pattern
There are many design patterns out there. Design patterns are a topic of their own, representing well-known ways of solving problems that one can represent in the abstract, give a name to, explain to others, and then reify within the constraints and idioms of one’s chosen programming language.
The **singleton** design pattern describes ways in which we can write a class that ensures it is instantiated only once in a program.
Singleton is not a well-liked pattern: it makes testing difficult, introduces dependencies on global state, represents a single point of failure in a program as well as a potential program-wide bottleneck, complicates multithreading (if the singleton is mutable, then its state requires synchronization), and so on, but it has its uses, is used in practice, and we use it on occasion in this book.
There are many ways to write a class that is instantiated only once in a program with the C++ language. All of them share some key characteristics:
* The type’s `copy` operations have to be deleted. If one can copy a singleton, then there will be more than one instance of that type, which leads to a contradiction.
* There should be no `public` constructor. If there were, the client code could call it and create more than one instance.
* There should be no `protected` members. Objects of derived classes are also, conceptually, objects of the base class, again leading to a contradiction (there would, in practice, be more than one instance of the singleton!).
* Since there is no `public` constructor, there should be a `private` constructor (probably a default constructor), and that one will only be accessible to the class itself or to its friends (if any). For simplicity, we’ll suppose that the way to access a singleton is to go through a `static` (obviously) member function of the singleton.
We’ll look at ways to implement an overly simplistic singleton in C++. For the sake of this example, the singleton will provide sequential integers on demand. The general idea for that class will be the following:
include
class SequentialIdProvider {
// ...
std::atomic
// 默认构造函数(私有)
SequentialIdProvider() : cur{ 0LL } {
}
public:
// 单例提供的服务(同步)
auto next() { return cur++; }
// 删除复制操作
SequentialIdProvider(const SequentialIdProvider&)
= delete;
SequentialIdProvider&
operator=(const SequentialIdProvider&) = delete;
// ...
};
The following subsections show two different techniques to create and provide access to the singleton.
Instantiation at program startup
One way to instantiate a singleton is to create it before `main()` starts by actually making it a `static` data member of its class. This requires *declaring* the singleton in the class and *defining* it in a separate source file in order to avoid ODR problems.
ODR, you say?
The **One Definition Rule** (**ODR**) and associated issues are described in *Chapter 2* of this book, but the gist of it is that in C++, every object can have many declarations but only one definition.
A possible implementation would be as follows:
include
class SequentialIdProvider {
// 声明(私有)
static SequentialIdProvider singleton;
std::atomic
// 默认构造函数(私有)
SequentialIdProvider() : cur{ 0LL } {
}
public:
// 提供对对象的静态成员函数访问
static auto & get() { return singleton; }
// 单例提供的服务(同步)
auto next() { return cur++; }
// 删除复制操作
SequentialIdProvider(const SequentialIdProvider&)
= delete;
SequentialIdProvider&
operator=(const SequentialIdProvider&) = delete;
// ...
};
// 在某个源文件中,例如 SequentialIdProvider.cpp
include "SequentialIdProvider.h"
// 定义(调用默认构造函数)
SequentialIdProvider,如果我们遇到麻烦,因为 C++不保证来自多个文件的全局对象实例化的顺序。
可能的客户端代码实现如下:
auto & provider = SequentialIdProvider::get();
for(int i = 0; i != 5; ++i)
cout << provider.next() << ' ';
这将显示单调递增的整数,可能是连续的(只要没有其他线程同时调用单例的服务)。
首次调用的实例化
实例化单例的另一种方法是,在首次请求其服务时创建它,使其成为提供单例访问权限的函数的static
变量。这样,由于static
局部变量在函数首次调用时创建并保持其状态,单例可以为其他单例提供服务,只要这不会创建循环。
可能的实现如下:
#include <atomic>
class SequentialIdProvider {
std::atomic<long long> cur; // state (synchronized)
// default constructor (private)
SequentialIdProvider() : cur{ 0LL } {
}
public:
// static member function providing access to the object
static auto & get() {
static SequentialIdProvider singleton; // definition
return singleton;
}
// service offered by the singleton (synchronized)
auto next() { return cur++; }
// deleted copy operations
SequentialIdProvider(const SequentialIdProvider&)
= delete;
SequentialIdProvider&
operator=(const SequentialIdProvider&) = delete;
// ...
};
可能的客户端代码实现如下:
auto & provider = SequentialIdProvider::get();
for(int i = 0; i != 5; ++i)
cout << provider.next() << ' ';
这将显示单调递增的整数,可能是连续的(只要没有其他线程同时调用单例的服务)。
注意,这个版本有一个隐藏的成本:函数本地的static
变量被称为static
变量涉及一些同步,并且这种同步在每次调用该函数时都会付出代价。前面的客户端代码通过一次调用SequentialIdProvider::get()
来减轻这种成本,然后在该调用之后重用通过该调用获得的引用;是get()
的调用引入了同步成本。
The std::exchange() function
在 <utility>
头文件中隐藏着(至少)两个非常有用且基本的功能。一个是众所周知的,并且已经存在很长时间:std::swap()
,它在标准库的许多用途以及用户代码中都被使用。
另一个较新的一个是 std::exchange()
。其中 swap(a,b)
交换对象 a
和 b
的值,表达式 a = exchange(b,c)
将 b
的值与 c
的值交换,并返回 b
的旧值(以便将其赋值给 a
)。一开始这可能看起来有些奇怪,但实际上这是一个非常实用的功能。
考虑以下简化版的 fixed_size_array
的移动构造函数:
template <class T>
class fixed_size_array {
T *elems{};
std::size_t nelems{};
public:
// ...
fixed_size_array(fixed_size_array &&other)
: elems{ other.elems }, nelems{ other.nelems } {
other.elems = nullptr;
other.nelems = 0;
}
// ...
};
你可能会注意到这个构造函数做了两件事:它从 other
中获取数据成员,然后使用默认值替换 other
的成员。这就是 std::exchange()
的典型应用,因此这个构造函数可以简化如下:
template <class T>
class fixed_size_array {
T *elems{};
std::size_t nelems{};
public:
// ...
fixed_size_array(fixed_size_array &&other)
: elems{ std::exchange(other.elems, nullptr) },
nelems{ std::exchange(other.nelems, 0) } {
}
// ...
};
使用 std::exchange()
,这个常见的两步操作可以简化为一个函数调用,简化代码并提高效率(在这种情况下,将赋值转换为构造函数调用)。