前言


new 和 delete 是 C++ 里被重载次数最多的两个关键字,也是初学者最容易混为一谈的一组概念。很多人第一次听到「重载 operator new」时会直接懵掉:new 不是一个关键字吗,关键字怎么能重载?还有人写 delete p 之后以为内存立刻还给了操作系统,于是拿它去衡量内存占用的变化,结果怎么都对不上。


问题的根源在于:同一个 new 这个拼写,在 C++ 里至少代表了四种截然不同的东西——new 表达式(new-expression)、分配函数 operator new、定位 new(placement new)语法、以及数组 new。delete 同理。它们处在不同的抽象层次上,一个调用另一个,混着讲就会晕。


本文按「谁调用谁」的顺序把这四层拆开讲,并给出可以替换全局分配函数的完整示例。文中示例以 C++17 为基准,在 GCC 13、Clang 17、MSVC 19.3x 上都应能编译通过;涉及实现细节的地方会明确标出是哪一家标准库的行为。


一、两个层次:new 表达式与 operator new 函数


先把术语分清楚。


new 表达式是语言层面的语法结构,写作 new T(args)、new T[n]。它做两件事,且顺序固定:



  1. 调用分配函数(allocation function)拿到一块原始内存;

  2. 在这块内存上构造对象(对类类型调用构造函数)。


operator new 是第 1 步里被调用的那个函数,它的职责只有「要一块内存」,跟构造函数毫无关系。标准库提供的全局版本在 <new> 里声明,签名是:


void* operator new(std::size_t size);
void* operator new[](std::size_t size);
void operator delete(void* ptr) noexcept;
void operator delete[](void* ptr) noexcept;
void operator delete(void* ptr, std::size_t size) noexcept; // C++14 起
void operator delete[](void* ptr, std::size_t size) noexcept; // C++14 起

注意最后两个带 std::size_t 参数的版本,它们叫带大小的释放函数(sized deallocation),C++14 引入。编译器在能确定对象大小时会优先调用带大小的版本。要不要真的走这条路径,取决于编译器是否开启了对应选项(GCC 在 C++14 及以上默认开启,可用 -fno-sized-deallocation 关闭;MSVC 也支持)。重载分配函数时把有大小和无大小两套一起替换,是最省心的做法。


delete 表达式同样做两件事,顺序与 new 相反:先析构,再调用释放函数(deallocation function)。


#include <iostream>
#include <string>

struct Widget {
std::string name;
explicit Widget(std::string n) : name(std::move(n)) {
std::cout << "ctor: " << name << '\n';
}
~Widget() { std::cout << "dtor: " << name << '\n'; }
};

int main() {
Widget* p = new Widget("alpha"); // operator new 拿内存 -> 构造函数
delete p; // 析构函数 -> operator delete 还内存
}

用一句话概括这一节:new 表达式 = 分配函数 + 构造函数;delete 表达式 = 析构函数 + 释放函数。分层想清楚,后面三种变体就都是同一个模板的套用了。


二、替换全局 operator new / operator delete


因为 operator new 只是一个普通函数,标准允许程序替换它的全局版本(这是标准明确许可的行为,不是 UB)。这也正是内存统计、内存池、内存泄漏检测工具的实现入口。


下面这份示例把所有分配都记一笔账,替换时用 std::malloc / std::free 而不是 new,否则会无限递归。


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

namespace memstat {
std::size_t alloc_count = 0;
std::size_t free_count = 0;
std::size_t total_bytes = 0;
}

void* operator new(std::size_t size) {
++memstat::alloc_count;
memstat::total_bytes += size;
if (void* p = std::malloc(size)) {
return p;
}
throw std::bad_alloc();
}

void* operator new[](std::size_t size) {
return ::operator new(size);
}

void operator delete(void* ptr) noexcept {
if (ptr != nullptr) {
++memstat::free_count;
std::free(ptr);
}
}

void operator delete[](void* ptr) noexcept {
::operator delete(ptr);
}

void operator delete(void* ptr, std::size_t) noexcept {
::operator delete(ptr);
}

void operator delete[](void* ptr, std::size_t) noexcept {
::operator delete(ptr);
}

int main() {
int* a = new int(7);
int* b = new int[4];
delete a;
delete[] b;
std::cout << memstat::alloc_count << ' ' << memstat::free_count << '\n';
std::cout << memstat::total_bytes << '\n';
}

