在C++中,使用new[]动态分配对象数组时,编译器需要额外记录数组的长度,以便在调用delete[]时能够正确调用每个元素的析构函数。如果错误地使用了不带方括号的delete,运行时无法得知数组大小,就会跳过大部分析构函数,造成资源泄漏,甚至直接导致堆损坏。这是动态对象数组管理中最基础也最容易犯的错误。

一、new[]与delete[]必须严格配对
分配单个对象使用new和delete,分配对象数组则必须使用new[]和delete[],这两组运算符不能混用。当你写出Demo* arr = new Demo[5];时,编译器会向堆请求一块足以容纳5个Demo对象的内存,并且通常会在对象内存之前额外分配一段头部空间,用来保存数组中的元素数量。正是这段隐藏的头部信息,让delete[]能够准确知道需要按逆序调用多少次析构函数。
如果误用了delete arr;而不是delete[] arr;,运行时会按照单个对象的方式释放内存:它只对第一个元素调用析构函数,并且释放的起始地址可能与原始分配地址不一致,因为头部信息的存在导致实际分配指针和指向第一个对象的指针存在偏移。这种未定义行为在简单类型如int上可能暂时看不出问题,但对于持有文件句柄、锁或网络连接的对象,立刻就会造成资源泄漏,严重时会引起堆损坏和程序崩溃。
#include <iostream>
#include <string>
class ResourceHolder {
public:
ResourceHolder() { std::cout << "构造资源\n"; }
~ResourceHolder() { std::cout << "释放资源\n"; }
};
int main() {
ResourceHolder* arr = new ResourceHolder[3];
delete arr; // 错误:应按delete[] arr
return 0;
}
上面的代码中,delete arr只调用一次析构函数,另外两个对象的资源不会被释放。要想正确管理数组,必须始终让new[]和delete[]成对出现,并且在代码审查中把这种配对关系当作硬性规则。注意,编译器不会帮你检查这种配对错误,因为裸指针丢失了数组长度信息。
二、构造函数与析构函数的调用顺序
使用new[]创建对象数组时,构造函数会按照下标从0到N-1的顺序依次调用。每个元素在构造完成后,数组中的对象才算完整存在。相应地,调用delete[]释放数组时,析构函数会按照相反的顺序——从最后一个元素到第一个元素——依次执行。这种逆序销毁方式与栈对象的行为一致,可以保证后创建的对象先销毁,避免对象之间相互依赖时出现悬挂引用。
下面的例子通过一个带编号的类来观察调用顺序。构造函数接收到编号后打印自己,析构函数也打印编号,从输出中可以清晰看到构造是正序、析构是逆序。
#include <iostream>
class Numbered {
int id;
public:
Numbered(int i) : id(i) {
std::cout << "构造元素 " << id << "\n";
}
~Numbered() {
std::cout << "析构元素 " << id << "\n";
}
};
int main() {
Numbered* arr = new Numbered[4] { Numbered(0), Numbered(1), Numbered(2), Numbered(3) };
delete[] arr;
return 0;
}
输出结果为构造0、构造1、构造2、构造3,然后析构3、析构2、析构1、析构0。理解这个顺序对于排查资源释放问题很有帮助。例如某个元素持有其他元素的裸指针时,如果析构顺序不是逆序,被指向的节点可能提前销毁,导致指针悬空。delete[]正好保证了后构造的节点先析构,降低了这种风险。不过即便有逆序保证,仍然不建议在对象之间保存裸指针,使用智能指针或索引替代裸指针才能从根本上提高安全性。
另外需要强调,如果类没有自定义析构函数,编译器会生成一个默认析构函数,此时逆序调用的成本仍然存在,但通常可以忽略。对于内置类型如int、double,没有析构函数调用,delete[]与delete在释放内存上的差异可能不明显,但这并不代表可以混用,因为编译器在实现new[]时可能采用了不同的内存分配策略。
三、异常安全与内存泄漏防范
动态分配对象数组时,任何一个元素的构造函数抛出异常,都会导致已经成功构造的元素被自动销毁,但分配到的原始内存不会自动归还给操作系统。例如Demo* arr = new Demo[10];如果第5个Demo的构造函数抛出异常,前4个Demo的析构函数会被调用,但整个数组占用的内存块仍然处于泄漏状态,程序拿不到指向这块内存的有效指针,无法手动释放。
要应对这种异常安全问题,常见做法是使用RAII包装类,在构造函数中记录指针,在析构函数中调用delete[]。如果new[]抛出异常,包装类对象尚未构造成功,不会调用析构函数,此时可以利用try-catch在捕获异常后手动释放已经分配的部分。更推荐的做法是直接用标准库容器std::vector,它在内部处理了异常安全和内存释放,即使元素构造抛异常,vector也会保证不会泄漏内存。
#include <iostream>
#include <stdexcept>
class MayThrow {
int id;
public:
MayThrow(int i) : id(i) {
if (i == 3) throw std::runtime_error("构造失败");
std::cout << "成功构造 " << id << "\n";
}
~MayThrow() {
std::cout << "析构 " << id << "\n";
}
};
int main() {
try {
MayThrow* arr = new MayThrow[5] { 0, 1, 2, 3, 4 };
delete[] arr;
} catch (const std::exception& e) {
std::cout << "捕获异常: " << e.what() << "\n";
}
return 0;
}
上面的代码中,元素3构造失败后,元素0、1、2会被自动析构,但原始内存块泄漏了。要想避免泄漏,可以把数组指针保存在一个自定义的ArrayGuard类中,该类的构造函数接受指针,析构函数调用delete[]。当new[]表达式本身抛出异常时,ArrayGuard尚未接管指针,此时不需要额外处理;而当元素构造抛出异常时,ArrayGuard已经构造完成,它的析构函数会在栈展开时执行delete[],这样就能安全释放内存。不过这种手动RAII方式容易出错,生产环境应当优先使用std::vector或std::unique_ptr<T[]>。
此外,异常安全还涉及析构函数自身不应抛出异常。如果delete[]调用某个元素的析构函数时抛出了异常,整个释放过程会被打断,剩余元素的析构函数不会执行,造成资源泄漏。标准建议是将析构函数声明为noexcept(true),确保释放过程不会中断。
四、用std::vector和智能指针代替裸数组
现代C++中,直接使用new[]和delete[]管理动态对象数组已经不是推荐做法。std::vector提供了动态数组的所有能力,包括自动扩展、随机访问、异常安全以及作用域结束自动释放。它内部维护了元素数量,不需要你记住配对规则,也避免了delete和delete[]混用的隐患。当需要动态对象数组时,直接使用std::vector<Demo>是最自然的选择。
如果确实需要手动控制对象生命周期,或者需要与C接口交互,可以使用std::unique_ptr<T[]>。它专门为数组形式提供了删除器,在unique_ptr析构时会自动调用delete[]。这样即使发生异常,内存也会被正确回收。例如:
#include <memory>
#include <iostream>
class Demo {
public:
Demo() { std::cout << "构造\n"; }
~Demo() { std::cout << "析构\n"; }
};
int main() {
std::unique_ptr<Demo[]> arr(new Demo[3]);
// 无需手动delete,作用域结束时自动调用delete[]
return 0;
}
std::unique_ptr<T[]> 的模板参数带方括号,表明它管理的是数组,析构时会调用delete[]而不是delete。如果误写成std::unique_ptr<Demo>来接收new Demo[3],虽然编译可以通过,但删除时会调用delete而不是delete[],同样会引发未定义行为。因此使用智能指针时也要注意模板参数中的方括号必须与分配形式匹配。
与裸指针相比,std::vector和智能指针还能让代码更好地表达所有权意图。裸指针既可能指向单个对象也可能指向数组,阅读代码的人必须依赖上下文判断;而std::vector或unique_ptr<T[]>在类型系统中就明确了容器性质。尤其在大型项目中,优先使用标准库容器可以显著降低内存管理相关的缺陷数量。只有在对性能极度敏感且需要避免容器开销的底层代码中,才考虑手动管理动态数组,并且需要经过严格的代码审查和单元测试。
回到最初的问题:动态对象数组分配和释放需要注意的核心就是配对使用new[]和delete[]、理解构造析构顺序、防范异常导致的内存泄漏,并尽可能采用现代C++提供的容器和智能指针来替代裸数组。掌握这些规则后,动态数组不再是隐患,而只是工具箱中一个可控的选择。