1. 动态内存管理的基本准则
在C++中,动态内存分配是每个开发者必须掌握的技能。当我们在类中使用new操作符时,实际上是在向操作系统请求一块堆内存的使用权。这种能力带来了巨大的灵活性,同时也伴随着相应的责任。
1.1 构造函数与析构函数的黄金法则
内存管理的首要原则是:谁分配,谁释放。这意味着:
- 构造函数中使用new分配的内存,必须在析构函数中用对应的delete释放
- 这种配对关系必须严格保持,就像锁和钥匙的关系一样
cpp复制class String {
public:
String() : str(new char[1]) { str[0] = '\0'; } // 分配
~String() { delete[] str; } // 释放
private:
char* str;
};
注意:忘记在析构函数中释放内存会导致内存泄漏,这是C++中最常见的错误之一。每次程序运行都会丢失一部分内存,最终可能导致系统资源耗尽。
1.2 new/delete的配对使用
C++提供了两种形式的new/delete:
- 单一对象版本:
cpp复制Type* ptr = new Type;
delete ptr;
- 数组版本:
cpp复制Type* array = new Type[N];
delete[] array;
它们必须严格配对使用,否则会导致未定义行为。我曾经在一个项目中遇到过这样的bug:开发者在构造函数中使用new[]分配数组,却在析构函数中使用delete而非delete[]。这导致只有第一个元素被正确释放,其余元素的内存全部泄漏。
1.3 多构造函数的一致性要求
当类有多个构造函数时,所有使用new的构造函数必须保持一致性:
- 要么全部使用new[]
- 要么全部使用普通new
这是因为析构函数只有一个,它必须与所有构造函数的分配方式兼容。不一致的分配方式会导致析构函数无法正确释放所有资源。
cpp复制class String {
public:
String() : str(nullptr) {} // 使用空指针
String(const char* s) : str(new char[strlen(s)+1]) {} // 使用new[]
// 析构函数必须与所有构造函数兼容
~String() { delete[] str; }
};
2. 空指针的最佳实践
在C++中表示空指针经历了几个发展阶段:
2.1 历史演变
- NULL宏:传统C风格,通常定义为0
cpp复制int* ptr = NULL;
- 字面量0:C++98/03常用方式
cpp复制int* ptr = 0;
- nullptr:C++11引入的类型安全空指针
cpp复制int* ptr = nullptr;
2.2 为什么nullptr更优
nullptr相比前两者有几个显著优势:
- 类型安全:nullptr有明确的指针类型,而NULL和0本质上是整数
- 函数重载解析更准确
- 代码可读性更好,明确表示这是一个指针
考虑以下重载函数:
cpp复制void func(int);
void func(char*);
调用func(NULL)可能会调用到int版本,而func(nullptr)一定会调用char*版本。
2.3 现代C++推荐做法
在新项目中,应该始终使用nullptr。对于维护旧代码,如果还不能升级到C++11,建议:
- 优先使用0而非NULL
- 在需要强调指针语义的地方添加注释
- 计划逐步迁移到nullptr
3. 复制控制成员函数的实现
当类包含动态分配的资源时,必须特别注意所谓的"三大件":复制构造函数、赋值运算符和析构函数。
3.1 深度复制与浅复制
浅复制只复制指针值,导致多个对象共享同一资源;深度复制则创建资源的完整副本。
cpp复制// 浅复制(危险!)
String(const String& other) : str(other.str) {}
// 深度复制(正确)
String(const String& other) {
str = new char[strlen(other.str)+1];
strcpy(str, other.str);
}
我曾经调试过一个崩溃问题,就是因为多个String对象共享同一个字符数组,当其中一个对象被销毁时释放了内存,导致其他对象访问了已释放的内存。
3.2 复制构造函数的实现要点
正确的复制构造函数应该:
- 分配足够大的新内存
- 复制源对象的数据(而非指针)
- 更新任何必要的类静态成员
cpp复制String::String(const String& other) {
len = other.len;
str = new char[len+1]; // 1. 分配新内存
strcpy(str, other.str); // 2. 复制数据
num_strings++; // 3. 更新静态成员
}
3.3 赋值运算符的实现技巧
赋值运算符比复制构造函数更复杂,因为它需要处理已有资源:
- 检查自赋值(a = a)
- 释放旧内存
- 分配新内存
- 复制数据
- 返回*this
cpp复制String& String::operator=(const String& other) {
if (this == &other) return *this; // 1. 自赋值检查
delete[] str; // 2. 释放旧内存
len = other.len;
str = new char[len+1]; // 3. 分配新内存
strcpy(str, other.str); // 4. 复制数据
return *this; // 5. 返回引用
}
专业建议:赋值运算符通常可以复用复制构造函数的逻辑,通过"拷贝-交换"惯用法实现更简洁和异常安全的代码。
4. 常见错误模式与修正方案
在实际开发中,有几个典型的错误模式经常出现。
4.1 错误的内存分配量
初学者常犯的错误是分配不足的内存:
cpp复制// 错误示例:只分配了单个char的空间
String::String(const char* s) {
len = strlen(s);
str = new char; // 只够存1个字符!
strcpy(str, s); // 缓冲区溢出!
}
修正方案是总是分配len+1的空间(+1用于终止符):
cpp复制str = new char[len+1];
4.2 不匹配的new/delete形式
另一个常见错误是混用new[]和delete:
cpp复制// 构造函数
String::String() : str(new char[10]) {}
// 错误的析构函数
String::~String() { delete str; } // 应该用delete[]
这种错误有时不会立即导致崩溃,但会破坏堆内存结构,导致难以追踪的问题。
4.3 默认构造函数的陷阱
默认构造函数也需要正确处理动态内存:
cpp复制// 危险:str未初始化
String::String() {}
// 危险:str指向字符串字面量(不可delete)
String::String() : str("default") {}
// 正确做法
String::String() : len(0), str(new char[1]) { str[0] = '\0'; }
我曾经见过一个项目,开发者将指针初始化为字符串字面量,然后在析构函数中尝试delete它,导致运行时崩溃。
5. 包含动态成员类的复制控制
当类包含其他管理动态资源的类成员时,情况会变得有趣。
5.1 自动生成的成员函数行为
考虑以下类:
cpp复制class Magazine {
String title;
std::string publisher;
};
编译器生成的默认复制构造函数会:
- 调用String的复制构造函数复制title
- 调用string的复制构造函数复制publisher
这通常就是我们想要的行为,因为String和std::string都正确实现了深度复制。
5.2 需要自定义复制控制的情形
只有在以下情况需要自定义复制控制成员:
- 类有特殊资源需要管理(如文件句柄)
- 需要修改默认的复制行为
- 类包含原始指针成员
cpp复制class SpecialMagazine {
String title;
std::string publisher;
int* specialData; // 原始指针
public:
// 需要自定义复制控制
SpecialMagazine(const SpecialMagazine& other) :
title(other.title),
publisher(other.publisher),
specialData(new int(*other.specialData)) {}
~SpecialMagazine() { delete specialData; }
};
6. 返回对象的优化技术
函数返回对象时,有多种方式可以选择,每种方式都有其适用场景。
6.1 返回const引用的适用场景
当返回的对象生命周期由调用者管理时,可以返回引用以提高效率。
cpp复制const Vector& Max(const Vector& a, const Vector& b) {
return a.magnitude() > b.magnitude() ? a : b;
}
适用条件:
- 返回的对象在函数返回后仍然存在
- 不需要修改返回的对象
6.2 返回非const引用的典型用例
主要用于支持链式操作:
cpp复制ostream& operator<<(ostream& os, const String& s) {
os << s.str;
return os;
}
String& String::operator=(const String& other) {
// 实现略
return *this;
}
6.3 返回值与临时对象
当返回局部对象时,必须返回值而非引用:
cpp复制Vector operator+(const Vector& a, const Vector& b) {
return Vector(a.x+b.x, a.y+b.y);
}
编译器通常会使用返回值优化(RVO)来消除不必要的复制。
6.4 const返回值的作用
const返回值可以防止意外的赋值操作:
cpp复制const Vector operator+(const Vector& a, const Vector& b);
(a+b) = Vector(0,0); // 错误:不能赋值给const对象
这可以捕获像if(a=b)这样的笔误(本意是if(a==b))。
7. 实战经验与性能考量
在实际项目中,动态内存管理需要考虑更多因素。
7.1 移动语义的引入(C++11)
C++11引入移动语义后,可以进一步优化资源管理:
cpp复制class String {
public:
// 移动构造函数
String(String&& other) noexcept : str(other.str) {
other.str = nullptr;
}
// 移动赋值运算符
String& operator=(String&& other) noexcept {
if (this != &other) {
delete[] str;
str = other.str;
other.str = nullptr;
}
return *this;
}
};
移动操作比复制更高效,因为它"窃取"资源而非创建副本。
7.2 异常安全考虑
动态内存操作可能抛出异常,代码应该保证异常安全:
cpp复制String& String::operator=(const String& other) {
char* newStr = new char[other.len+1]; // 先分配新内存
strcpy(newStr, other.str);
delete[] str; // 再释放旧内存
str = newStr;
len = other.len;
return *this;
}
这种实现保证了即使new抛出异常,原对象仍然保持有效状态。
7.3 小型对象优化
对于小型对象,可以考虑不使用动态分配:
cpp复制class SmallString {
static const int SSO_SIZE = 16;
union {
char* ptr;
char sso[SSO_SIZE];
};
size_t length;
bool isSSO;
public:
// 根据长度选择存储方式
SmallString(const char* s) {
size_t len = strlen(s);
if (len < SSO_SIZE) {
strcpy(sso, s);
isSSO = true;
} else {
ptr = new char[len+1];
strcpy(ptr, s);
isSSO = false;
}
}
};
这种技术被标准库中的std::string广泛使用。
8. 现代C++的最佳实践
随着C++的发展,出现了更安全的内存管理方式。
8.1 智能指针的应用
智能指针可以自动管理内存生命周期:
cpp复制#include <memory>
class SafeString {
std::unique_ptr<char[]> str;
size_t len;
public:
SafeString(const char* s) : len(strlen(s)),
str(std::make_unique<char[]>(len+1)) {
strcpy(str.get(), s);
}
// 不需要析构函数!
};
unique_ptr确保内存一定会被释放,即使发生异常。
8.2 RAII原则
资源获取即初始化(RAII)是C++的核心思想:
- 在构造函数中获取资源
- 在析构函数中释放资源
- 使用对象生命周期管理资源
cpp复制class FileHandle {
FILE* file;
public:
FileHandle(const char* name) : file(fopen(name, "r")) {
if (!file) throw std::runtime_error("Open failed");
}
~FileHandle() { if (file) fclose(file); }
// 禁用复制
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
};
8.3 规则三/五/零
- 规则三:如果需要自定义析构函数,通常也需要复制构造函数和赋值运算符
- 规则五:增加移动构造函数和移动赋值运算符
- 规则零:使用智能指针等工具,让编译器生成所有特殊成员函数
现代C++倾向于遵循规则零,减少手动资源管理。