逐行核对几个容易出错的地方:



  • operator new 的返回类型必须是 void*,参数必须是 std::size_t。写成 int 或 unsigned 都只是声明了一个新的普通函数,不会替换任何东西。

  • operator delete 必须声明 noexcept。这条不是风格问题:标准规定释放函数不得抛出异常,签名不一致会直接报错。

  • new[] 版本里转发到 ::operator new(size) 时,size 是元素总字节数加上可能的数组开销。数组 new 在原始内存前面额外存一份元素个数,这个「cookie」是实现细节:libstdc++ 与 MSVC STL 通常都会加,具体偏移量不保证,标准完全不管。所以不要自己算出「元素个数 × 元素大小」再去释放。

  • main 里只 delete 了两次,free_count 就是 2。operator delete 的调用次数等于 delete 表达式的次数,与分配次数未必相等,把它当成泄漏检测的充要条件是不成立的。


除了全局版本,类里也可以定义类专属分配函数(class-specific allocation function),它是一个 static 成员函数:


#include <cstddef>
#include <new>
#include <iostream>

struct Pooled {
static void* operator new(std::size_t size) {
std::cout << "Pooled::operator new(" << size << ")\n";
return ::operator new(size);
}
static void operator delete(void* ptr) noexcept {
std::cout << "Pooled::operator delete\n";
::operator delete(ptr);
}
int payload = 0;
};

int main() {
Pooled* p = new Pooled(); // 走类专属版本
delete p;
int* q = new int(1); // 走全局版本
delete q;
}

这里有个隐蔽的坑:类专属 operator new 会被派生类继承。如果基类定义了内存池版本,派生类即使什么都没写,new Derived 也会走基类那个池——而这个池很可能只按 sizeof(Base) 预留了空间。标准对此的态度是用 -Wclass-memaccess 之类的手段提醒你(不同编译器诊断不同),但从类型系统上它并不报错,需要人工确认 size 参数拿到的确实是派生类的大小。


三、定位 new 与 nothrow new


定位 new(placement new)是在「已经有一块内存」的前提下构造对象的语法,需要包含 <new>:


void* operator new(std::size_t size, void* ptr) noexcept;

它做的事只有一件:原样返回传入的指针。所以 new (buf) T(args) 的效果就是「在 buf 上调用 T 的构造函数」。


#include <cstddef>
#include <new>
#include <string>
#include <iostream>

int main() {
alignas(std::string) unsigned char buf[sizeof(std::string)];

std::string* p = new (buf) std::string("hello"); // 只构造,不分配
std::cout << *p << '\n';

p->~std::string(); // 必须手工析构
// delete p; // 错:buf 不是 new 出来的
}

三条硬规定:



  1. 这块缓冲区必须对齐正确(alignas 或 std::aligned_storage,后者在 C++23 被弃用,直接用 alignas 加字符数组更省事)、大小足够。

  2. 对象的生命周期从构造函数返回那一刻开始,到显式调用析构函数那一刻结束。周期结束后再访问就是 UB。

  3. 不能用 delete 释放它——那块内存根本不属于自由存储区(free store)。这句话对应的后果就是 UB,标准不保证任何行为。


定位 new 最常见的用途是容器实现(std::vector 内部就是拿分配器要到内存、再用定位 new 在其中逐个构造元素)和「节省一次分配」的场景。


nothrow new 是另一个变体,它不抛异常,分配失败返回空指针:


#include <new>
#include <iostream>

int main() {
int* p = new (std::nothrow) int[1024];
if (p == nullptr) {
std::cout << "allocation failed\n";
return 0;
}
delete[] p;
}

std::nothrow 是 <new> 里定义的一个 const std::nothrow_t 对象,配套的分配函数是:


void* operator new(std::size_t size, const std::nothrow_t&) noexcept;
void* operator new[](std::size_t size, const std::nothrow_t&) noexcept;

注意 nothrow new 只保证「分配这一步」不抛异常。构造函数照样可以抛,此时异常会正常传播出来。把 new (std::nothrow) T 理解成「整个 new 表达式不会失败」,是很多人踩过的坑。


四、delete 与 delete[] 为什么不能换着用


new T 必须配 delete,new T[n] 必须配 delete[],这是标准里写死的对应关系。


原因是第二节提到的数组 cookie:new T[n] 申请的内存前面(通常是前面)会额外记录元素个数 n,delete[] 需要读出 n 才能知道要析构几个对象。用 delete 去释放数组,析构只做一次,剩下 n-1 个对象的析构函数永远不会被调用;如果它们持有资源(文件句柄、堆内存、锁),泄漏就发生了。反过来用 delete[] 去释放单个对象,元素个数是垃圾值,会析构掉一片不属于你的内存。


