14、类与对象(下):拷贝构造函数、深拷贝与浅拷贝、拷贝赋值运算符、移动构造函数与移动赋值运算符、RAII原则
好,咱们继续往下聊。上一章我们把构造函数和析构函数讲透了,这一章要聊的,是C++里最容易踩坑、也最能体现功底的地方——拷贝和移动。
说实话,我见过太多人写了几年C++,还在为浅拷贝的问题头疼。我自己刚入行那会儿,也因为这个bug熬过好几个通宵。嗯,今天咱们就把这块彻底掰扯清楚。
14.1 拷贝构造函数:什么时候会被调用?
拷贝构造函数,说白了就是用已存在的对象去初始化新对象。它的签名长这样:
class Widget {
public:
Widget(const Widget& other); // 拷贝构造函数
// ...
};
什么时候会触发它?三种情况:
- 用对象直接初始化另一个对象:
Widget w2 = w1; - 函数传参时按值传递:
void func(Widget w); - 函数返回对象时按值返回:
Widget createWidget();
你想想看,如果编译器没做优化,每次拷贝都要完整复制一份,代价可不小。不过现代编译器有RVO(返回值优化),这个我们后面再聊。
小提示:我个人习惯把拷贝构造函数的参数声明为const引用。为什么?因为如果不是引用,传参时又会触发拷贝构造,死循环了。你试试看就知道了。
14.2 浅拷贝 vs 深拷贝:一个指针引发的血案
这是C++新手最容易翻车的地方。我当年第一次写带指针的类,就栽在这里。
浅拷贝:只是简单地把成员变量的值复制过去。对于指针,复制的是地址,不是指针指向的数据。
深拷贝:不仅复制指针本身,还要复制指针指向的那块内存。
看个例子:
class StringBad {
private:
char* str;
int len;
public:
StringBad(const char* s) {
len = strlen(s);
str = new char[len + 1];
strcpy(str, s);
}
// 默认拷贝构造函数——浅拷贝!
// StringBad(const StringBad& other)
// : str(other.str), len(other.len) {} // 两个对象指向同一块内存
~StringBad() {
delete[] str; // 两个对象析构时,同一块内存被释放两次!
}
};
为什么会这样?两个对象共享同一块内存,第一个析构时释放了,第二个析构时再释放——程序直接崩溃。
解决办法就是自己写深拷贝:
class StringGood {
private:
char* str;
int len;
public:
StringGood(const char* s) {
len = strlen(s);
str = new char[len + 1];
strcpy(str, s);
}
// 深拷贝构造函数
StringGood(const StringGood& other) {
len = other.len;
str = new char[len + 1]; // 重新分配内存
strcpy(str, other.str); // 复制数据
}
~StringGood() {
delete[] str;
}
};
警告:我曾经在一个项目中,因为忘记写深拷贝构造函数,导致两个对象共享同一个文件句柄。一个对象关闭了文件,另一个还在用——结果就是诡异的"文件已关闭"错误,查了两天才找到根因。记住:只要类里有指针成员,大概率需要深拷贝。
14.3 拷贝赋值运算符:不只是拷贝
拷贝赋值运算符和拷贝构造函数很像,但有本质区别:
- 拷贝构造函数:创建新对象时调用
- 拷贝赋值运算符:已有对象被赋值时调用
它的标准写法,我建议你记住这个模式:
class StringGood {
public:
StringGood& operator=(const StringGood& other) {
// 1. 自赋值检查
if (this == &other) {
return *this;
}
// 2. 释放旧资源
delete[] str;
// 3. 分配新资源并复制数据
len = other.len;
str = new char[len + 1];
strcpy(str, other.str);
// 4. 返回自身引用
return *this;
}
};
这里有个细节:为什么要先释放旧资源?因为当前对象可能已经持有了一块内存,不释放就泄漏了。为什么要检查自赋值?因为如果自己给自己赋值,先释放再分配就出事了——数据都没了。
核心要点:拷贝赋值运算符必须处理自赋值、释放旧资源、深拷贝新资源、返回引用这四个步骤。少一步都不行。
14.4 移动构造函数与移动赋值运算符:C++11的礼物
说实话,C++11之前,临时对象的拷贝效率是个大问题。你想想看,一个函数返回一个很大的vector,要完整拷贝一份,多浪费啊。
移动语义就是来解决这个问题的。它的核心思想:偷走临时对象的资源,而不是复制。
class Buffer {
private:
int* data;
size_t size;
public:
// 移动构造函数
Buffer(Buffer&& other) noexcept
: data(other.data), size(other.size) {
other.data = nullptr; // 把源对象置为空
other.size = 0;
}
// 移动赋值运算符
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data; // 释放自己的资源
data = other.data; // 偷走对方的资源
size = other.size;
other.data = nullptr; // 对方不再持有资源
other.size = 0;
}
return *this;
}
};
注意那个noexcept关键字。为什么加它?因为标准库容器(比如vector)在重新分配内存时,如果移动操作不抛异常,就会优先用移动;否则只能用拷贝。加了noexcept,性能能提升不少。
经验之谈:我建议你养成习惯,只要写了移动构造函数和移动赋值运算符,就加上noexcept。这不仅是好习惯,还能让标准库容器更高效地使用你的类。
14.5 RAII原则:资源管理的终极答案
RAII,全称是"资源获取即初始化"。名字听着高大上,说白了就是:把资源和对象的生命周期绑定在一起。
资源在构造函数中获取,在析构函数中释放。这样,只要对象活着,资源就在;对象死了,资源自动释放。
你想想看,这解决了什么问题?异常安全。如果代码中途抛出异常,栈展开时会自动调用析构函数,资源就不会泄漏。
class FileGuard {
private:
FILE* file;
public:
FileGuard(const char* filename, const char* mode) {
file = fopen(filename, mode);
if (!file) {
throw std::runtime_error("文件打开失败");
}
}
~FileGuard() {
if (file) {
fclose(file);
}
}
// 禁止拷贝
FileGuard(const FileGuard&) = delete;
FileGuard& operator=(const FileGuard&) = delete;
// 允许移动
FileGuard(FileGuard&& other) noexcept
: file(other.file) {
other.file = nullptr;
}
void write(const char* data) {
fprintf(file, "%s", data);
}
};
这个类用起来很安全:
void processData() {
FileGuard guard("output.txt", "w");
guard.write("Hello, RAII!");
// 不管这里是否抛出异常,guard析构时都会关闭文件
}
注意:RAII类通常应该禁止拷贝,或者实现深拷贝/移动语义。为什么?因为如果两个对象共享同一个文件句柄,一个析构时关闭了文件,另一个就成野指针了。我曾经在一个遗留代码里看到过这种bug,排查起来特别痛苦。
14.6 知识体系总览
这一章的内容比较多,我画了张图帮你理清关系:
这张图把这一章的核心概念串起来了。你看,拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符、析构函数——这五个特殊成员函数构成了C++资源管理的基石。而RAII原则,就是把这些函数串起来的那根线。
最后说一句:写C++类的时候,我建议你遵循"三五法则"——如果你需要显式定义析构函数、拷贝构造函数、拷贝赋值运算符中的任何一个,那么大概率三个都需要。C++11之后,移动构造函数和移动赋值运算符也加入了这个行列。
好了,这一章的内容就到这里。记住:资源管理是C++的核心能力,而拷贝和移动是资源管理的核心操作。把这些搞明白了,你的C++水平会上一个大台阶。