震惊!80%的程序员都搞不懂深拷贝和浅拷贝的区别!

大家好,我是小康!

今天我们聊点和复制有关的事情——不是什么键盘的 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;
}

因为 obj1obj2 共享同一块内存,改了 obj1 的数据,obj2 也被影响了!这就是浅拷贝的问题。

浅拷贝的另一个大问题:内存释放两次

浅拷贝还有个坑,那就是当我们使用默认的拷贝构造函数进行浅拷贝时,如果两个对象都尝试释放同一块内存,就会发生 内存释放两次 的问题。具体来说,当对象销毁时,如果没有正确的拷贝构造函数,它们会试图释放同一块内存,从而导致程序崩溃或者未定义行为。

还是上面的例子:

int main() {
    MyClass obj1("Hello");
    MyClass obj2 = obj1;  // 浅拷贝,obj1 和 obj2 共享同一块内存

    obj1.print();
    obj2.print();

    return 0;            // 程序结束时,obj1 和 obj2 都会尝试释放同一块内存
}

在这个例子中,obj1obj2 通过浅拷贝共享了同一块内存。当程序结束时,obj1obj2 这两个对象析构的时候,都会调用 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;
}

通过深拷贝,obj1obj2 各自有独立的内存。改 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 使用了浅赋值。浅赋值只是简单地复制了指针,结果 obj1obj2 指向了同一块内存。所以,当我们修改 obj1 的数据时,obj2 的数据也跟着变了。就像两个朋友共享一个秘密,知道了的都一样。
  • 内存释放两次:由于 obj1obj2 指向同一块内存,当程序结束时,obj1obj2 的析构函数都会尝试释放同一块内存。这就导致了“内存释放两次”的问题,最终可能会导致程序崩溃或出现未定义行为。

微信搜索 【跟着小康学编程】,关注我,定期分享计算机编程硬核技术文章。

但这段代码其实还有其他问题:
  • 内存泄漏:浅赋值时,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;
}
深赋值的好处
  • 这样一来,obj1obj2 就各自有了独立的内存,修改 obj1 的内容就不会影响 obj2
  • 每个对象都像一个独立的个体,互不干扰, 彼此的数据完全隔离。
浅赋值的问题都解决了吗?

通过深赋值,obj1obj2 分别拥有了独立的内存,避免了共享内存的问题。而且,由于每个对象都管理自己的内存,内存泄漏和内存释放两次的问题也得到了解决。每个对象都会在析构时安全地释放自己的内存,不会发生重复释放或泄漏。

浅赋值 vs 深赋值

  • 浅赋值:只复制指针,两个对象共享同一块内存。修改一个对象的数据,另一个也会受到影响。简单,但容易出问题。
  • 深赋值:复制数据,每个对象拥有独立的内存,互不影响。稍微麻烦一些,但更安全、可靠。

小提示:如果你的类中包含动态内存(比如指针或数组),一定要小心浅赋值带来的“共享内存”问题。为了避免潜在风险,最好实现深赋值。

五、Rule of Three/Five:三部曲与五部曲

在学习深拷贝和浅拷贝的时候,有一个非常重要的规则不能忽视——Rule of Three/Five。这个规则告诉我们,如果你在类中自定义了拷贝构造函数、赋值运算符或者析构函数,就应该确保它们正确地一同实现。为什么呢?因为它们是紧密相关的,往往需要配合工作来正确管理内存,避免资源泄漏和其他潜在问题。

Rule of Three/Five:

  1. 拷贝构造函数:当你创建一个新对象时,它会从另一个对象复制数据。
  2. 赋值运算符:当你把一个已经存在的对象赋值给另一个对象时,会用到赋值运算符。
  3. 析构函数:在对象销毁时,它负责释放资源,防止内存泄漏。

如果你手动定义了其中一个(例如,拷贝构造函数),你通常还需要定义其他两个(赋值运算符和析构函数),以确保资源管理的正确性。这是因为,它们共同决定了对象如何管理资源,如果不同时定义,可能会导致内存泄漏、重复释放资源等问题。

移动构造函数和移动赋值运算符——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 分配数组或指针,尤其是需要保证对象间数据独立时。

浅拷贝虽然看起来方便,但容易导致内存共享带来的各种问题(如重复释放)。深拷贝虽稍显麻烦,但能避免这些坑,让代码更加健壮。

八、总结

今天我们聊了拷贝的两种方式:浅拷贝深拷贝

  1. 浅拷贝:复制的是指针,数据共享。它简单,但容易出问题,尤其是内存重复释放的问题。
  2. 深拷贝:复制的是数据,两个对象互不干扰。它更加安全,避免了内存共享带来的麻烦,但实现上稍微复杂一些。

然后,我们探讨了赋值运算符的作用:

  • 浅赋值:共享内存,可能导致意外的修改和内存泄漏。
  • 深赋值:确保每个对象都有独立的内存,避免问题。

对于涉及动态内存管理的类,深拷贝和深赋值更加靠谱。

最后,别忘了 Rule of Three/Five!这条规则帮助我们确保拷贝构造函数、赋值运算符、析构函数能一起正确工作,从而管理好内存。如果你用 C++11 或更高版本,记得实现 移动构造函数移动赋值运算符,它们能让你高效地转移资源,提升性能,特别是在处理大量数据时。

简单记住:

  • 浅拷贝简单但有风险,容易导致内存管理问题。
  • 深拷贝更安全,适用于需要独立内存的场景。
  • Rule of Three/Five:如果你自己定义了拷贝构造、赋值运算符或析构函数,记得一并实现它们,避免资源泄漏。
  • 现代 C++ 中,移动构造和赋值运算符 能显著提升效率,避免不必要的资源复制。

今天关于C++深浅拷贝的分享就到这里!希望你对深拷贝和浅拷贝有了更加清晰的认识。如果觉得这篇文章对你有帮助,别忘了点赞、收藏和关注哦~ 让更多人轻松搞懂这两个拷贝概念!

也欢迎大家来关注我公众号 「跟着小康学编程」,这里会持续分享计算机编程硬核技术文章!

怎么关注我的公众号?

微信搜索 【跟着小康学编程】,关注我,定期分享计算机编程硬核技术文章。

另外,小康还建了一个技术交流群,专门聊技术、答疑解惑。如果你在读文章时碰到不懂的地方,随时欢迎来群里提问!我会尽力帮大家解答,群里还有不少技术大佬在线支援,咱们一起学习进步,互相成长!

想找我?加我微信即可,微信号:jkfwdkf ,备注 「加群

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值