这两条都是 UB,标准不保证任何行为。它「有时看起来正常」,是因为对平凡可析构(trivially destructible)的类型编译器不做任何事,于是错误被掩盖过去,等到换成 std::string 成员才炸。


下面这张表把四种形态收在一起:




写法分配函数是否构造对象配套释放备注



new T(args)operator new(size)是delete p失败抛 std::bad_alloc

new T[n]operator new[]是,n 次delete[] p可能带数组 cookie

new (std::nothrow) Toperator new(size, nothrow_t)是delete p只有分配失败返回空

new (ptr) T(args)operator new(size, void*)是手工 p->~T()不分配内存,不能 delete



delete nullptr 是合法的、什么都不做,所以析构函数里写 delete p_; 之后不必再判空。而 delete 一个非空但无效的指针(比如已经 delete 过一次的、或者指向栈上的)是 UB。


常见坑点


1. new/delete 与 malloc/free 混搭


// ❌ 配对错误,UB
Widget* p = new Widget("a");
std::free(p);

// ✅ 谁分配谁释放,成对出现
Widget* p = new Widget("a");
delete p;

new 和 malloc 不是一层的东西:new 会调构造函数,malloc 不会;free 不会调析构函数,delete 会。混用一律 UB。


2. 数组 delete 写错


// ❌ 少析构 n-1 个对象,UB
Widget* arr = new Widget[5];
delete arr;

// ✅ 数组就用 delete[]
delete[] arr;

3. 类专属分配函数被继承导致越界


// ❌ Base 的内存池按 sizeof(Base) 预留,Derived 更大
struct Base { static void* operator new(std::size_t n); };
struct Derived : Base { double extra[16]; };
Derived* d = new Derived(); // 走 Base 的池,可能不够

// ✅ 派生类里重新声明自己的分配函数,或改用虚工厂
struct Derived2 : Base { static void* operator new(std::size_t n); };

4. 定位 new 之后用 delete


// ❌ buf 不在自由存储区,UB
alignas(Widget) unsigned char buf[sizeof(Widget)];
Widget* p = new (buf) Widget("x");
delete p;

// ✅ 手工析构
p->~Widget();

5. 缓冲区对齐不够


// ❌ char 数组默认对齐是 1,不满足 Widget 的对齐要求
char buf[sizeof(Widget)];
Widget* p = new (buf) Widget("x");

// ✅ 用 alignas 拉齐
alignas(Widget) char buf[sizeof(Widget)];

6. 重载 operator new 时用了 new 本身


// ❌ 无限递归,栈溢出
void* operator new(std::size_t n) { return new char[n]; }

// ✅ 底层用 malloc 或 ::operator new 的原始接口
void* operator new(std::size_t n) { return std::malloc(n); }

7. 忽略 sized deallocation 导致统计对不上


// ❌ 只替换无大小版本,开了 sized deallocation 的编译器可能走库里的带大小版本
void operator delete(void* p) noexcept;

// ✅ 两套都替换,行为一致
void operator delete(void* p) noexcept;
void operator delete(void* p, std::size_t) noexcept;

8. 构造函数抛异常时以为内存一定泄漏


// ❌ 手工 try/catch 去释放,其实语言已经保证会调用匹配的 operator delete
Widget* p = nullptr;
try { p = new Widget("x"); } catch (...) { /* 内存已由运行库释放 */ }

// ✅ new 表达式自身负责释放,只需要管好自己 new 出来的东西

new 表达式里如果构造函数抛异常,标准要求自动调用匹配的释放函数回滚内存。例外是定位 new 形式:如果没有找到匹配的定位释放函数,则什么也不调用(那块内存本来也不归你管)。


总结




概念层次关键事实



new 表达式语言语法分配 + 构造两步;构造抛异常会自动回滚

operator new / operator delete可替换函数只管内存,不管生命周期;签名必须精确

定位 new语法 + 重载函数不分配内存,必须手工析构,不能 delete

nothrow new语法 + 重载函数只有分配失败返回空指针,构造异常照旧抛出

数组 new / delete[]语言语法与单对象版本严格配对,否则 UB



把 new 拆成「分配」和「构造」两件事之后,四种形态其实就是同一套规则的排列组合:谁负责要内存、谁负责构造、谁负责析构、谁负责还内存。写代码时只要盯住这四问,new 与 delete 的配对错误基本可以杜绝;而涉及替换分配函数、手写内存池的场合,务必把有大小和无大小两套释放函数同时提供,并且永远不要在 operator new 内部再用 new。




Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