7.3. C++分配操作符详解

C++ 中内存分配操作符有很多种(无限多种!),但编写自己的分配操作符时需要遵循一些规则。本章大部分内容主要介绍这些规则;接下来的章节将探讨如何利用 C++ 给我们提供的这种自由:

每种情况下,内存分配函数都以四组形式出现的:operator new()、operator new[]()、operator delete() 和 operator delete[](),这一规则也有一些例外情况。如果重载了其中至少一个函数,就要同时重载全部四个函数,以保持程序行为的一致性。使用这种底层机制时,错误往往会带来更严重的后果,这也解释了为什么在第 2 章和第 3 章中,花了大量篇幅讲解可能遇到的问题……以及如何遵守规则。

内存分配与对象模型(详见第 1 章)和异常安全(贯穿本书的主题)密切相关,请确保在接下来的章节中理解这些交互。它们将有助于你,更好地理解和运用本书中所介绍的内容。

关于堆分配优化(HALO)的一点说明

不重载内存分配操作符会带来一些好处。其中之一,库供应商默认已经提供了非常高效的实现;另一个好处,编译器可以假设内存分配次数为不可观察的。则编译器允许将 n 次对 new 的调用替换为一次单独的调用,一次性分配需要的所有内存,然后再来管理这些内存。这在实践中可以带来非常显著的优化效果,甚至可能完全移除生成代码中的 new 和 delete 调用,即使出现在源码中也是如此!如果有疑问,请务必在将优化措施应用于生产代码之前,确保其能带来可测量(可量化)的性能提升。

对于本章中将要看到的内存分配操作符重载,应包含 <new> 头文件,其中声明了 std::bad_alloc 等类型,而这是内存分配函数通常用来报告分配失败的类型

7.3.1 全局分配操作符

假设想要控制 C++ 中内存分配操作符的全局版本。为了展示这是如何工作的,暂时只是简单地调用 malloc() 和 free(),并在第 8 章中展示一个更复杂的例子。

如果只使用这些操作符的基本形式,在 C++11 之前需要重载4个函数,而从 C++11 开始则需要重载6个函数。当然,本书假设已经处在 C++14 发布十多年之后的时代。

我们需要重载的函数签名如下所示:

void *operator new(std::size_t);
void *operator new[](std::size_t);
void operator delete(void *) noexcept;
void operator delete[](void *) noexcept;
// 自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{ /* ... 参数 ... */ };

这其实是一个两步操作:

// 分配足够存放一个 X 对象的空间
void * buf = operator new(sizeof(X));

// 在该内存位置构造一个 X 对象
X *p = ... // 在 buf 上调用 X 的构造函数 X::X( /* ... 参数 ... */ )

构造函数就像是一层“油漆”涂到了一块内存上,从而将这块内存转变为一个对象。从而,像 new X 这样的表达式可能在两个阶段失败:如果内存分配失败,则 operator new() 返回异常或失败;如果构造函数执行失败,则 X::X() 抛出异常或导致未定义行为。

只有这两个步骤都成功完成后,使用端代码才需要对所指向的对象负责。

关于调用这些操作符的说明

有的读者可能在前面的例子中注意到,我们有时写作 new X,有时写作 operator new(sizeof(X))。

这两种写法有不同的作用:第一种形式 —— 即操作符形式(new X) —— 会执行两步操作:首先是分配内存,然后调用构造函数进行对象构造;第二种形式 —— 即函数形式(operator new(sizeof(X))) —— 则只是直接调用内存分配函数,不会调用构造函数。

这种区别同样适用于 delete 操作符:

delete p 会先调用析构函数,然后调用 operator delete(p) 释放内存;而直接调用 operator delete(p) 则只会释放内存,不会调用析构函数。

因此,在使用时要特别注意究竟需要的是完整的对象生命周期管理,还是仅仅需要原始内存的分配或释放。

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; // 假设 p 的类型是 X*

这实际上是一个两步操作,等价于以下代码:

p->~X(); // 销毁 p 所指向的对象
operator delete(p); // 释放与该对象关联的内存

