图 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 表达式得到的,行为未定义。崩溃、泄漏、看似正常运行,全都「合法」,而且「看似正常」恰恰最危险:对象持有的资源悄悄泄漏,问题拖到上线后才暴露。
图 2:三行配对规则、隐藏计数机制与错配推导链。
面试现场的答题结构。
先斩钉截铁给出「构造 5 次无争议,析构次数标准不保证」,然后讲隐藏计数机制解释 delete[] 为什么知道析构几次、delete 为什么不知道,接着点明未定义行为的三种现实表现——崩溃、泄漏、看似正常,最后补一句工程建议:现代 C++ 优先用 vector 和 unique_ptr 数组版,让配对由类型系统保证,连手写 delete 的机会都不留。你在项目里抓到过这类错配吗?是靠编译器警告、 sanitizer 还是线上事故发现的?欢迎聊聊。