4. new和delete运算符:从内存分配到对象生命终结
说实话,很多C++程序员用了好几年new和delete,却从来没想过它们背后到底干了什么。我刚开始学的时候也觉得,new不就是分配内存吗?delete不就是释放内存吗?嗯,事情远没那么简单。
今天我们就来扒一扒new和delete的底裤。我会结合自己踩过的坑,带你彻底搞懂这几个问题:new表达式到底走了几步?operator new能不能自己写?placement new是什么黑科技?delete和析构函数又是怎么配合的?
4.1 new表达式的工作流程:两步走,一步都不能少
先看一个最简单的例子:
class Widget {
public:
Widget() { std::cout << "构造\n"; }
~Widget() { std::cout << "析构\n"; }
};
Widget* p = new Widget; // 这行代码干了什么?
delete p; // 这行又干了什么?
很多人以为new就是分配内存。其实不是。new表达式做了两件事:
- 分配原始内存 —— 调用
operator new函数 - 在内存上构造对象 —— 调用构造函数
delete也是两步:
- 调用析构函数 —— 销毁对象
- 释放内存 —— 调用
operator delete函数
核心理解:new表达式 ≠ operator new。前者是语言关键字,后者是可重载的函数。new表达式内部调用了operator new来获取内存,然后调用构造函数。
我在项目中遇到过一个问题:有个同事重载了operator new,但忘了在构造函数里处理异常安全。结果构造函数抛异常时,内存泄漏了。嗯,这个坑我们后面细说。
4.2 operator new全局重载:自己掌控内存分配
为什么需要重载operator new?说白了,就是你想自定义内存分配策略。比如:
- 使用内存池,避免频繁调用malloc
- 统计内存使用情况
- 在特定内存区域分配(比如共享内存)
全局重载的签名长这样:
// 全局重载 operator new
void* operator new(std::size_t size) {
std::cout << "分配 " << size << " 字节\n";
void* p = std::malloc(size);
if (!p) throw std::bad_alloc();
return p;
}
void operator delete(void* p) noexcept {
std::cout << "释放内存\n";
std::free(p);
}
你想想看,一旦你写了全局重载,整个程序里所有new都会走你的分配逻辑。这影响面太大了,我个人建议除非你很清楚自己在做什么,否则别轻易动全局的。
警告:全局重载operator new会影响所有使用new的代码,包括标准库内部。我曾经见过一个团队全局重载后,std::vector的分配也走了自定义逻辑,结果和STL的分配器策略冲突,导致诡异的崩溃。调试了整整两天。
更常见的做法是类专属重载:
class Widget {
public:
static void* operator new(std::size_t size) {
std::cout << "Widget专属分配\n";
return std::malloc(size);
}
static void operator delete(void* p) noexcept {
std::cout << "Widget专属释放\n";
std::free(p);
}
};
这样只有 new Widget 才会走你的逻辑,不影响其他类型。
4.3 placement new:在已有的内存上构造对象
placement new 是个很有意思的东西。它不分配内存,只负责在指定地址上调用构造函数。
#include <new> // 需要这个头文件
void* buffer = std::malloc(sizeof(Widget));
Widget* pw = new (buffer) Widget(); // placement new
// 注意:这里没有分配内存,只是在buffer上构造
为什么要用placement new?我举两个实际场景:
- 内存池:预先分配一大块内存,然后反复在上面构造/析构对象,避免频繁malloc/free
- 共享内存:在mmap出来的共享内存区域构造对象
使用placement new时,绝对不能用delete释放,因为delete会调用operator delete释放内存,而这块内存不是你分配的。正确的销毁方式是:
pw->~Widget(); // 显式调用析构函数
std::free(buffer); // 释放原始内存
技巧:我习惯把placement new和显式析构调用封装成工具函数,避免手写时忘记某一步。比如写一个 constructAt 和 destroyAt 模板函数。
4.4 delete与析构函数的配合:谁先谁后?
delete表达式执行时,顺序是固定的:
- 调用析构函数(销毁对象)
- 调用operator delete(释放内存)
这个顺序很重要。你想想看,如果先释放内存再调用析构函数,析构函数访问的成员变量已经不存在了,那不就崩了吗?
还有一个容易忽略的点:析构函数抛出异常。C++标准规定,析构函数默认是noexcept的。如果析构函数抛异常,而delete又在调用它,那程序会直接terminate。我曾经在一个项目里看到有人故意在析构里抛异常,说是"清理失败要通知调用方"——结果程序直接挂了。嗯,这是个反面教材。
class BadDesign {
public:
~BadDesign() {
throw std::runtime_error("清理失败"); // 别这么干!
}
};
// 调用delete时,程序会终止
delete ptr; // 析构抛异常 → terminate()
最佳实践:析构函数永远不要抛出异常。如果清理操作可能失败,要么吞掉异常,要么提供单独的清理函数让调用方显式调用。
4.5 知识体系总览
下面这张图总结了new/delete的完整流程和各个重载点:
4.6 避坑指南:我踩过的那些坑
最后分享几个实战中容易翻车的地方:
- 数组形式的new/delete要配对:
new[]必须配delete[],否则行为未定义。我见过有人用delete释放new[]分配的内存,结果只析构了第一个对象,后面的内存泄漏了。 - placement new 不要用 delete 释放:前面强调过了,这是新手最容易犯的错误。
- 重载 operator new 时别忘了处理失败情况:标准要求分配失败抛
std::bad_alloc,如果你返回nullptr而不抛异常,调用方会以为分配成功。 - 析构函数不要抛异常:这个也提过了,但值得再说一遍。C++11之后析构默认noexcept,你硬要抛的话,程序直接终止。
个人建议:除非你有明确的性能需求(比如高频分配的小对象),否则尽量用智能指针管理生命周期,少手写new/delete。现代C++里,裸new/delete应该只出现在底层内存管理代码中。
好了,这一章的内容就到这里。new和delete看似简单,但背后涉及的内存分配、对象构造、异常安全等问题,每一个都值得深入理解。希望你能从这些实战经验中有所收获。