C++ 中无论是析构函数还是 operator delete(),都不应该抛出异常。如果抛出了异常,程序会立即终止。具体原因将在第 12 章中介绍。

带有大小感知能力的 operator delete() 和 operator delete[]() 版本是在 C++14 中引入的,如今在实现内存管理时,除了实现这些函数的经典版本之外,通常也建议实现带大小参数的版本。

operator new() 知道所分配内存块的大小,但 operator delete() 却不知道。这使得那些希望执行与大小相关操作的实现(例如,为了模糊内存中存储的内容而将内存块填充为某些特定值)需要进行一些(不必要的)复杂处理。

关于带大小参数 operator delete[]() 的重载说明

如果追踪自己重载的 operator delete[]() 执行情况,可能会发现,对于某些类型,带大小参数的 operator delete[]() 版本并不一定会调用。

事实上,如果有一个对象数组 arr,而其元素类型 简单可析构(trivially destructible) ,则标准并未明确规定在写下 delete[] arr 时,会使用带大小参数还是不带大小参数的 operator delete[]() 版本。

这并不是一个 bug,请放心。这是标准允许的实现细节,取决于编译器如何处理简单析构函数的情况。

一个简单且完整的实现,基本上只是将工作委托给 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 异常。由于内存分配之后紧接着是对象构造,使用端代码还可能面对构造函数抛出的异常。我们将在第 12 章中编写自定义容器时探讨如何处理这些情况。

某些应用领域中,异常机制并不是一个可选项。这可能是由于内存限制;大多数异常处理机制会使程序略微变大,这在嵌入式系统等领域可能不可接受。也可能是由于速度限制;try 块中的代码通常执行得很快,其代表的是“正常”的执行路径,但 catch 块中的代码通常认为是罕见的(“异常”)路径,执行速度可能会显著变慢。当然,有些人并不是因为技术上的缺陷,而是出于理念、设计哲学或编码风格的考虑,而选择不在程序中使用异常机制。

幸运的是,我们还有一种方式可以在不使用异常的情况下进行动态内存分配,那就是使用 nothrow 版本的 new 操作符,它在分配失败时返回 nullptr 而不是抛出异常。我们将在下一节中详细介绍这一机制。

7.3.2 分配操作符的非抛出版本

某些版本的内存分配操作符在分配失败时不会抛出异常。这些函数的签名如下:

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;
// 自C++14起
void operator delete
  (void *, std::size_t, const std::nothrow_t&) noexcept;
void operator delete[]
  (void *, std::size_t, const std::nothrow_t&) noexcept;

为什么有人会希望在内存分配失败时显式地要求返回空指针(nullptr),而不是抛出异常?毕竟,频繁地在代码中插入对 nullptr 的检查,显然比“假装不会发生错误”要麻烦得多!

事实是,在程序中使用异常机制确实需要付出一些代价:可能会略微增加生成的二进制文件体积,并可能降低代码执行速度,尤其是在捕获异常时。此外,还涉及代码风格的问题:有些人即使知道使用异常可以让代码更快,也坚决不愿意使用异常。

因此,像游戏开发或嵌入式系统这样的应用领域通常会回避使用异常,并会尽最大努力编写不依赖异常机制的代码。而内存分配函数的 “不抛出”(non-throwing)版本 正是为满足这些需求而设计的。

类型 std::nothrow_t 称为“标签类型”(tag type):它是一个空类,其实例(全局对象 std::nothrow)用于在生成代码时引导编译器做出正确的函数重载选择。这些函数签名要求将 std::nothrow_t 类型的参数以 常量引用(const&)方式传递,而不是按值传递。因此,如果打算替换这些函数,请务必遵循这一签名规范。

以下是这些函数的一个使用示例:

X *p = new (nothrow) X{ /* ... 参数 ... */ };
if(p) {
  // ... 使用 *p
  //  注意:这里的 delete 并不是 "nothrow" 版本的 delete
  delete p; // 即使 p 为 nullptr,这行也是安全的(不会做任何事)
}

有读者可能会对 nothrow 出现在 new 表达式中的位置感到有些意外。但请仔细想想,这几乎是唯一可以放置传递给 operator new() 参数的位置。

