第18.7章 类级 operator new 与 operator delete
1. 当一个类拥有自己的分配
类可以通过声明一个名为 operator new 的静态成员,
替换作用在它自己身上的 new 所使用的分配。
当你写 new T(...) 时,编译器先找 T::operator new,
只有类里没有时才退回全局那个。
你必须想清楚的一整套:
| 形式 | 被谁调用 | 需要吗 |
|---|---|---|
static void *operator new(::std::size_t) |
new T |
要,只要你写了其中任何一个 |
static void operator delete(void *) |
不带大小的 delete p |
要 —— 缺了它,全局那个会被用来释放你自己分配的内存 |
static void operator delete(void *, ::std::size_t) |
delete p(C++14 起的带大小释放) |
要 —— 编译器通常调用的就是它 |
static void *operator new[](::std::size_t) |
new T[n] |
只有当你也想给数组用池时 |
static void *operator new(::std::size_t, void *) |
定位 new (p) T |
永远不要:全局的定位形式不允许被替换 |
不匹配是经典缺陷:一个类声明了 operator new 却忘了
operator delete,于是它的内存从你的池里来、却还给 C++ 的堆。
那是堆破坏,没有任何诊断,通常表现为一秒之后某个不相干的分配里崩溃。
2. 一个完整正确的例子
#include <fast_io.h>
#include <cstddef>
#include <cstdint>
#include <cstdlib>
struct pooled
{
::std::int_least32_t v{};
static void *operator new(::std::size_t n) noexcept
{
void *p{::std::malloc(n)};
if (p == nullptr)
{
::std::abort();
}
return p;
}
static void operator delete(void *p, ::std::size_t) noexcept
{
::std::free(p);
}
static void operator delete(void *p) noexcept
{
::std::free(p);
}
};
int main()
{
try
{
::fast_io::io::println("sizeof(pooled)=", sizeof(pooled));
pooled *p = new pooled{};
p->v = 7;
::fast_io::io::println("v=", p->v);
delete p;
::fast_io::io::println("done: no counter, no null, no bad_alloc");
}
catch throws (::std::error e)
{
::fast_io::io::perrln("error: ", e);
}
}
sizeof(pooled)=4
v=7
done: no counter, no null, no bad_alloc
要注意五件事,每一件都是一条规则:
- 分配失败就调用
::std::abort()。 不是nullptr,不是::std::bad_alloc。 没有返回值可以让调用者忽略,没有半初始化对象会泄漏, 你程序附近也没有任何遗留异常。第3节给出完整论证。 sizeof(pooled)仍然是 4。operator new是静态函数,不给对象增加任何东西。分配策略不是状态。- 析构函数仍然是你的析构函数。
delete p先跑~pooled(),再跑pooled::operator delete,顺序如此。 - 两个
operator delete重载都需要, 因为编译器在知道大小时会传、不知道时不传 (数组、多态删除、某些库代码路径)。 - 不做任何记账。不计数、不追踪。 如果你想知道存在多少个对象,那是诊断构建的工作, 用消毒器或链接期插桩去做,而不是一个跟着类一起发布出去的静态数据成员。
3. 分配失败就是终止
堆分配失败必须终止程序。不是“被处理”、不是“被报告”、不是“被重试”: 而是终止,就地终止,就在内存耗尽的那个地方。这一页之所以存在, 就是因为 C++ 选了相反的做法。
把堆分配失败做成异常,是 C++ 历史上最大的错误之一。
想想这门语言承诺了什么:拿不到内存是一个调用者能从中恢复的异常情形,
它通过一条带类型的通道传播,而整个程序的不变量在它穿过之后仍然成立。这些统统不成立。
栈耗尽是未定义行为,所以这两种失败模式不可能都被“处理”。
从分配失败里退栈会跑析构函数,而析构函数又会分配。
你下面的库本来就直接 abort:glibc 的 xmalloc 不返回空,它直接死。
在 Linux 上,OOM killer 会在 malloc 返回空之前先到。
而且程序里每个 new 都变成一条能离开任何函数的看不见的控制流边,
这就是那些看起来根本没有错误处理的代码里异常安全 bug 的来源。
所以我们要说 1998 年就该说的那句话:分配失败就 abort。 真发生了,程序就结束了,诊断就是一份 core dump,而这是诚实的结果。 其他做法全是演戏,代价是每个调用者一次检查、每个函数一条看不见的出口。
内存不在时,一个分配函数总共只有四种做法,而其中只有一种值得写:
| 它做什么 | 结论 |
|---|---|
抛 ::std::bad_alloc |
语言的默认行为。一个 1998 年的遗留异常,在赫布异常程序里会以
[itanium_exception](St9bad_alloc) 的形式被拖进来
(第18.1章) |
返回 nullptr |
永远不要。它之所以合法,只是因为这个函数是
noexcept 的,而这时 new T 得到的是一个空指针:
每个调用者都必须检查,而没有调用者会检查,对象也从未被构造 |
::std::nothrow / ::std::nothrow_t |
永远不要 —— 见下 |
::std::abort() |
这一个 |
为什么 ::std::abort() 才是正确答案
来自 Portable-Cpp-Guideline 的五个理由,对一个不是内核的程序全都成立:
- 析构函数可能再次分配。从分配失败里退栈会跑析构函数, 而一个会分配的析构函数也可能失败,于是“处理它”这条路是一个没有保证出口的循环。
- libsupc++ 本来就有紧急堆,而即使有它,也有很多路径最终走到
::std::terminate。把紧急堆删掉试过,可观察行为没有任何变化。 - 你下面的库也不处理它。glibc 调
xmalloc, 而malloc失败时它就 abort。你没法让自己的程序的失败故事比 C 库更优雅。 - Linux 会 overcommit。在
malloc返回空之前, 你通常已经先被 OOM killer 杀掉了,所以你精心写下的那个分支, 在你真正发布的平台上就是死代码。 - 程序里每个
new都是一条看不见的控制流边 (第18.1章); 把分配失败做成致命的,一次性把这些边全部消掉。
指南还明确给出一个一致性论证:在 C++ 里栈耗尽是未定义行为,而堆耗尽不是。 这两种失败模式不可能都被“处理”,所以把一个当成致命的、另一个当成可恢复的,是演戏。
永远不用 ::std::nothrow
::std::nothrow 看着像中间路线,其实不是:libstdc++ 的 nothrow
operator new 就是在 try/catch(...) 里调用会抛的那个,
任何异常都返回空:
_GLIBCXX_WEAK_DEFINITION void *
operator new (std::size_t sz, const std::nothrow_t&) noexcept
{
__try
{
return ::operator new(sz);
}
__catch (...)
{
return nullptr;
}
}
所以 ::std::nothrow 对“会不会抛”什么保证都没有 —— 抛还是抛了,只是替你接住了 ——
而标准不允许你像替换 operator new() 那样替换
operator new(std::nothrow_t const&)。
它没有任何好处,还少了一样你能控制的东西。不要用它,也不要把它传给
new:如果你要一条不会失败的分配路径,就写 malloc 并检查它;
而对失败你又做不了任何有意义的事,就调用 ::std::abort()。
4. 为什么你不该这么做
- 它把一个设计决定藏进了类型里。调用者写
new T, 看不见、选不了、也覆盖不了内存从哪来 —— 而且传不进分配器。 一个工厂函数make_pooled(...),或者一个接收分配器的容器, 把同一个决定放在调用点可见的地方,而决定就该在那儿。 - 它对标准容器不可见。
::fast_io::vector<pooled>不会调用pooled::operator new;它问自己的分配器,而分配器问全局堆。 所以你自以为加上的“池化”恰好只作用于你手写new的那些对象 —— 而我们已经说过, 那些对象你根本不该写。 - 它把 ABI 焊死了。如果库里的
operator new变了, 每个调用者都必须重编,因为分配和释放不再来自同一个地方。 - 它和多态删除配合得很糟。类级
operator delete只有在delete表达式所用静态类型拥有它时才会被找到。 通过没有虚析构的基类指针去 delete,你得到的是基类的operator delete—— 或者全局那个 —— 作用在你派生类分配的、属于你自己的内存上。 - 你现在得测试它。分配 bug 是最难归因的一类, 所以你一写自定义分配器,就拥有了一类新的缺陷,而且没有任何消毒器能把它讲清楚。
5. 那该做什么
| 目标 | 类 operator new |
更好的做法 |
|---|---|---|
| 让某个热点类型分配得更快 | static void *operator new(size_t) |
一个传给容器的池对象,或者一个区域类型 (第14.1章) |
| 分配时不抛异常 | 返回空的 operator new,或者 ::std::nothrow |
在你自己的类型里用 malloc,失败时
::std::abort(),而真正算错误的东西用
throw throws 报告
(第18.6章) |
| 批量生命周期 | 一个泄露的 operator delete |
一个带析构函数的区域类型,一次调用释放全部 |
| 诊断 | 在 operator new 里放计数器 |
测试构建里给 ::operator new 做一个链接期插桩,或者用消毒器 |
6. 经验法则
- 不要写类级
operator new。如果你需要分配策略,就把它传进去: 分配器参数、池对象、区域。 - 如果你写了,就在同一个类里、同一次修改里、用同一个存储来源,
把两个
operator delete重载都写上。 - 让
operator new是noexcept,并在malloc返回空时调用::std::abort()。 永远不要从分配函数返回nullptr,也永远不要用::std::nothrow。 - 永远不要混:来自
malloc的内存还给free, 来自new的内存还给delete, 而类级operator new必须与类级operator delete配对。 - 优先在你自己写的工厂函数里用
malloc加定位new。代码量一样,它在调用点可见,而且不会被意外继承。