各位同学,大家好!欢迎来到我们今天的技术讲座。我是你们的老朋友,一名在编程领域摸爬滚打了多年的实践者。今天,我们将一起深入探索C++中一个至关重要但又充满挑战的领域:动态内存管理。我们将手把手地学习如何运用 new 和 delete 这两个操作符,来掌控程序运行时的堆空间。
在C++的世界里,内存管理是性能优化和程序稳定性的基石。理解内存的分配和释放机制,是成为一名优秀C++程序员的必经之路。你可能会问,为什么我们需要动态内存?它解决了什么问题?又带来了哪些新的挑战?别急,我们一步步来揭开这些谜团。
1. 内存的舞台:静态、栈与堆
在深入 new 和 delete 之前,我们有必要回顾一下C++程序中几种主要的内存区域,它们共同构成了程序运行时的“内存舞台”。理解它们的特点和生命周期,是理解动态内存管理的前提。
1.1 静态存储区(Static Storage Area)
静态存储区,顾名思义,存储的是具有静态生命周期的变量。这包括全局变量、静态局部变量、静态成员变量以及字符串字面量等。
- 生命周期: 从程序启动时分配,到程序结束时释放。它们伴随程序整个执行过程。
- 特点: 在编译时或程序加载时就已经确定了大小和位置。
- 存储内容:
- 全局变量: 在函数外部定义的变量。
- 静态局部变量: 在函数内部用
static关键字修饰的变量。 - 静态成员变量: 类的静态成员。
- 字符串字面量: 如
const char* s = "hello";中的"hello"。
-
示例:
#include <iostream> int globalVar = 10; // 全局变量,在静态存储区 void func() { static int staticLocalVar = 20; // 静态局部变量,在静态存储区 std::cout << "staticLocalVar: " << staticLocalVar << std::endl; staticLocalVar++; } class MyClass { public: static int staticMemberVar; // 静态成员变量声明 }; int MyClass::staticMemberVar = 30; // 静态成员变量定义和初始化 int main() { std::cout << "globalVar: " << globalVar << std::endl; func(); // staticLocalVar = 20 func(); // staticLocalVar = 21 (值被保留) std::cout << "MyClass::staticMemberVar: " << MyClass::staticMemberVar << std::endl; return 0; }静态存储区分配的内存,其生命周期与程序相同,因此我们无需手动管理其内存。
1.2 栈存储区(Stack Storage Area)
栈存储区用于存储具有自动生命周期的变量,主要是函数内的局部变量、函数参数以及函数调用时的返回地址等。
- 生命周期: 随着其作用域的创建而分配,随着作用域的结束而自动释放。
- 特点: 遵循“后进先出”(LIFO)的原则。编译器在编译时可以确定大部分栈上变量的大小。分配和释放速度非常快,由系统自动管理。
- 存储内容:
- 局部变量: 在函数或代码块内部定义的非静态变量。
- 函数参数: 传递给函数的参数。
- 函数调用信息: 如返回地址、寄存器状态等。
-
示例:
#include <iostream> void processData(int value) { // value 是函数参数,在栈上 int localArray[5]; // localArray 是局部变量,在栈上 for (int i = 0; i < 5; ++i) { localArray[i] = value + i; } std::cout << "Local array in processData: "; for (int i = 0; i < 5; ++i) { std::cout << localArray[i] << " "; } std::cout << std::endl; // localArray 和 value 在函数结束时自动销毁 } int main() { int x = 100; // x 是局部变量,在栈上 processData(x); // x 在 main 函数结束时自动销毁 return 0; }栈内存的自动管理机制非常方便,效率高,但它的缺点是大小有限(通常远小于堆),且变量的生命周期受限于其作用域。我们无法在函数外部访问函数内部的栈上变量,因为它们在函数返回后就已被销毁。
1.3 堆存储区(Heap Storage Area)
堆存储区是C++程序中最灵活的内存区域,它用于存储动态分配的内存。
- 生命周期: 由程序员手动控制。从程序运行时通过特定机制(如
new)分配开始,直到程序员手动释放(如delete)或程序结束。 - 特点: 大小通常远大于栈,没有严格的生命周期限制,可以在程序运行的任何时候分配和释放。但分配和释放的开销相对较大,且管理不当容易导致内存泄漏、野指针等问题。
- 存储内容: 任何需要运行时决定大小、或需要在多个函数调用之间共享、或需要超出当前作用域生命周期的对象。
-
示例(初步概念):
#include <iostream> int main() { // 在堆上分配一个int类型的内存 int* p_int = new int; *p_int = 42; std::cout << "Value on heap: " << *p_int << std::endl; // 释放堆上的内存 delete p_int; p_int = nullptr; // 良好的编程习惯,避免野指针 // 在堆上分配一个包含10个int的数组 int* p_array = new int[10]; for (int i = 0; i < 10; ++i) { p_array[i] = i * 2; } std::cout << "Array on heap: "; for (int i = 0; i < 10; ++i) { std::cout << p_array[i] << " "; } std::cout << std::endl; // 释放堆上的数组内存 delete[] p_array; p_array = nullptr; // 良好的编程习惯 return 0; }今天,我们的重点就是如何使用
new和delete来管理这片自由而又危险的“堆”空间。
1.4 内存区域总结表
| 内存区域 | 分配方式 | 生命周期 | 速度 | 大小限制 | 管理方式 | 典型用途 |
|---|---|---|---|---|---|---|
| 静态存储区 | 编译/加载时 | 整个程序运行期间 | 最快 | 固定 | 系统管理 | 全局变量、静态变量、字符串字面量 |
| 栈存储区 | 运行时 | 作用域内 | 很快 | 有限 | 系统管理 | 局部变量、函数参数、函数调用信息 |
| 堆存储区 | 运行时 | 程序员手动控制 | 较慢 | 较大 | 手动管理 | 动态数据结构、大对象、跨函数/作用域生命周期对象 |
2. 揭秘 new 和 delete:动态内存的守护者
现在,我们终于要迎来今天的主角:new 和 delete 操作符。它们是C++中用于在堆上动态分配和释放内存的核心工具。
2.1 new 操作符:请求堆空间
new 操作符负责在堆上分配指定大小的内存,并返回一个指向该内存起始地址的指针。如果分配的是对象,new 还会负责调用该对象的构造函数。
基本语法:
-
分配单个对象:
Type* pointer_name = new Type; Type* pointer_name = new Type(constructor_arguments); // 带参数构造Type可以是内置类型(如int,double)或自定义类型(如class,struct)。 -
分配对象数组:
Type* pointer_name = new Type[size];注意:对于数组,不能在
new后直接跟构造函数参数,因为数组中的每个元素都会使用默认构造函数(如果存在)。如果需要对数组元素进行初始化,需要在分配后循环遍历赋值,或者使用C++11的列表初始化(但通常更复杂且有特定限制)。
new 的行为:
- 内存分配:
new首先会调用一个名为operator new的函数来实际分配原始的、未初始化的内存块。 - 对象构造: 如果分配的是对象(而不是内置类型),
new接着会在分配到的内存上调用对象的构造函数,完成对象的初始化。 - 返回指针:
new返回一个指向新分配并构造完成的对象的指针。 - 错误处理:
- 默认行为: 如果
new无法分配所需的内存(例如,堆空间耗尽),它会抛出std::bad_alloc异常。这是C++标准库规定的默认行为。 nothrow版本: 可以使用new (std::nothrow) Type的形式。在这种情况下,如果内存分配失败,new不会抛出异常,而是返回nullptr。这对于不希望程序因内存不足而崩溃,而是希望优雅处理错误的情况非常有用。
- 默认行为: 如果
示例:分配单个对象
#include <iostream>
#include <string>
#include <new> // For std::nothrow
// 自定义类
class MyObject {
public:
int id;
std::string name;
MyObject(int _id, const std::string& _name) : id(_id), name(_name) {
std::cout << "MyObject(" << id << ", "" << name << "") constructed." << std::endl;
}
~MyObject() {
std::cout << "MyObject(" << id << ", "" << name << "") destructed." << std::endl;
}
void display() const {
std::cout << "ID: " << id << ", Name: " << name << std::endl;
}
};
int main() {
// 1. 分配一个内置类型 int
int* p_int = new int; // 分配一个未初始化的int
*p_int = 100;
std::cout << "Dynamically allocated int: " << *p_int << std::endl;
// 释放内存将在后面讲解
// 2. 分配一个内置类型 int 并初始化
int* p_initialized_int = new int(200); // 直接初始化为200
std::cout << "Dynamically allocated and initialized int: " << *p_initialized_int << std::endl;
// 3. 分配一个自定义对象 MyObject
MyObject* p_obj1 = new MyObject(1, "FirstObject"); // 调用带参数构造函数
p_obj1->display();
// 4. 使用 new (std::nothrow) 处理内存分配失败
MyObject* p_obj_nothrow = nullptr;
try {
// 假设这里尝试分配一个非常大的对象,可能会失败
// 为了演示,我们不真的分配大对象,而是模拟一个失败场景
// 实际中,你很难通过 new 一个普通对象来触发 bad_alloc
// 但如果系统内存真的耗尽,或者分配超大数组,就可能发生
p_obj_nothrow = new (std::nothrow) MyObject(2, "NothrowObject");
if (p_obj_nothrow == nullptr) {
std::cerr << "Error: Memory allocation failed using new (std::nothrow)." << std::endl;
} else {
std::cout << "Successfully allocated object with nothrow." << std::endl;
p_obj_nothrow->display();
}
} catch (const std::bad_alloc& e) {
// 即使使用了 nothrow,如果原始 new 发生异常,这里也能捕获
// 但 nothrow 本身不会抛出
std::cerr << "Caught std::bad_alloc exception (should not happen with nothrow): " << e.what() << std::endl;
}
// 5. 分配一个对象数组
// 注意:这里的MyObject类需要有一个默认构造函数,否则会编译错误
// 如果没有默认构造函数,需要使用placement new或者其他高级技巧
// 为了演示,我们假设MyObject有一个默认构造函数 (或者我们手动添加一个)
// 这里我们用上面的 MyObject 类,它没有默认构造函数,所以直接 new MyObject[size] 会报错
// 让我们稍微修改一下 MyObject,添加一个默认构造函数
// class MyObject { /* ... */ MyObject() : id(0), name("Default") { std::cout << "Default constructed." << std::endl; } };
// 假设 MyObject 已经有了默认构造函数
MyObject* p_obj_array = new MyObject[3]; // 分配3个MyObject对象,会调用3次默认构造函数
for (int i = 0; i < 3; ++i) {
p_obj_array[i].id = 100 + i;
p_obj_array[i].name = "ArrayObject" + std::to_string(i);
p_obj_array[i].display();
}
// 6. 清理内存 (将在下一节详细讲解)
// delete p_int;
// delete p_initialized_int;
// delete p_obj1;
// if (p_obj_nothrow) delete p_obj_nothrow;
// delete[] p_obj_array;
return 0;
}
重要提示:
当使用 new Type[size] 分配对象数组时,如果 Type 是一个自定义类型,它必须有一个可访问的默认构造函数(即无参数构造函数),否则编译会失败。如果 Type 没有默认构造函数,你需要自己想办法初始化数组元素,这通常涉及到更复杂的内存管理技术(如placement new)或者使用容器(如 std::vector)。
2.2 delete 操作符:归还堆空间
delete 操作符负责释放由 new 分配的内存。这是至关重要的一步,因为如果忘记释放,就会导致内存泄漏。如果释放的是对象,delete 还会负责调用该对象的析构函数。
基本语法:
-
释放单个对象:
delete pointer_name;pointer_name必须是指向由new Type分配的单个对象的指针。 -
释放对象数组:
delete[] pointer_name;pointer_name必须是指向由new Type[size]分配的数组的指针。注意[]是必须的,它告诉delete这是一个数组,需要调用数组中每个元素的析构函数并释放整个数组的内存块。
delete 的行为:
- 对象析构: 如果释放的是对象,
delete首先会调用对象的析构函数,执行清理工作。 - 内存释放: 接着,
delete会调用一个名为operator delete的函数来实际将原始内存块归还给系统。 - 传入
nullptr是安全的: 对nullptr执行delete操作是安全的,它什么也不会做。
示例:释放内存
让我们完善上面的 main 函数,加入 delete 操作:
#include <iostream>
#include <string>
#include <new> // For std::nothrow
// 自定义类 (添加默认构造函数以支持数组分配)
class MyObject {
public:
int id;
std::string name;
MyObject() : id(0), name("Default") { // 默认构造函数
std::cout << "MyObject(Default) constructed." << std::endl;
}
MyObject(int _id, const std::string& _name) : id(_id), name(_name) {
std::cout << "MyObject(" << id << ", "" << name << "") constructed." << std::endl;
}
~MyObject() {
std::cout << "MyObject(" << id << ", "" << name << "") destructed." << std::endl;
}
void display() const {
std::cout << "ID: " << id << ", Name: " << name << std::endl;
}
};
int main() {
std::cout << "--- Allocating memory ---" << std::endl;
int* p_int = new int;
*p_int = 100;
std::cout << "Dynamically allocated int: " << *p_int << std::endl;
int* p_initialized_int = new int(200);
std::cout << "Dynamically allocated and initialized int: " << *p_initialized_int << std::endl;
MyObject* p_obj1 = new MyObject(1, "FirstObject");
p_obj1->display();
MyObject* p_obj_nothrow = nullptr;
p_obj_nothrow = new (std::nothrow) MyObject(2, "NothrowObject");
if (p_obj_nothrow == nullptr) {
std::cerr << "Error: Memory allocation failed using new (std::nothrow)." << std::endl;
} else {
std::cout << "Successfully allocated object with nothrow." << std::endl;
p_obj_nothrow->display();
}
MyObject* p_obj_array = new MyObject[3]; // 调用3次默认构造函数
for (int i = 0; i < 3; ++i) {
p_obj_array[i].id = 100 + i;
p_obj_array[i].name = "ArrayObject" + std::to_string(i);
p_obj_array[i].display();
}
std::cout << "n--- Deallocating memory ---" << std::endl;
// 1. 释放单个内置类型
delete p_int;
p_int = nullptr; // 良好的编程习惯,防止野指针
delete p_initialized_int;
p_initialized_int = nullptr;
// 2. 释放单个自定义对象
delete p_obj1; // 调用MyObject的析构函数
p_obj1 = nullptr;
// 3. 释放通过 nothrow 分配的对象 (如果成功分配)
if (p_obj_nothrow != nullptr) {
delete p_obj_nothrow; // 调用MyObject的析构函数
p_obj_nothrow = nullptr;
}
// 4. 释放对象数组
delete[] p_obj_array; // 调用数组中每个MyObject的析构函数 (共3次)
p_obj_array = nullptr;
std::cout << "All dynamically allocated memory freed." << std::endl;
return 0;
}
运行上述代码,你会看到 MyObject 的构造函数和析构函数被正确调用,这表明 new 和 delete 不仅仅是分配和释放内存,它们还负责对象的生命周期管理。
new 和 delete 的匹配原则:
new Type必须与delete pointer匹配。new Type[size]必须与delete[] pointer匹配。
如果匹配错误,例如用 delete 释放 new[] 分配的内存,或者用 delete[] 释放 new 分配的内存,可能会导致未定义行为(Undefined Behavior),包括内存泄漏、程序崩溃等。这是因为 delete[] 需要额外的元数据来知道数组的大小,以便正确地调用每个元素的析构函数,并释放整个内存块。如果只用 delete,它可能只会释放第一个元素或整个块,但不会正确调用所有析构函数,也不会正确处理数组的元数据。
3. 内存管理的陷阱:泄漏、野指针与重复释放
动态内存管理赋予了程序员极大的灵活性,但也带来了巨大的责任。一旦管理不当,就可能引入严重的内存问题,导致程序不稳定甚至崩溃。
3.1 内存泄漏(Memory Leak)
内存泄漏是指程序分配了堆上的内存,但在不再使用它时,未能将其释放回系统。随着程序的运行,未释放的内存会逐渐累积,最终耗尽系统资源,导致程序变慢甚至崩溃。
产生原因:
- 忘记
delete: 最常见的原因,例如在一个函数中new了一块内存,但在函数返回前没有delete。 - 指针重定向: 在释放旧内存之前,将指向动态分配内存的指针指向了新的内存或
nullptr,导致旧内存的地址丢失,无法释放。 - 异常安全问题: 在
new和delete之间发生异常,导致delete未被执行。
示例:内存泄漏
#include <iostream>
void memoryLeakExample() {
int* p_data = new int[1000]; // 在堆上分配一个包含1000个int的数组
// 假设这里进行了一些操作
for (int i = 0; i < 1000; ++i) {
p_data[i] = i;
}
std::cout << "Allocated 1000 ints, first element: " << p_data[0] << std::endl;
// ... 函数结束,但 p_data 没有被 delete[]
// 这1000个int的内存就泄漏了
} // p_data 指针超出作用域,但其指向的堆内存没有被释放
void pointerRedirectLeak() {
int* p_data = new int(10); // 分配内存A
std::cout << "Initial p_data value: " << *p_data << std::endl;
p_data = new int(20); // 再次分配内存B,并把 p_data 指向它
// 此时,指向内存A的指针已经丢失,内存A发生泄漏
std::cout << "New p_data value: " << *p_data << std::endl;
delete p_data; // 只能释放内存B
p_data = nullptr;
}
int main() {
std::cout << "--- Demonstrating Memory Leak ---" << std::endl;
memoryLeakExample(); // 第一次泄漏
memoryLeakExample(); // 第二次泄漏 (内存持续累积)
std::cout << "n--- Demonstrating Pointer Redirect Leak ---" << std::endl;
pointerRedirectLeak();
// 在实际程序中,这些泄漏可能会导致程序运行一段时间后内存耗尽
std::cout << "Program finished, but memory might have leaked." << std::endl;
return 0;
}
预防策略:
- 始终配对使用
new和delete: 这是最基本的原则。 - RAII (Resource Acquisition Is Initialization): 这是C++中管理资源(包括内存)的核心思想。将资源的生命周期绑定到对象的生命周期。当对象被创建时,资源被获取;当对象被销毁时(无论是正常退出作用域还是异常),资源被自动释放。智能指针就是RAII的典型应用。
- 使用智能指针: 在现代C++中,几乎总是推荐使用
std::unique_ptr和std::shared_ptr来管理动态内存,而不是直接使用new和delete。它们内部封装了delete操作,并在对象超出作用域时自动释放内存,从而有效杜绝内存泄漏。
3.2 野指针(Dangling Pointer)
野指针是指向一块已经失效或不再属于程序的内存区域的指针。当这块内存被释放后,但指针仍然持有其地址时,该指针就成了野指针。
产生原因:
- 释放后未置空: 内存被
delete后,指针本身并没有变为nullptr,仍然指向原来的地址。此时如果再次解引用这个指针,就会访问到无效内存。 - 局部变量的地址返回: 函数返回局部变量的地址,而局部变量在函数返回后被销毁。
示例:野指针
#include <iostream>
int* createAndDestroy() {
int* p_val = new int(100);
std::cout << "Inside createAndDestroy: *p_val = " << *p_val << std::endl;
delete p_val; // 内存被释放
// p_val 此时是一个野指针,它仍然指向那块被释放的内存地址
// 但这块内存可能已经被操作系统回收或分配给了其他程序
return p_val; // 返回野指针
}
int main() {
std::cout << "--- Demonstrating Dangling Pointer ---" << std::endl;
int* p_dangling = createAndDestroy(); // p_dangling 接收了一个野指针
// 尝试解引用野指针,这是非常危险的行为!
// 可能会导致程序崩溃,或者读写到不属于你的内存,产生难以预料的错误
// 编译器通常无法检测出这种错误,运行时可能也不报错,直到问题变得严重
std::cout << "Attempting to dereference dangling pointer..." << std::endl;
// std::cout << *p_dangling << std::endl; // 千万不要在实际代码中这样做!
// 良好的编程习惯是立即将指针置为 nullptr
int* p_safe = new int(50);
delete p_safe;
p_safe = nullptr; // 现在 p_safe 是一个空指针,安全
if (p_safe == nullptr) {
std::cout << "p_safe is now a nullptr, safe to check." << std::endl;
}
std::cout << "Program finished, avoided dereferencing dangling pointer." << std::endl;
return 0;
}
预防策略:
delete后立即将指针置为nullptr: 这是避免野指针最直接有效的方法。对nullptr执行delete是安全的,而解引用nullptr会导致可预测的崩溃(通常是段错误),比访问随机内存要好调试得多。- 避免返回局部变量的地址。
- 使用智能指针: 智能指针在所管理的对象被销毁后会自动将内部指针置空或正确管理其生命周期,从根本上消除了野指针的风险。
3.3 重复释放(Double Free)
重复释放是指尝试 delete 同一块内存两次或更多次。这通常会导致堆损坏(heap corruption),进而引发程序崩溃。
产生原因:
- 同一个指针被
delete两次: 没有将指针在第一次delete后置为nullptr,导致后续再次delete。 - 多个指针指向同一块内存: 多个原始指针指向同一块动态分配的内存,其中一个指针释放了内存,而其他指针不知道,再次尝试释放。
示例:重复释放
#include <iostream>
int main() {
std::cout << "--- Demonstrating Double Free ---" << std::endl;
int* p1 = new int(10);
std::cout << "p1 points to: " << *p1 << std::endl;
delete p1; // 第一次释放
std::cout << "Memory pointed by p1 freed once." << std::endl;
// p1 此时是野指针,没有被置为 nullptr
// 再次 delete p1 将导致重复释放
// delete p1; // 危险操作!取消注释将导致崩溃或未定义行为
// std::cout << "Attempted second free (dangerous!)." << std::endl;
// 正确的做法:
p1 = nullptr; // 将 p1 置为 nullptr,防止再次释放
delete p1; // 对 nullptr 执行 delete 是安全的
std::cout << "n--- Multiple pointers to same memory leading to double free ---" << std::endl;
int* p_orig = new int(20);
int* p_alias = p_orig; // p_alias 也指向同一块内存
std::cout << "p_orig points to: " << *p_orig << std::endl;
std::cout << "p_alias points to: " << *p_alias << std::endl;
delete p_orig; // p_orig 释放了内存
p_orig = nullptr;
std::cout << "Memory freed by p_orig." << std::endl;
// 此时 p_alias 成了野指针
// delete p_alias; // 危险操作!取消注释将导致重复释放和崩溃
// std::cout << "Attempted second free by p_alias (dangerous!)." << std::endl;
// 再次强调:`delete` 后务必将指针置为 `nullptr`
std::cout << "Program finished, avoided double free." << std::endl;
return 0;
}
预防策略:
delete后立即将指针置为nullptr: 这是防止重复释放的关键。- 避免多个原始指针管理同一块内存: 如果确实需要多个指针访问同一块动态内存,考虑使用智能指针(尤其是
std::shared_ptr),它能自动跟踪引用计数,确保内存只在所有引用都消失后才被释放。 - 避免在复制构造函数和赋值操作符中直接复制原始指针: 如果自定义类管理动态内存,必须实现“三/五法则”(Rule of Three/Five),即自定义复制构造函数、复制赋值操作符和析构函数(在C++11后还需要移动构造函数和移动赋值操作符),以确保深拷贝或正确的资源所有权转移。
4. new 和 delete 与自定义类型(类/结构体)
当 new 和 delete 操作符作用于自定义类型(类或结构体)的对象时,它们不仅仅是分配和释放内存,还会涉及到构造函数和析构函数的调用,这是对象生命周期管理的关键部分。
4.1 构造函数与析构函数的调用
new操作符:- 分配足够的原始内存来存储对象。
- 在这块内存上调用对象的构造函数来初始化对象。
- 返回指向已构造对象的指针。
delete操作符:- 调用对象的析构函数来执行清理工作(例如释放对象内部持有的其他动态内存)。
- 释放对象所占用的内存。
这使得 new 和 delete 能够与C++的对象模型无缝集成,确保对象在创建时正确初始化,在销毁时正确清理。
示例:自定义类的动态分配
#include <iostream>
#include <string>
class MyComplexObject {
public:
int value;
std::string message;
int* data; // 内部也管理动态内存
MyComplexObject(int v, const std::string& msg) : value(v), message(msg), data(nullptr) {
data = new int[5]; // 构造函数中分配内部动态内存
for (int i = 0; i < 5; ++i) {
data[i] = v + i;
}
std::cout << "MyComplexObject(value=" << value << ", message="" << message << "") constructed. Internal data allocated." << std::endl;
}
~MyComplexObject() {
if (data != nullptr) {
delete[] data; // 析构函数中释放内部动态内存
data = nullptr;
std::cout << "MyComplexObject(value=" << value << ", message="" << message << "") destructed. Internal data freed." << std::endl;
} else {
std::cout << "MyComplexObject(value=" << value << ", message="" << message << "") destructed. No internal data to free." << std::endl;
}
}
void display() const {
std::cout << " Value: " << value << ", Message: " << message << std::endl;
if (data) {
std::cout << " Internal data: ";
for (int i = 0; i < 5; ++i) {
std::cout << data[i] << " ";
}
std::cout << std::endl;
}
}
};
int main() {
std::cout << "--- Allocating a single MyComplexObject ---" << std::endl;
MyComplexObject* obj1 = new MyComplexObject(10, "Hello");
obj1->display();
delete obj1; // 调用析构函数,释放obj1及其内部的data
obj1 = nullptr;
std::cout << "n--- Allocating an array of MyComplexObject ---" << std::endl;
// 注意:MyComplexObject 需要一个默认构造函数才能用 new MyComplexObject[size]
// 我们的 MyComplexObject 没有默认构造函数,所以直接 new MyComplexObject[size] 是错误的
// 这是为了说明问题,如果需要数组,你需要提供默认构造函数或者使用 placement new
// 假设我们添加一个默认构造函数:
// MyComplexObject() : value(0), message("Default"), data(nullptr) { /* ... */ }
// 为了演示,我们先跳过数组,或者手动构造
// 如果没有默认构造函数,以下代码会编译失败
// MyComplexObject* obj_array = new MyComplexObject[2]; // Error: No matching default constructor
// 正确的做法 (如果类没有默认构造函数,且需要动态数组):
// 方式1: 使用 std::vector (推荐)
// std::vector<MyComplexObject> vec;
// vec.emplace_back(20, "VecObj1");
// vec.emplace_back(21, "VecObj2");
// 方式2: 手动使用 placement new (复杂)
// 暂时不展开,会在后面 placement new 部分提及
std::cout << "n--- End of program ---" << std::endl;
return 0;
}
通过这个例子,我们看到 MyComplexObject 的构造函数和析构函数在 new 和 delete 时被正确调用,从而确保了内部动态内存 data 的正确分配和释放。这正是RAII原则在类设计中的体现。
4.2 虚析构函数的重要性
当使用多态性(通过基类指针指向派生类对象)并动态分配对象时,虚析构函数变得至关重要。
问题: 如果基类的析构函数不是虚函数,当通过基类指针 delete 一个派生类对象时,只会调用基类的析构函数,而不会调用派生类的析构函数。这会导致派生类中特有的资源(例如派生类自己动态分配的内存)无法被释放,造成内存泄漏。
解决方案: 将基类的析构函数声明为 virtual。这样,当通过基类指针 delete 对象时,会根据对象的实际类型调用正确的析构函数链(从派生类到基类)。
示例:虚析构函数
#include <iostream>
#include <string>
class Base {
public:
Base() { std::cout << "Base constructor called." << std::endl; }
// virtual ~Base() { std::cout << "Base destructor called." << std::endl; } // 正确做法
~Base() { std::cout << "Base destructor called." << std::endl; } // 错误做法,非虚析构
void show() { std::cout << "I am Base." << std::endl; }
};
class Derived : public Base {
public:
int* p_data;
Derived() : p_data(new int(100)) { // 派生类中分配动态内存
std::cout << "Derived constructor called. p_data allocated." << std::endl;
}
~Derived() {
if (p_data) {
delete p_data; // 派生类中释放动态内存
p_data = nullptr;
std::cout << "Derived destructor called. p_data freed." << std::endl;
}
}
void show() { std::cout << "I am Derived." << std::endl; }
};
int main() {
std::cout << "--- Case 1: Delete Derived object directly ---" << std::endl;
Derived* d1 = new Derived();
delete d1; // 正确调用 Derived 和 Base 的析构函数
d1 = nullptr;
std::cout << "n--- Case 2: Delete Derived object through Base pointer (non-virtual destructor) ---" << std::endl;
Base* b1 = new Derived(); // 基类指针指向派生类对象
b1->show();
delete b1; // 仅调用 Base 的析构函数,Derived 的析构函数未被调用!
// p_data 内存泄漏!
b1 = nullptr;
std::cout << "n--- Case 3: Delete Derived object through Base pointer (virtual destructor) ---" << std::endl;
// 如果将 Base 的析构函数改为 virtual ~Base()
// 那么下面的 delete 操作将正确调用 Derived 和 Base 的析构函数
// Base* b2 = new Derived();
// delete b2; // 这时会正确调用两个析构函数
// b2 = nullptr;
std::cout << "n--- End of program ---" << std::endl;
return 0;
}
在 Case 2 中,由于 Base 的析构函数不是虚函数,通过 Base* 删除 Derived 对象时,只会调用 Base 的析构函数,导致 Derived 中 p_data 的内存泄漏。如果将 Base 的析构函数声明为 virtual,这个问题就会得到解决。
经验法则: 如果一个类有可能被用作基类,并且它有任何虚函数,那么它的析构函数也应该声明为虚函数。更安全地说,如果一个类打算被继承,并且通过基类指针进行多态删除,其析构函数就必须是虚的。
5. placement new:在指定位置构造对象
placement new 是一种特殊形式的 new 操作符,它允许你在已经分配好的内存块上构造一个对象。它不负责分配内存,只负责调用构造函数。
语法:
void* buffer = operator new(sizeof(Type)); // 或者 char buffer[sizeof(Type)];
Type* ptr = new (buffer) Type(constructor_arguments); // 在 buffer 指向的内存上构造 Type 对象
何时使用 placement new?
- 内存池(Memory Pool): 当你需要频繁分配和释放大量小对象时,为了提高效率,可以预先分配一大块内存(内存池),然后使用
placement new在这些预分配的内存块上构造对象,避免每次都向操作系统请求内存。 - 共享内存: 在多进程通信中,如果多个进程需要共享同一块内存区域,可以在共享内存上使用
placement new构造对象。 - 内存对齐: 当需要精确控制对象的内存对齐时。
- 性能敏感的场景: 避免
operator new的额外开销。
placement new 的注意事项:
- 不分配内存:
placement new不会调用operator new来分配内存,它只是在提供的地址上构造对象。 - 不释放内存: 由于
placement new不分配内存,因此也不存在placement delete。你不能对placement new返回的指针直接使用delete。你需要手动调用对象的析构函数,然后手动释放原始的内存缓冲区。 - 手动析构: 使用
placement new构造的对象,其析构函数需要手动调用,以确保资源的正确清理。
示例:placement new
#include <iostream>
#include <string>
#include <new> // 包含 placement new 的头文件
class MyPlacementObject {
public:
int id;
std::string name;
MyPlacementObject(int _id, const std::string& _name) : id(_id), name(_name) {
std::cout << "MyPlacementObject(" << id << ", "" << name << "") constructed." << std::endl;
}
~MyPlacementObject() {
std::cout << "MyPlacementObject(" << id << ", "" << name << "") destructed." << std::endl;
}
void display() const {
std::cout << " ID: " << id << ", Name: " << name << std::endl;
}
};
int main() {
std::cout << "--- Demonstrating Placement New ---" << std::endl;
// 1. 预先分配一块内存缓冲区
// 方法一:栈上数组作为缓冲区
char buffer[sizeof(MyPlacementObject)];
// 方法二:堆上分配缓冲区
// void* heap_buffer = operator new(sizeof(MyPlacementObject));
// 2. 在缓冲区上使用 placement new 构造对象
MyPlacementObject* obj_ptr = new (buffer) MyPlacementObject(101, "PlacementObj");
// MyPlacementObject* obj_ptr_heap = new (heap_buffer) MyPlacementObject(102, "HeapPlacementObj");
obj_ptr->display();
// obj_ptr_heap->display();
// 3. 手动调用析构函数
obj_ptr->~MyPlacementObject(); // 必须手动调用析构函数
// obj_ptr_heap->~MyPlacementObject();
// 4. 释放原始缓冲区 (如果是堆上分配的)
// operator delete(heap_buffer); // 如果使用了堆缓冲区,需要手动释放
std::cout << "--- End of Placement New Demo ---" << std::endl;
return 0;
}
placement delete (非标准,但概念存在)
虽然没有 placement delete 操作符,但你可以模拟它的行为。当你使用 placement new 在预分配的内存上构造对象时,如果构造函数抛出异常,为了清理已经成功构造的对象(如果构造的是数组的一部分),需要调用这些对象的析构函数。C++标准库提供了一个全局的 operator delete (void*, void*),它被称为 placement delete,用于处理 placement new 构造失败时的清理。但它不释放内存,只处理部分构造成功的情况。在日常应用中很少直接使用,更多是编译器在后台处理。
6. 重载 new 和 delete 操作符
C++允许你重载全局的 new 和 delete 操作符,或者为特定的类重载它们。重载的目的是为了定制内存分配和释放的行为。
6.1 全局重载 new 和 delete
全局重载 new 和 delete 会影响整个程序中所有动态内存的分配和释放(除非有类级的重载)。
用途:
- 内存池: 为整个程序实现一个高效的内存池,减少系统调用开销。
- 内存追踪/调试: 记录所有内存的分配和释放,检测内存泄漏、越界访问等。
- 内存对齐: 确保所有动态分配的内存都满足特定的对齐要求。
- 自定义错误处理: 改变
new失败时的行为。
语法:
// 全局 new
void* operator new(std::size_t size);
void* operator new[](std::size_t size); // for arrays
void* operator new(std::size_t size, const std::nothrow_t&) noexcept;
void* operator new[](std::size_t size, const std::nothrow_t&) noexcept;
// 全局 delete
void operator delete(void* ptr) noexcept;
void operator delete[](void* ptr) noexcept; // for arrays
void operator delete(void* ptr, const std::nothrow_t&) noexcept;
void operator delete[](void* ptr, const std::nothrow_t&) noexcept;
在重载时,通常需要在内部调用标准库提供的 malloc/free 或 std::malloc/std::free 来实现实际的内存操作,以避免无限递归调用自己。
示例:全局重载
#include <iostream>
#include <new> // For std::bad_alloc, std::nothrow_t
// 全局重载 new
void* operator new(std::size_t size) {
std::cout << "Global operator new called for size: " << size << " bytes." << std::endl;
void* ptr = std::malloc(size); // 调用标准C库的 malloc
if (!ptr) {
// 如果分配失败,抛出 bad_alloc 异常
throw std::bad_alloc();
}
return ptr;
}
// 全局重载 delete
void operator delete(void* ptr) noexcept {
std::cout << "Global operator delete called." << std::endl;
std::free(ptr); // 调用标准C库的 free
}
// 可选:重载 new[] 和 delete[]
void* operator new[](std::size_t size) {
std::cout << "Global operator new[] called for size: " << size << " bytes." << std::endl;
void* ptr = std::malloc(size);
if (!ptr) {
throw std::bad_alloc();
}
return ptr;
}
void operator delete[](void* ptr) noexcept {
std::cout << "Global operator delete[] called." << std::endl;
std::free(ptr);
}
class MyData {
public:
int x;
double y;
MyData() : x(0), y(0.0) { std::cout << "MyData constructor." << std::endl; }
~MyData() { std::cout << "MyData destructor." << std::endl; }
};
int main() {
std::cout << "--- Testing Global Overload ---" << std::endl;
int* i = new int; // 会调用我们重载的 operator new
delete i; // 会调用我们重载的 operator delete
MyData* d = new MyData; // 会调用我们重载的 operator new
delete d; // 会调用我们重载的 operator delete
int* arr = new int[5]; // 会调用我们重载的 operator new[]
delete[] arr; // 会调用我们重载的 operator delete[]
MyData* d_arr = new MyData[2]; // 会调用我们重载的 operator new[]
delete[] d_arr; // 会调用我们重载的 operator delete[]
std::cout << "--- End of Global Overload Test ---" << std::endl;
return 0;
}
6.2 类内部重载 new 和 delete
你也可以为特定的类重载 operator new 和 operator delete。这只会影响该类的对象(及其派生类的对象)的动态分配,而不会影响其他类型的对象。
用途:
- 特定类的内存池: 为某个频繁创建和销毁的类实现一个专用的内存池。
- 特殊内存需求: 例如,将某个类的所有对象分配到特定硬件的内存区域(如GPU内存)。
- 调试: 仅追踪某个类的内存使用情况。
语法:
class MyClass {
public:
// 类内重载 new
static void* operator new(std::size_t size);
static void* operator new[](std::size_t size); // for arrays
// 类内重载 delete
static void operator delete(void* ptr) noexcept;
static void operator delete[](void* ptr) noexcept; // for arrays
// ... 其他成员
};
示例:类内重载
#include <iostream>
#include <new>
#include <cstdlib> // For malloc, free
class MyCustomAllocClass {
public:
int data_member;
MyCustomAllocClass() : data_member(42) {
std::cout << "MyCustomAllocClass constructor called." << std::endl;
}
~MyCustomAllocClass() {
std::cout << "MyCustomAllocClass destructor called." << std::endl;
}
// 类内重载 operator new
static void* operator new(std::size_t size) {
std::cout << "MyCustomAllocClass::operator new called for size: " << size << " bytes." << std::endl;
void* ptr = std::malloc(size);
if (!ptr) throw std::bad_alloc();
return ptr;
}
// 类内重载 operator delete
static void operator delete(void* ptr) noexcept {
std::cout << "MyCustomAllocClass::operator delete called." << std::endl;
std::free(ptr);
}
// 类内重载 operator new[]
static void* operator new[](std::size_t size) {
std::cout << "MyCustomAllocClass::operator new[] called for size: " << size << " bytes." << std::endl;
void* ptr = std::malloc(size);
if (!ptr) throw std::bad_alloc();
return ptr;
}
// 类内重载 operator delete[]
static void operator delete[](void* ptr) noexcept {
std::cout << "MyCustomAllocClass::operator delete[] called." << std::endl;
std::free(ptr);
}
};
class NormalClass {
public:
int val;
NormalClass() : val(1) { std::cout << "NormalClass constructor." << std::endl; }
~NormalClass() { std::cout << "NormalClass destructor." << std::endl; }
};
int main() {
std::cout << "--- Testing Class-Specific Overload ---" << std::endl;
// 分配 MyCustomAllocClass 对象,会调用其类内重载的 new/delete
MyCustomAllocClass* custom_obj = new MyCustomAllocClass();
delete custom_obj;
MyCustomAllocClass* custom_arr = new MyCustomAllocClass[2];
delete[] custom_arr;
std::cout << "n--- Testing Normal Class Allocation ---" << std::endl;
// 分配 NormalClass 对象,会调用全局的 new/delete (如果重载了全局的)
// 如果没有全局重载,则使用默认的 new/delete
NormalClass* normal_obj = new NormalClass();
delete normal_obj;
NormalClass* normal_arr = new NormalClass[3];
delete[] normal_arr;
std::cout << "--- End of Class-Specific Overload Test ---" << std::endl;
return 0;
}
通过类内重载,我们可以为 MyCustomAllocClass 提供一套完全独立的内存管理机制,而不影响 NormalClass 或其他类型的内存分配。
7. 告别原始指针:智能指针的崛起
尽管 new 和 delete 是动态内存管理的基石,但它们需要程序员手动管理,极易出错。为了解决内存泄漏、野指针和重复释放等问题,现代C++引入了“智能指针”(Smart Pointers)。智能指针是RAII原则的典范应用,它们封装了原始指针,并在对象生命周期结束时自动调用 delete 或 delete[] 来释放内存。
虽然本讲座专注于 new 和 delete,但作为一名编程专家,我必须强调,在绝大多数情况下,你都应该优先使用智能指针来管理动态内存。
常见的智能指针:
std::unique_ptr: 独占所有权。一个unique_ptr只能指向一个动态分配的对象,且不能被复制(但可以被移动)。当unique_ptr超出作用域时,它所指向的对象会被自动删除。适用于唯一所有权场景。std::shared_ptr: 共享所有权。多个shared_ptr可以指向同一个动态分配的对象。内部维护一个引用计数,只有当所有shared_ptr都失效(引用计数变为零)时,对象才会被删除。适用于共享所有权场景。std::weak_ptr: 弱引用。与std::shared_ptr配合使用,解决循环引用问题。weak_ptr不增加引用计数,因此不会阻止对象被删除。
智能指针如何利用 new 和 delete?
智能指针的底层实现仍然依赖于 new 和 delete。它们内部持有一个原始指针,并在其析构函数中调用 delete(或 delete[]),从而自动化了内存释放过程。
示例:使用 std::unique_ptr
#include <iostream>
#include <memory> // 包含智能指针的头文件
#include <string>
class Resource {
public:
std::string name;
Resource(const std::string& n) : name(n) {
std::cout << "Resource "" << name << "" constructed." << std::endl;
}
~Resource() {
std::cout << "Resource "" << name << "" destructed." << std::endl;
}
void doSomething() {
std::cout << "Resource "" << name << "" is doing something." << std::endl;
}
};
void processResource() {
// 使用 std::unique_ptr 管理动态分配的 Resource 对象
// 推荐使用 std::make_unique 而不是直接 new
std::unique_ptr<Resource> res_ptr = std::make_unique<Resource>("MyUniqueResource");
res_ptr->doSomething();
// res_ptr 在函数结束时自动释放其指向的 Resource 对象
} // res_ptr 超出作用域,自动调用 delete res_ptr.get()
void processResourceArray() {
// std::unique_ptr 也可以管理动态数组
std::unique_ptr<Resource[]> res_array_ptr = std::make_unique<Resource[]>(3);
for (int i = 0; i < 3; ++i) {
res_array_ptr[i].name = "ArrayRes" + std::to_string(i);
res_array_ptr[i].doSomething();
}
// res_array_ptr 在函数结束时自动释放其指向的 Resource 数组
} // res_array_ptr 超出作用域,自动调用 delete[] res_array_ptr.get()
int main() {
std::cout << "--- Using std::unique_ptr for single object ---" << std::endl;
processResource(); // 内存自动管理,无泄漏
std::cout << "n--- Using std::unique_ptr for array ---" << std::endl;
processResourceArray(); // 数组内存自动管理,无泄漏
std::cout << "n--- End of Smart Pointer Demo ---" << std::endl;
return 0;
}
通过 std::unique_ptr 的例子,我们可以清楚地看到,无需显式调用 delete,内存也能被正确释放,这大大简化了内存管理,并有效避免了常见错误。
8. 实践指南与最佳实践
在今天的讲座尾声,我将为大家总结一些关于动态内存管理的实践指南和最佳实践,帮助大家写出更健壮、更高效的C++代码。
- 优先使用栈内存和自动存储: 只有当你确实需要动态生命周期、运行时确定大小或分配大块内存时,才考虑堆内存。对于小型、局部且生命周期短的对象,栈内存是首选,因为它分配和释放速度最快,且由编译器自动管理。
- 优先使用标准库容器: 例如
std::vector,std::string,std::map等。它们内部已经封装了动态内存管理,通常是经过高度优化且异常安全的。例如,std::vector会根据需要自动增长和收缩,并负责元素的构造和析构。 - 优先使用智能指针: 在需要动态分配内存时,几乎总是优先选择
std::unique_ptr或std::shared_ptr。它们实现了RAII原则,能够自动管理内存,有效避免内存泄漏和野指针。只有在极少数对性能、内存布局有极致要求,且明确了解风险和解决方案的场景下,才考虑直接使用new和delete。 - 遵循
new与delete的匹配原则: 如果你确实需要手动使用new和delete:new Type必须与delete pointer匹配。new Type[size]必须与delete[] pointer匹配。- 匹配错误会导致未定义行为。
delete后将指针置为nullptr: 这是一个非常重要的习惯。它能有效防止野指针问题,并在尝试对已释放内存进行重复delete时提供一个安全的检查点。对nullptr执行delete是安全的。- 处理
new失败:new默认行为是抛出std::bad_alloc异常。如果你的应用程序需要对内存分配失败进行更细粒度的控制,可以使用new (std::nothrow)版本,它会在失败时返回nullptr。 - 自定义类与“三/五/零法则”: 如果你的类拥有原始指针成员并管理动态内存,你必须:
- 三法则(Rule of Three): 显式定义析构函数、复制构造函数和复制赋值操作符。
- 五法则(Rule of Five): 在C++11及更高版本中,还需要显式定义移动构造函数和移动赋值操作符。
- 零法则(Rule of Zero): 理想情况是,你的类不直接管理任何资源,而是通过使用智能指针或标准库容器来间接管理。这样,编译器生成的默认特殊成员函数就足够了,你无需手动编写它们。这是现代C++的最佳实践。
- 虚析构函数: 如果一个类可能被用作基类,并且你计划通过基类指针多态地
delete派生类对象,那么基类的析构函数必须是虚函数。 - 初始化动态分配的内存: 无论是内置类型还是自定义类型,确保动态分配的内存在使用前得到正确初始化,以避免未定义行为。
new int()会将int初始化为0,new int则不会。 - 使用内存调试工具: 对于复杂的内存问题,Valgrind (Linux), AddressSanitizer (Clang/GCC), Dr. Memory (Windows/Linux) 等工具能帮助你检测内存泄漏、越界访问、重复释放等问题。
动态内存管理是C++编程中一个既强大又危险的领域。掌握 new 和 delete 的精髓,理解它们背后的机制,是构建高性能、稳定程序的关键。同时,也要拥抱现代C++的工具,如智能指针和标准库容器,让它们成为你内存管理的得力助手,从而将精力更多地投入到业务逻辑的实现上。
总结与展望
今天,我们系统地学习了C++中的动态内存管理,从内存分区的基本概念,到 new 和 delete 的详细用法,再到内存泄漏、野指针、重复释放等常见陷阱及其预防措施。我们还探讨了 placement new 的特殊应用,以及如何通过重载 new/delete 来定制内存行为。最后,我们强调了智能指针在现代C++中作为自动内存管理工具的重要性。
理解并熟练运用这些知识,不仅能帮助你编写出更安全、更高效的C++代码,还能为你在面对复杂系统设计和性能优化挑战时,提供坚实的基础。希望今天的讲座能为大家在C++的内存管理之路上点亮一盏明灯。感谢大家的参与!