传递给该函数的第一个参数始终是要分配的连续字节数(在这里是 sizeof(X))。而在表达式 new X { ...args... } 中,紧跟在要构造对象类型 X 之后的部分是,传递给该类型构造函数的参数列表。

因此,如果想为 operator new() 本身传递其他的参数(比如 nothrow),唯一合适的语法位置就是在 new 和要构造的对象类型之间,用括号括起来的位置。

关于 operator new() 其他的参数位置的说明

为了更清楚地说明这一点,可以通过一个刻意设计的例子来展示。例如,可以编写如下的 operator new() 重载:

void* operator new(std::size_t, int, double);

这是一个带有其他参数的 operator new() 版本,除了第一个必须的 std::size_t 参数(表示要分配的字节数)之外,还接受一个 int 和一个 double 类型的参数。

然后,使用端代码中,可能有如下调用该假想函数的语句:

X *p = new (3, 1.5) X{ /* ... */ };

这个表达式中:

  • new:表示动态内存分配的开始

  • (3, 1.5):是传递给重载的 operator new() 的额外参数,类型分别是 int 和 double

回到 operator new() 和 operator new[]() 的 nothrow 版本:为什么还需要编写 operator delete() 和 operator delete[]() 的重载版本。毕竟,即使使用端代码使用的是 new 的 nothrow 版本(如前面的例子所示),通常,销毁对象时仍很可能使用的是“普通”版本的 operator delete()。那么,为什么还要编写 operator delete() 的 nothrow 版本呢?

原因在于异常安全。既然在编写的是不会抛出异常的 operator new() 版本,那为什么要担心异常呢?好吧,请记住,通过 operator new() 进行内存分配是一个两步操作:找到放置对象的位置;在该位置构造对象。

即使 operator new() 不抛出异常,仍无法控制构造函数是否会抛出异常。只有在分配和构造都成功完成后,使用端代码才会获得一个有效的指针;如果在分配成功但构造失败(抛出异常)的情况下,使用端代码无法自行处理内存释放……毕竟,根本还没看到那个指针!在构造函数抛出异常的情况下,释放内存的责任就落在了 C++ 运行时系统身上,而这一点适用于所有版本的 operator new(),不仅仅是 nothrow 版本。

(非正式地)其处理算法如下

// 第一步:尝试为某个类型 T 的对象分配内存
p = operator new(n, ... 可能还有其他参数 ...)

// 下面这行只适用于 nothrow 版本的 new
if(!p) return p

try {
  // 第二步:在地址 p 上构造 T 类型的对象
  在地址 p 上调用 T 的构造函数  // 这一步可能抛出异常
} catch(...) { // 如果构造过程中抛出异常
  释放在 p 上分配的内存 // 这正是我们关注的重点
  重新抛出异常,不管它是什么类型
}

return p // p 指向一个完全构造好的对象
         // 只有在这之后,使用端代码才会看到 p

正如该算法所示,当构造函数抛出异常时,C++ 运行时必须释放内存。但它是如何做到这一点的呢?

它会使用与 new 或 new[] 的分配版本签名相匹配的 operator delete()(或 operator delete[]())来执行释放。如果使用 operator new(std::size_t, int, double) 来分配内存,并且构造函数失败了,运行时就会使用 operator delete(void*, int, double) 来执行隐式的内存释放。

这正是为什么,如果重载了 new 和 new[] 的 nothrow 版本,就必须重载 delete 和 delete[] 的 nothrow 版本(当构造函数抛出异常时,这些版本将被用于释放内存);同时,也必须重载 new 和 new[] 的“普通”抛出版本,以及对应的 delete 和 delete[] 版本。

简单地说,像 X* p = new (std::nothrow) X; 这样的代码通常会使用 delete p; 来结束所指向对象的生命周期,suoyi nothrow 版本和抛出版本的分配函数必须保持一致。

下面是一个简单且完整实现示例,其中抛出版本通过调用非抛出版本来减少重复代码:

#include <iostream>
#include <cstdlib>
#include <new>

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

