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表达式做了两件事:

  1. 分配原始内存 —— 调用 operator new 函数
  2. 在内存上构造对象 —— 调用构造函数

delete也是两步:

  1. 调用析构函数 —— 销毁对象
  2. 释放内存 —— 调用 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表达式执行时,顺序是固定的:

  1. 调用析构函数(销毁对象)
  2. 调用operator delete(释放内存)

这个顺序很重要。你想想看,如果先释放内存再调用析构函数,析构函数访问的成员变量已经不存在了,那不就崩了吗?

还有一个容易忽略的点:析构函数抛出异常。C++标准规定,析构函数默认是noexcept的。如果析构函数抛异常,而delete又在调用它,那程序会直接terminate。我曾经在一个项目里看到有人故意在析构里抛异常,说是"清理失败要通知调用方"——结果程序直接挂了。嗯,这是个反面教材。

class BadDesign {
public:
    ~BadDesign() {
        throw std::runtime_error("清理失败");  // 别这么干!
    }
};

// 调用delete时,程序会终止
delete ptr;  // 析构抛异常 → terminate()

最佳实践:析构函数永远不要抛出异常。如果清理操作可能失败,要么吞掉异常,要么提供单独的清理函数让调用方显式调用。

4.5 知识体系总览

下面这张图总结了new/delete的完整流程和各个重载点:

new/delete 完整流程与重载点 new 表达式 步骤1: operator new(size) 分配内存 步骤2: 调用构造函数 delete 表达式 步骤1: 调用析构函数 步骤2: operator delete(p) 释放内存 可重载点 ① 全局 operator new / operator delete ② 类专属 static operator new / operator delete ③ placement new(不分配内存,只构造) ④ nothrow 版本:new (std::nothrow) Widget 对应

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看似简单,但背后涉及的内存分配、对象构造、异常安全等问题,每一个都值得深入理解。希望你能从这些实战经验中有所收获。

可可精品资料库