深蓝黑背景封面。左侧上方是一块堆内存示意:四个相连的对象格子标注「对象0 对象1 对象2 对象3」,格子最左端有一个小格标注「隐藏计数」,上方箭头标注「new[]:构造函数执行 4 次」。下方是两行对照:第一行青色卡片标「正确配对 delete[]」,内容是「先读计数 → 析构 4 次 → 整块归还」;第二行紫色卡片标「错配 delete(少了一对 [])」,内容是「按单个对象处理:计数被误读、只析构 1 次甚至直接崩溃」,卡片右侧盖一枚「未定义行为」标签。最底下一行灰色小字注「常见实测表现:析构次数不够、堆元数据损坏、程序时好时坏」。右侧大标题两行「new[] 出来的数组」「用 delete 释放会怎样」,副标题「分配与释放的配对规则:构造 N 次,析构几次?」,底部小注「错配不是『少析构几次』的小错,而是标准意义上的未定义行为」。左上角标注「面试经典 · C/C++」,右上角圆角胶囊标注「Day 08 · 12-05」。 图 1:本文封面,正确配对与错配释放的对照。

先说明属性:这是一篇笔试真题复盘,题目出自本地历史面试资料整理,与任何公司的当前招聘无关。题面只有两行代码:用 new ClassA[5] 申请一个对象数组,然后用 delete(少了一对 [])释放,问构造函数和析构函数各执行几次。选项里有 5/5、5/1、1/1 好几个看起来都有道理的答案。

一句话结论:构造函数确定执行 5 次;析构次数在标准层面没有答案——数组分配配单对象释放是未定义行为,实测表现因实现而异。

先把无争议的一半拿下:new[] 一定构造 5 次。

new ClassA[5] 做两件事:向堆要一块能放下 5 个对象的内存,然后对 5 个元素逐个调用默认构造函数。这半句没有任何歧义,构造 5 次。问题的全部陷阱在释放那一半。

关键机制:分配器替你记住了「有几个对象」。

new[] 返回的内存,很多实现会在数组头部前面藏一小块附加数据,记录元素个数——这就是「隐藏计数」。delete[] 释放时先读这个计数,知道要析构 5 次,从后往前逐个析构,最后把整块内存连同计数区一起还给堆。而 delete 是为单对象设计的,它根本不知道计数的存在:它把传进来的指针当成「指向一个对象」,最多析构第一个对象,然后把指针直接还给堆分配器——可这个指针其实偏在计数区后面,分配器拿到的是错误的地址,堆元数据可能当场损坏。所以「析构 1 次」只是某些实现下的一种常见表现,不是标准承诺的结果。

用代码实测两种配对(Clang 17,g++ -std=c++17 -Wall mismatch.cpp):

#include <cstdio>
struct A {
  static int ctor, dtor;
  A()  { ++ctor; }
  ~A() { ++dtor; }
};
int A::ctor = 0, A::dtor = 0;

int main(int argc, char** argv) {
  A* p = new A[5];
  std::printf("new A[5] 之后: 构造 %d 次, 析构 %d 次\n", A::ctor, A::dtor);
  std::fflush(stdout);
  if (argc > 1) {
    delete p;      // 错配:未定义行为
  } else {
    delete[] p;    // 正确配对
  }
  std::printf("释放之后: 构造 %d 次, 析构 %d 次\n", A::ctor, A::dtor);
}

实测结果:

编译期: warning: 'delete' applied to a pointer that was allocated with 'new[]';
       did you mean 'delete[]'? [-Wmismatched-new-delete]
== 正确配对(delete[])==
new A[5] 之后: 构造 5 次, 析构 0 次
释放之后: 构造 5 次, 析构 5 次        (正常退出)
== 错配(delete)==
new A[5] 之后: 构造 5 次, 析构 0 次
进程直接崩溃(Trace/BPT trap),第二行输出没机会打印

三个信息都很值钱。其一,正确配对下构造 5 次、析构 5 次,配对规则闭环。其二,这套编译器在编译期就发出 -Wmismatched-new-delete 警告——错配在现代工具链下是能静态抓住的。其三,错配在这个实现上不是「少析构几次」,而是释放阶段直接崩溃,因为 delete 把偏移后的指针交给了分配器。

为什么标准把它定为未定义行为,而不是规定「只析构第一个」?

因为结果本来就没有可统一的合理定义:不同的实现在计数存放位置、对齐处理、分配器策略上各不相同,标准只能如实声明——指针不是从匹配形式的 new 表达式得到的,行为未定义。崩溃、泄漏、看似正常运行,全都「合法」,而且「看似正常」恰恰最危险:对象持有的资源悄悄泄漏,问题拖到上线后才暴露。

深蓝黑背景概念图。顶部一行小字「概念图 · 内存配对规则」与主标题「分配器记住了『几个对象』,释放时必须用对的钥匙」。上部是一张三行配对表:「new ↔ delete(单对象)」「new[] ↔ delete[](对象数组)」「malloc ↔ free(裸内存,无构造析构)」,每行左右两侧用双向箭头相连。中部左侧是机制示意面板:一块数组内存,最左端小格标「元素计数 N」,其后四个格子标「对象0~3」,下方两条注释分别写「delete[] 先读 N,逆序析构 N 次再归还整块」「delete 不知道 N 的存在,把数组当单个对象」。中部右侧是错配推导链,四步依次排列:① delete 只按单对象处理,隐藏计数被忽略或误读;② 析构次数与构造次数对不上(实测常只析构 1 次);③ 剩余对象的资源泄漏,堆元数据可能损坏;④ 标准定性:未定义行为,崩溃、泄漏、看似正常都合法。底部总结条写着「一句话:用什么姿势申请,就用什么姿势归还 —— new 配 delete、new[] 配 delete[]、malloc 配 free」,并附小注「现代做法:优先用容器与智能指针(如 vector、unique_ptr 数组版),让配对由类型系统保证」。 图 2:三行配对规则、隐藏计数机制与错配推导链。

面试现场的答题结构。

先斩钉截铁给出「构造 5 次无争议,析构次数标准不保证」,然后讲隐藏计数机制解释 delete[] 为什么知道析构几次、delete 为什么不知道,接着点明未定义行为的三种现实表现——崩溃、泄漏、看似正常,最后补一句工程建议:现代 C++ 优先用 vector 和 unique_ptr 数组版,让配对由类型系统保证,连手写 delete 的机会都不留。你在项目里抓到过这类错配吗?是靠编译器警告、 sanitizer 还是线上事故发现的?欢迎聊聊。