如果想要完整且一致地覆盖这一机制的抛出版本和非抛出版本,就需要编写相当多的函数,才能形成一组完整、协调的内存分配操作符。

还有许多内容需要讲解。例如,已经多次提到一个概念:在特定内存位置构造对象,特别是在 new 调用所建模的两步过程中的第二步中指定内存位置。接下来就来看看这要如何实现。

7.3.3 最重要的operator new:placement new

operator new() 及其相关函数中最重要的一个版本是无法替换的,即使可以替换它……嗯,这里想要说的是,想要实现一个更高效的版本是非常困难的:

// 注意:这些(指某些函数、机制或实现)是存在的,
// 你可以使用它们,但不能替换它们
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 { }

我们称这些函数为布置(placement)分配函数,在编程社区中它们更广为人知的名字是 placement new。

这些函数的用途是什么?我们一开始讨论内存分配操作符的全局版本时曾说过:“new 所做的,是找到一个用于构造对象的位置。”这并不代表 new 会进行内存分配。事实上,placement new 就不会进行分配;只是简单地将传入的地址作为参数原样返回。允许将对象构造在内存中的任意位置……前提是我们拥有对该内存位置的写权限。

placement new 有多种用途:

placement new 的一个重要优势体现在容器的实现,以及容器与分配器(allocator)之间的交互上,这些主题将在第 12 到第 14 章中详细探讨。目前,先通过一个简单且人为设计的例子来说明 placement new 是如何施展其“魔法”的。这个例子只是为了演示其工作原理,并不是建议实际中这样使用(不应该照搬下面的例子!)。

假设要计算一个以空字符(null)结尾的字符串的长度,但却不记得那个可以高效完成这项任务的 C 标准库函数(也就是众所周知的 std::strlen())。

一种可以实现类似效果、但效率低得多的方式如下所示:

auto string_length(const char *p) {
  return std::string{ p }.size(); // 哎哟!但确实能用……
}

这种写法效率低下,std::string 的构造函数可能会进行内存分配。我们其实只是想统计字符数量,直到遇到第一个为零的字符(空终止符),虽然这段代码可以工作,但效率不高(注意:如果你改用 std::string_view 而不是 std::string 来执行相同的操作,性能实际上会合理得多!)。现在,假设想向朋友们展示可以将对象放置在内存中的任意位置,并利用该对象的数据成员来完成最初的目标。可以(但不应该)这样:

auto string_length(const char *p) {
  using std::string;

  // A) 创建一个大小和对齐方式都适合 string 对象的本地缓冲区
  alignas(string) char buf[sizeof(string)];

  // B) 该缓冲区中“构造”一个 string 对象
  // 注意:该对象可能会在外部分配自己的数据,但这不是我们关心的问题
  string *s = new (static_cast<void*>(buf)) string{ p };

  // C) 使用该对象来计算字符串长度
  const auto sz = s->size();

  // D) 销毁对象,但不释放缓冲区的内存
  // 缓冲区不是动态分配的,只是局部存储
  s->~string(); // yes, you can do this

  return sz;
}

这个复杂版本相比于简单版本没有好处,但它展示了进行这种底层内存管理操作时的复杂性。根据代码示例中的注释,步骤工作如下:

这是一个使用 placement new 的不良示例,但它明确且(希望是)有指导意义。第 12 章编写具有显式内存管理的容器时,将看到这一特性如何以更加合理的方式带来显著的速度优势。

关于 std::make_shared<T>(args...) 的说明

第 6 章中提到,make_shared<T>(args...) 通常会带来比 shared_ptr<T>{ new T(args...) } 更好的内存布局,至少在缓存使用方面是如此。现在,可以开始理解其中的原因。

当调用 shared_ptr<T>::shared_ptr(T*) 时,这个构造函数接管了一个已经存在的对象的管理责任,该对象的地址作为参数传入。由于该对象已经构造,shared_ptr<T> 必须单独为其分配一个引用计数器,最终导致两个独立的内存分配,很可能位于不同的缓存行中。大多数程序中,这种较差的局部性可能会在运行时引发性能下降。

