第20.7章:默认堆存储:new 与 delete

English

1. 这个特性

new 不是一个操作,而是三个,而源码里只看得见其中一个:向一个全局分配函数要内存、在那块内存里运行构造函数、决定分配函数失败时怎么办。delete 是镜像:运行析构函数、归还内存。中间那步是你的。第一步和最后一步属于一个单一的、全局的、看不见的对象:::operator new,整个进程一个堆。

P1105R0 第 4.4 节"Default heap storage" 提议在没有堆的目标上把这一对做成可选的,对应特性测试宏 __cpp_freestanding_no_default_heap —— 这说明委员会怎么看它:一个独立构建可以关掉的特性,而不是"目标要么有要么没有"的东西。

2. 实测:new 对环境提出了什么要求

一次看起来微不足道的堆分配,编译成目标文件,然后用 -nostdlib 链接:

#include <new>

struct S { int v; };

S *make(int x) noexcept(false) { return new S{x}; }

int main() {
  S *p = make(7);
  int r = p->v;
  delete p;
  return r;
}
$ g++ -std=c++26 -O2 -c nd.cc && nm -u nd.o
                 U _Znwm
$ g++ -std=c++26 -O2 -nostdlib nd.cc -o nd
  undefined reference to `operator new(unsigned long)

一个符号,而且它必须来自某个地方。在宿主系统上它来自 libc,libc 从 sbrk/mmap 拿到内存,那就是内核。在独立目标上,没有人写它。现在对比 placement new,也就是 new 里不分配的那部分:

#include <new>
struct S { int v; };

int f(int x) noexcept {
  alignas(S) unsigned char buf[sizeof(S)];
  S *p = new (buf) S{x};
  return p->v;
}
$ g++ -std=c++26 -O2 -nostdlib -c pn.cc -o pn.o && nm -u pn.o
  [0 undefined symbols]

零。Placement new 是纯语言特性:在一个你已经拥有的地址上构造对象。它是独立程序在没有堆的情况下唯一能用的 new 形式,也是 19.9 分配器模型所构建其上的形式。

3. 这堆乱账,分五部分

即使目标确实有内存,new 也是语言里设计得最糟的部分,而独立实现只是理由之一。

  1. 一个全局堆,替你选好了。::operator new 有且只有一个。替换它就替换了进程里每个库的堆,包括你控制不了的那些。
  2. 失败策略是不可见的。分配失败时是抛 bad_alloc、终止进程、还是返回 nullptr,取决于你调的是哪个重载以及你的程序怎么构建的。签名里没有任何东西说明这一点(19.2 论证终止进程是唯一正确的策略)。
  3. 它是一个绑定到链接期符号的语言关键字。你没法把一个堆传给一个用了 new 的函数。这个函数要么从全局堆分配,要么不分配。
  4. 虚析构函数会悄悄要求 operator delete。P1105R0 说得很直接:类的 vtable 指向一个 deleting destructor,它调用 ::operator delete,所以仅仅拥有一个虚析构函数,就给了那些从不调用 new 的独立用户一份负担。一个 hello world 接口就是这样最终依赖上一个堆的。
  5. 数组、对齐和 nothrow 把它们又乘了一遍。new T[n] 需要一个数组 cookie 和 operator new[];过对齐的类型需要 aligned 重载;std::nothrow 是同一件事的第四个变体(类级别的版本见 18.7)。

4. 而且就算它干净,一个堆也仍然不够

这是 C++ 程序员通常忽略、而内核程序员从不忽略的部分。内核没有"那个"堆。它按内存 zone 有堆、按 NUMA 节点有堆、按带 DMA 约束的设备有堆,还有生命周期与记账方式都不同的各种池。选择一次分配来自哪个堆是一个语义决定,不是性能决定,而它无法用一个全局来表出。

所以问题不是"new 危不危险",而是"分配来源如何出现在类型系统里"。下面就是反过来做的方式。两个 arena、两个容器、一个程序 —— 分配器是模板实参,所以每个容器只能用它自己的那个堆:

#include <fast_io.h>
#include <fast_io_dsal/vector.h>

template <::std::size_t extent>
class arena {
public:
  alignas(::std::max_align_t) static inline ::std::byte storage[extent]{};
  static inline ::std::size_t used{};

  static ::fast_io::allocation_least_result
  allocate_aligned_conditional_zero_at_least_die(::std::size_t alignment,
                                                 ::std::size_t n,
                                                 bool zero) noexcept {
    ::std::size_t const start{(used + alignment - 1u) / alignment * alignment};
    if (start + n > extent) { ::fast_io::fast_terminate(); }
    used = start + n;
    if (zero) {
      for (::std::size_t i{}; i != n; ++i) { storage[start + i] = ::std::byte{}; }
    }
    return {storage + start, n};
  }
  static void deallocate(void *) noexcept {}
  static void deallocate_aligned_n(void *, ::std::size_t, ::std::size_t) noexcept {}
};

using heap_a = ::fast_io::generic_allocator_adapter<arena<4096>>;
using heap_b = ::fast_io::generic_allocator_adapter<arena<8192>>;
a from A true b from B true
a from B false b from A false
A used 144 of 4096
B used 144 of 8192

两个容器谁也够不到对方的 arena:它们是不同的类型,而分配器是类型的一部分。图里没有全局堆、没有 ::operator new、没有运行期配错的可能,而失败策略写在 arena 里 —— 这里是 fast_terminate(),或者如果 arena 用了 _try 口味,就是一个赫布异常。

5. 应该怎样,以及本书怎么做

P1105R0 提议的规则是对的:在一个没有默认堆的系统上,可替换分配函数就不提供,用了 new 的程序在链接期失败 —— 在编译时失败,而不是凌晨三点在板子上失败。它还说明了替代方案为什么不行:一个立刻抛 bad_alloc 的 ::operator new "会把整套异常处理机制拖进来",而那正是你想避免的那笔交易。

new / deletefast_io 分配器
哪个堆一个全局的,链接期选定一个模板实参,按容器选定
失败策略取决于重载和构建方式分配器类型里的一个标志(19.9)
一个函数能否从它没被给予的堆里分配?能不能
独立实现没有默认堆(P1105R0)能,上面那个 arena 编译后外部符号为零
能否按子系统替换只能替换那个全局实例化另一个类型即可

要带走的规则:永远不要在自己的代码里写 new,也不要设计在调用者背后分配的接口。当你需要存储时,说清它来自哪里 —— 一个缓冲区、一个 arena、一个分配器类型 —— 程序就能在一块板子上、在内核里、在桌面上不改一行地工作。