大家好,我是小康!
今天我们聊点和复制有关的事情——不是什么键盘的 Ctrl+C,而是 C++ 里拷贝对象的两种方式:深拷贝和浅拷贝。
说实话,刚学 C++ 的时候,这俩概念看着挺玄乎,但其实一旦搞懂了,你会发现它们没那么复杂。今天我们就用通俗易懂的方式,把深拷贝和浅拷贝讲清楚,让你一看就明白!
微信搜索 【跟着小康学编程】,关注我,定期分享计算机编程硬核技术文章。
一、什么是拷贝?深拷贝和浅拷贝又是啥?
拷贝,说白了就是复制。想象一下,你在家做饭,用菜刀切东西。你切了一块牛排,可以选择切一块完整的牛排,也可以只切牛排的影子。前者是深拷贝,后者是浅拷贝。
- 浅拷贝:只复制指针或引用,不复制实际数据。就像你切了牛排的影子,影子和牛排还挂在一起,你动牛排,影子也变。
- 深拷贝:完全复制数据,独立于原对象。就像你切下一块真正的牛排,切完你吃牛排,别人那块还在。
举个简单的 C++ 例子:
#include <iostream>
using namespace std;
class MyClass {
public:
char* data;
// 构造函数
MyClass(const char* str) {
data = new char[strlen(str) + 1];
strcpy(data, str);
}
// 浅拷贝(默认复制)
MyClass(const MyClass& other) {
data = other.data; // 只是复制了指针,没有新建数据
}
// 打印数据
void print() {
std::cout << "Data: " << data << std::endl;
}
// 析构函数
~MyClass() {
delete[] data; // 释放动态分配的内存
}
};
int main() {
MyClass obj1("Hello");
MyClass obj2 = obj1; // 浅拷贝,obj1 和 obj2 共享同一块内存
obj1.print();
obj2.print();
return 0;
}
这里,obj2
是通过浅拷贝从 obj1
复制来的。注意,它们的 data
指向同一个内存地址。
二、浅拷贝的问题:坑人的共享内存
浅拷贝的确节省时间,因为它只复制指针,没去管数据。但问题是,它容易“牵一发而动全身”。比如下面这个例子:
int main() {
MyClass obj1("Hello");
MyClass obj2 = obj1; // 浅拷贝
obj1.data[0] = 'h'; // 修改 obj1 的数据
obj1.print(); // 输出: hello
obj2.print(); // 输出: hello(obj2 也被改了)
return 0;
}
因为 obj1
和 obj2
共享同一块内存,改了 obj1
的数据,obj2
也被影响了!这就是浅拷贝的问题。
浅拷贝的另一个大问题:内存释放两次
浅拷贝还有个坑,那就是当我们使用默认的拷贝构造函数进行浅拷贝时,如果两个对象都尝试释放同一块内存,就会发生 内存释放两次 的问题。具体来说,当对象销毁时,如果没有正确的拷贝构造函数,它们会试图释放同一块内存,从而导致程序崩溃或者未定义行为。
还是上面的例子:
int main() {
MyClass obj1("Hello");
MyClass obj2 = obj1; // 浅拷贝,obj1 和 obj2 共享同一块内存
obj1.print();
obj2.print();
return 0; // 程序结束时,obj1 和 obj2 都会尝试释放同一块内存
}
在这个例子中,obj1
和 obj2
通过浅拷贝共享了同一块内存。当程序结束时,obj1
和 obj2
这两个对象析构的时候,都会调用 delete[]
来释放内存,这就导致了内存释放两次,从而引发崩溃或未定义行为。
如何避免这个问题?
我们需要手动实现 深拷贝,确保每个对象都有自己的独立内存,避免共享内存,从而避免重复释放。
深拷贝的做法就是在拷贝构造函数中,为每个对象单独分配一块新的内存,而不是直接复制指针。
三、深拷贝:各自独立,互不干扰
深拷贝则完全避免了上述问题。它会为新对象分配一块新的内存,复制数据,而不是仅仅复制指针。这样,拷贝出来的对象是独立的,谁也不影响谁。
深拷贝的实现
我们可以通过自定义拷贝构造函数来实现深拷贝:
class MyClass {
public:
char* data;
// 构造函数
MyClass(const char* str) {
data = new char[strlen(str) + 1];
strcpy(data, str);
}
// 深拷贝
MyClass(const MyClass& other) {
data = new char[strlen(other.data) + 1]; // 为新对象分配内存
strcpy(data, other.data); // 复制数据
}
// 打印数据
void print() {
std::cout << "Data: " << data << std::endl;
}
// 析构函数
~MyClass() {
delete[] data; // 释放内存
}
};
int main() {
MyClass obj1("Hello");
MyClass obj2 = obj1; // 深拷贝
obj1.data[0] = 'h'; // 修改 obj1 的数据
obj1.print(); // 输出: hello
obj2.print(); // 输出: Hello(obj2 不受影响)
return 0;
}
通过深拷贝,obj1
和 obj2
各自有独立的内存。改 obj1
的数据,obj2
不受影响。
深拷贝不会有内存重复释放的问题:
在深拷贝的情况下,每个对象都会为自己的数据分配新的内存,因此销毁对象时,每个对象都可以安全地释放它自己独立的内存,不会发生重复释放的情况。
四、赋值运算符:深浅拷贝的延续
继续聊聊“复制”这件事。前面我们讲了拷贝构造函数,现在来说说赋值运算符。它跟拷贝构造函数很像,都是为了让一个对象的内容“复制”到另一个对象。不同的是: 拷贝构造函数是在创建新对象时触发,而赋值运算符则是在已经有对象的基础上做赋值。
如果你不手动实现赋值运算符,编译器会提供默认的赋值操作。默认的赋值运算符和默认的拷贝构造函数类似,通常是浅赋值,也就是说,如果对象有指针,浅赋值同样会导致指针共享的问题。
浅赋值的陷阱
来看一个简单的例子:
#include <iostream>
#include <cstring>
using namespace std;
class MyClass {
public:
char* data;
// 构造函数
MyClass(const char* str) {
data = new char[strlen(str) + 1];
strcpy(data, str);
}
// 浅赋值
MyClass& operator=(const MyClass& other) {
if (this != &other) { // 防止自我赋值
data = other.data; // 直接复制指针
}
return *this;
}
// 打印数据
void print() const {
std::cout << "Data: " << data << std::endl;
}
// 析构函数
~MyClass() {
delete[] data; // 释放内存
}
};
int main() {
MyClass obj1("Hello");
MyClass obj2("World");
obj2 = obj1; // 浅赋值,obj2 和 obj1 共享同一块内存
obj1.print(); // 输出: Hello
obj2.print(); // 输出: Hello(obj2 也变了)
return 0;
}
和浅拷贝类似,浅赋值同样有共享内存和内存释放两次的问题:
- 共享内存的问题:在这个例子中,
obj2 = obj1
使用了浅赋值。浅赋值只是简单地复制了指针,结果obj1
和obj2
指向了同一块内存。所以,当我们修改obj1
的数据时,obj2
的数据也跟着变了。就像两个朋友共享一个秘密,知道了的都一样。
- 内存释放两次:由于
obj1
和obj2
指向同一块内存,当程序结束时,obj1
和obj2
的析构函数都会尝试释放同一块内存。这就导致了“内存释放两次”的问题,最终可能会导致程序崩溃或出现未定义行为。
微信搜索 【跟着小康学编程】,关注我,定期分享计算机编程硬核技术文章。
但这段代码其实还有其他问题:
- 内存泄漏:浅赋值时,
obj2
原本所指向的内存并没有被释放。这样就发生了内存泄漏,obj2
原来指向的内存仍然存在,但没有任何指针指向它,程序无法回收它。
深赋值:让每个对象都有自己的“秘密”
为了避免这种“共享秘密”的问题,我们可以手动实现 深赋值。深赋值会复制数据,而不仅仅是指针。这样,每个对象都有自己独立的数据,互不干扰。
来看看代码如何实现深赋值?
MyClass& operator=(const MyClass& other) {
if (this != &other) {
delete[] data; // 释放旧内存
data = new char[strlen(other.data) + 1]; // 分配新内存
strcpy(data, other.data); // 复制数据
}
return *this;
}
深赋值的好处
- 这样一来,
obj1
和obj2
就各自有了独立的内存,修改obj1
的内容就不会影响obj2
。 - 每个对象都像一个独立的个体,互不干扰, 彼此的数据完全隔离。
浅赋值的问题都解决了吗?
通过深赋值,obj1
和 obj2
分别拥有了独立的内存,避免了共享内存的问题。而且,由于每个对象都管理自己的内存,内存泄漏和内存释放两次的问题也得到了解决。每个对象都会在析构时安全地释放自己的内存,不会发生重复释放或泄漏。
浅赋值 vs 深赋值
- 浅赋值:只复制指针,两个对象共享同一块内存。修改一个对象的数据,另一个也会受到影响。简单,但容易出问题。
- 深赋值:复制数据,每个对象拥有独立的内存,互不影响。稍微麻烦一些,但更安全、可靠。
小提示:如果你的类中包含动态内存(比如指针或数组),一定要小心浅赋值带来的“共享内存”问题。为了避免潜在风险,最好实现深赋值。
五、Rule of Three/Five:三部曲与五部曲
在学习深拷贝和浅拷贝的时候,有一个非常重要的规则不能忽视——Rule of Three/Five。这个规则告诉我们,如果你在类中自定义了拷贝构造函数、赋值运算符或者析构函数,就应该确保它们正确地一同实现。为什么呢?因为它们是紧密相关的,往往需要配合工作来正确管理内存,避免资源泄漏和其他潜在问题。
Rule of Three/Five:
- 拷贝构造函数:当你创建一个新对象时,它会从另一个对象复制数据。
- 赋值运算符:当你把一个已经存在的对象赋值给另一个对象时,会用到赋值运算符。
- 析构函数:在对象销毁时,它负责释放资源,防止内存泄漏。
如果你手动定义了其中一个(例如,拷贝构造函数),你通常还需要定义其他两个(赋值运算符和析构函数),以确保资源管理的正确性。这是因为,它们共同决定了对象如何管理资源,如果不同时定义,可能会导致内存泄漏、重复释放资源等问题。
移动构造函数和移动赋值运算符——Rule of Five 的新成员
随着 C++11 引入了移动语义,Rule of Five 也随之诞生。这条规则在 Rule of Three 的基础上,新增了两个重要的函数——移动构造函数和移动赋值运算符。
这两个函数的核心思想是:与其复制资源,不如“转移”资源。具体来说,当你使用移动构造或移动赋值时,系统不会创建一个新的副本,而是将一个对象的资源直接“交给”另一个对象。这不仅避免了不必要的数据复制,还能大大提升程序的性能,尤其是在处理大量数据时,可以显著减少内存开销和时间消耗。
移动构造函数和移动赋值运算符的作用:
- 移动构造函数:它允许在创建对象时“窃取”另一个对象的资源,避免了不必要的拷贝。
- 移动赋值运算符:它允许在赋值时“窃取”另一个对象的资源,避免了拷贝操作,直接转移所有权。
代码示例:
以下是一个包含了 拷贝构造函数、赋值运算符、析构函数,以及 移动构造函数 和 移动赋值运算符 的完整例子,演示了如何遵循 Rule of Five 规则。
#include <iostream>
#include <cstring>
using namespace std;
class MyClass {
public:
char* data;
// 构造函数
MyClass(const char* str) {
data = new char[strlen(str) + 1];
strcpy(data, str);
cout << "构造函数: 创建了 " << data << endl;
}
// 拷贝构造函数
MyClass(const MyClass& other) {
data = new char[strlen(other.data) + 1];
strcpy(data, other.data);
cout << "拷贝构造函数: 拷贝了 " << data << endl;
}
// 赋值运算符
MyClass& operator=(const MyClass& other) {
if (this != &other) {
delete[] data; // 清理旧数据
data = new char[strlen(other.data) + 1];
strcpy(data, other.data);
cout << "赋值运算符: 赋值了 " << data << endl;
}
return *this;
}
// 移动构造函数
MyClass(MyClass&& other) noexcept {
data = other.data; // 直接“偷”走其他对象的内存
other.data = nullptr; // 清空源对象的数据
cout << "移动构造函数: 通过移动获得了 " << data << " (源对象现在是空的)" << endl;
}
// 移动赋值运算符
MyClass& operator=(MyClass&& other) noexcept {
if (this != &other) {
delete[] data; // 释放当前资源
data = other.data; // 转移资源
other.data = nullptr; // 源对象变为空状态
cout << "移动赋值运算符: 通过移动赋值获得了 " << data << " (源对象被清空)" << endl;
}
return *this;
}
// 析构函数
~MyClass() {
if (data != nullptr) {
delete[] data;
cout << "析构函数: 删除 " << data << endl;
}
}
void print() const {
cout << "当前数据: " << data << endl;
}
};
int main() {
cout << "\n创建 obj4 通过移动构造函数..." << endl;
MyClass obj4 = MyClass("Moved Object"); // 触发移动构造函数
cout << "\n通过移动赋值将 obj1 赋值给 obj5..." << endl;
MyClass obj5("Another Temp");
obj5 = MyClass("Moved Again"); // 触发移动赋值运算符
return 0;
}
代码讲解:
- 移动构造函数:当
obj4
被创建时,它从临时对象MyClass("Moved Object")
通过移动构造函数获得资源,而不是复制数据。此时原对象的data
被置为nullptr
,表示它不再拥有这块内存。 - 移动赋值运算符:当
obj5
被赋值为MyClass("Moved Again")
时,它通过移动赋值运算符获取新数据,同时原对象的data
被置为空。
小结一下:
- Rule of Three/Five 规则强调,如果你自定义了拷贝构造函数、赋值运算符或析构函数,必须确保它们一同正确实现,以避免资源管理错误。
- 对于现代 C++(C++11及其之后),除了拷贝构造、赋值运算符和析构函数外,建议实现移动构造函数和移动赋值运算符,这样能显著提高性能,避免不必要的资源复制,提升程序效率。
- 遵守 Rule of Three/Five 规则,可以有效避免浅拷贝和浅赋值带来的问题,确保内存的安全释放。
通过实现这些函数,我们能够确保当对象被复制或移动时,内存得到正确的管理,避免内存泄漏或重复释放等问题,从而提高程序的稳定性和性能。
六、深拷贝和浅拷贝的区别
特性 | 浅拷贝 | 深拷贝 |
---|---|---|
内存分配 | 只复制指针,共享同一块内存 | 分配新内存,复制数据 |
独立性 | 修改一个,另一个也被影响 | 两者独立,互不干扰 |
实现复杂度 | 默认行为,简单易用 | 需要手动实现,稍复杂 |
性能 | 快,但可能带来隐患 | 稍慢,但安全可靠 |
内存释放问题 | 可能出现重复释放或内存泄漏问题 | 不会发生重复释放或泄漏 |
七、什么时候用浅拷贝,什么时候用深拷贝?
- 浅拷贝:适用于对象只包含简单数据(如整数或浮点数)的情况,此时内存共享不会出问题。
- 深拷贝:适用于有动态内存分配的类,比如通过
new
分配数组或指针,尤其是需要保证对象间数据独立时。
浅拷贝虽然看起来方便,但容易导致内存共享带来的各种问题(如重复释放)。深拷贝虽稍显麻烦,但能避免这些坑,让代码更加健壮。
八、总结
今天我们聊了拷贝的两种方式:浅拷贝和深拷贝。
- 浅拷贝:复制的是指针,数据共享。它简单,但容易出问题,尤其是内存重复释放的问题。
- 深拷贝:复制的是数据,两个对象互不干扰。它更加安全,避免了内存共享带来的麻烦,但实现上稍微复杂一些。
然后,我们探讨了赋值运算符的作用:
- 浅赋值:共享内存,可能导致意外的修改和内存泄漏。
- 深赋值:确保每个对象都有独立的内存,避免问题。
对于涉及动态内存管理的类,深拷贝和深赋值更加靠谱。
最后,别忘了 Rule of Three/Five!这条规则帮助我们确保拷贝构造函数、赋值运算符、析构函数能一起正确工作,从而管理好内存。如果你用 C++11 或更高版本,记得实现 移动构造函数 和 移动赋值运算符,它们能让你高效地转移资源,提升性能,特别是在处理大量数据时。
简单记住:
- 浅拷贝简单但有风险,容易导致内存管理问题。
- 深拷贝更安全,适用于需要独立内存的场景。
- Rule of Three/Five:如果你自己定义了拷贝构造、赋值运算符或析构函数,记得一并实现它们,避免资源泄漏。
- 现代 C++ 中,移动构造和赋值运算符 能显著提升效率,避免不必要的资源复制。
今天关于C++深浅拷贝的分享就到这里!希望你对深拷贝和浅拷贝有了更加清晰的认识。如果觉得这篇文章对你有帮助,别忘了点赞、收藏和关注哦~ 让更多人轻松搞懂这两个拷贝概念!
也欢迎大家来关注我公众号 「跟着小康学编程」,这里会持续分享计算机编程硬核技术文章!
怎么关注我的公众号?
微信搜索 【跟着小康学编程】,关注我,定期分享计算机编程硬核技术文章。
另外,小康还建了一个技术交流群,专门聊技术、答疑解惑。如果你在读文章时碰到不懂的地方,随时欢迎来群里提问!我会尽力帮大家解答,群里还有不少技术大佬在线支援,咱们一起学习进步,互相成长!
想找我?加我微信即可,微信号:jkfwdkf ,备注 「加群」