另一方面,当我们调用 make_shared<T>(args...) 时,这个工厂函数负责创建一块内存,其布局能够容纳 T 类型的对象和引用计数器,并满足各自的大小和对齐要求。实现这一点的方式有很多种,包括:(a) 使用一个联合(union),其中“共存”着一对指针和一个指向包含计数器与 T 对象的内存块的单指针;(b) 使用一个适当大小和对齐的字节缓冲区,然后在该缓冲区内的合适位置对两个对象分别执行 placement new。后一种情况下,只进行一次内存分配,分配了一块足以容纳两个对象的连续内存块,并执行了两次 placement new 调用。

7.3.4 分配操作符的成员版本

有时,对某些类型在动态内存分配方面的需求,和特性有特别的了解。关于如何利用这种类型特定知识的一个完整示例,将在第 10 章中详细讲解,其中会讨论基于“arena”的内存分配机制(虽然做了简化)。

目前,仅限于介绍如何对分配操作符进行成员函数重载的语法及其效果。

接下来的例子中,假设类 X 可以从这些机制的按类定制中获益。当调用 new X 时,使用端代码会调用这些定制的分配函数,而当调用 new int 时则不会使用这些特化版本。

#include <iostream>
#include <new>

class X {
  // ...
public:
  X() { std::cout << "X::X()\n"; }
  ~X() { std::cout << "X::~X()\n"; }
  void *operator new(std::size_t);
  void *operator new[](std::size_t);
  void operator delete(void*);
  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";
  return ::operator new[](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";
  return ::operator delete[](p);
}

int main() {
  std::cout << "p = new int{3}\n";
  int *p = new int{ 3 }; // 使用全局 operator new 分配内存并构造 int

  std::cout << "q = new X\n";
  X *q = new X; // 使用 X 类自定义的 operator new 分配内存

  std::cout << "delete p\n";
  delete p; // 使用全局 operator delete 释放内存

  std::cout << "delete q\n";
  delete q; // 使用 X 类自定义的 operator delete 释放内存
}

这些重载的操作符将被派生类所继承,如果这些操作符的实现以某种方式依赖于该类特有的细节 —— 大小、对齐方式,或者其他在派生类中,可能因添加了数据成员等“看似无害”的改动,而失效的特性 —— 应该考虑将重载了这些操作符的类标记为 final。

7.3.5 对齐感知的分配操作符版本

设计 C++17 时,内存分配过程中一个根本性的问题得到了修复,这个问题与我们所说的过对齐类型(overaligned types)有关。其核心是:有些类型的对齐要求比机器上最严格自然对齐(std::max_align_t)还要更严格。我们希望为这些类型提供正确的内存分配支持。

有很多原因会导致这种更严格的对齐需求。一个简单的例子是当我们与某些专用硬件通信时,可能要求特殊的内存对齐方式,不同于普通程序所使用的规则。假设下面的 Float4 类型就是这样一个例子。其大小是 4 * sizeof(float),并且要求 Float4 必须按 64 字节边界对齐:

struct alignas(16) Float4 { float vals[4]; };

这个例子中,如果从类型声明中移除 alignas(16),那么 Float4 类型的自然对齐方式将是 alignof(float),在大多数平台上这个值很可能是 4。

C++17 之前,这类类型存在的问题是:由编译器生成的变量会遵守对齐要求,但那些位于动态分配内存中的对象默认只会具有 std::max_align_t 的对齐方式,这将导致不正确的对齐结果。因为,像 malloc() 和 operator new() 这样的函数默认会按照平台上最坏情况的对齐需求来分配内存,它们不知道分配的内存将用于构造什么类型的对象,但我们不能假设它们,能自动满足比这更严格的对齐要求。

从 C++17 开始,可以在调用 operator new() 或 operator new[]() 时通过传入一个 std::align_val_t 类型的参数,来显式指定更严格的对齐要求。std::align_val_t 是一种整数类型。这一操作必须在调用点显式完成,如下例所示:

#include <iostream>
#include <new>
#include <cstdlib>
#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_cast<std::size_t>(al), n
  );
}

// (其他省略)
struct alignas(16) Float4 { float vals[4]; };

int main() {
  auto p = new Float4; // 调用 operator new(std::size_t)
  // 调用 operator new(std::size_t, std::align_val_t)

  auto q = new(std::align_val_t{ 16 }) Float4;
  // 有内存泄漏,但这不是重点
}

为 p 分配的内存块将按照 std::max_align_t 的边界对齐,而为 q 分配的内存块将按照 16 字节边界对齐。

如果运气好,前一种对齐方式可能刚好满足我们类型的需求;否则就可能导致未定义行为(混乱的结果);而后一种方式只要 operator delete 的重载实现正确,就能严格满足我们的对齐约束。

7.3.6 销毁式删除

C++20 引入了一项新颖且高度专业化的特性,称为 “销毁式删除”(destroying delete)。目标使用场景是:某个类为其 operator delete 提供了一个成员函数重载,该重载可以利用对被销毁对象类型的特定知识,从而更高效地执行销毁过程。当为某个类型 T 定义了这种成员函数时,即使 T 还提供了其他形式的 operator delete() 重载,在对 T* 指针使用 delete 时也会优先选择 销毁式删除 版本。要为某个类型 X 使用 销毁式删除,必须实现以下成员函数:

class X {
  // ...
public:
  void operator delete(X*, std::destroying_delete_t);
  // ...
}

std::destroying_delete_t 是一种标签类型(tag type),类似于本章前面看到的 std::nothrow_t。对于类 X 的 销毁式删除 版本来说,其第一个参数是 X*,而不是 void* —— 因为 销毁式删除 具有双重角色:既要销毁对象,又要释放内存……因此得名!

它是如何工作的?为什么它如此有用?来看一个具体的例子,使用一个名为 Wrapper 的包装类。

在这个例子中,Wrapper 类型的对象隐藏了两种实现中的一种,这两种实现由 Wrapper::ImplA 和 Wrapper::ImplB 建模。具体使用哪一种实现在构造时根据枚举值 Wrapper::Kind 来选择。这样做的目的是从该类中移除对虚函数的依赖,取而代之的是基于所选实现类型的 if 语句。当然,这个(不可否认的)小例子中,仍然只使用了一个虚函数(Impl::f()),这是为了保持示例的简洁性。此外,还有一个设计目标是希望 Wrapper 类的析构函数保持为简单析构函数(trivial destructor)。

我们将逐步分析这个例子,它比之前的例子要复杂一些。首先,来看看 Wrapper 的基本结构,包括 Wrapper::Kind、Wrapper::Impl 及其派生类:

#include <new>
#include <iostream>

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;
  // ...

Wrapper::Impl 并没有定义虚析构函数,但 Wrapper 类中保留了一个 Impl* 类型的数据成员 p。如果直接调用 delete p,可能不会调用所指向对象应有的析构函数。

Wrapper 类提供了一个构造函数,该构造函数接受一个 Kind 类型的参数,然后调用 Wrapper::create() 来构造一个合适的实现对象,该实现对象由 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 } {
  }
  // ...

现在来看 销毁式删除 的使用。由于在构造时就已知唯一的可能实现只有 ImplA 和 ImplB,可以检查 p→kind 来确定 p 所指向的具体类型,然后直接调用对应的析构函数。这一步完成时,Wrapper 对象本身的生命周期也就结束了,可通过直接调用 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(); }
}

对于使用端代码来说,是否使用了 销毁式删除 完全透明:

int main() {
  using namespace std;
  auto p = new Wrapper{ Wrapper::Kind::A };
  cout << p->f() << endl;
  delete p;

  p = new Wrapper{ Wrapper::Kind::B };
  cout << p->f() << endl;
  delete p;
}

销毁式删除是在本文撰写时,C++20 引入的一项较新的 C++ 特性,是一个非常有用的工具,可以对对象的销毁过程获得更多的控制。大多数类型可能并不需要这个特性,了解它也非常有价值 —— 尤其是在需要对执行速度和程序体积进行精细优化时。与所有优化手段一样,请务必测试改动所带来的实际效果,以确保器确实能带来了预期的收益。