02-5 深拷贝和浅拷贝
深拷贝和浅拷贝是一个经典的面试题目,也是一个常见的坑。(虽然我认为面试对我没啥用途......)
- 浅拷贝:简单的赋值拷贝操作
- 深拷贝:在堆区重新申请内存空间,进行拷贝操作
如果在类中,有属性是指向堆区的指针,做赋值操作的时候就会出现深拷贝和浅拷贝的问题。
简单来说,就是在类中,如果有成员属性是一个指针,那么在浅拷贝中,就会直接将这个地址拷贝到新的成员属性中。但是新旧两个含有指针的两个成员属性记录的内存地址是完全一样的。而深拷贝,则是使用new关键字在堆区重新创建一块内存存储数据,以防止旧对象删除而释放指针指向的数据。
我们先创建一个类:
#include <iostream>
using namespace std;
class Person
{
public:
    int m_age;
    int* m_height;
    Person()
    {
        cout << "这是 Person 的默认构造函数调用。" << endl;
    }
    Person(int age)
    {
        m_age = age;
        cout << "这是 Person 的有参构造函数1调用。" << endl;
    }
    Person(int age, int height)
    {
        m_age = age;
        m_height = new int(height);
        cout << "这是 Person 的有参构造函数2调用。" << endl;
    }
    ~Person()
    {
        if (m_height = !NUll)
        {
            delete m_height;
            m_height = NULL;
        }
        cout << "这是 Person 的析构函数调用。" << endl;
    }
};在这个类中,有两个属性:m_age和m_height,我们提供了无参构造函数、有参构造函数和析构函数。
在这里,析构函数用于将开辟的堆区数据清除。
if (m_height != NULL)
{
    delete m_height;
    m_height = NULL;
}因为我们在创建类的时候,创建了一个int* m_height;。其中,属性m_height存储的是一个内存地址的编号,并不是一个真正的数据值。
我们在有参构造的时候需要传入两个数据,一个是age,另外一个是height。 age 会简单的赋值给类内的 m_age,而 height 使用的是new操作符赋值给类内的 m_height 。
- 
防止忘记: new操作符:在堆区创建一块内存存储数据,其返回值是一个内存地址。比如说: int* p = int new(10);这里, p记录的是一块内存地址的编号,这块内存存放着数据10。而对p进行解引用(*p),则可以还原出这块内存地址上存储的数据。
而析构函数,在这里是将堆区的数据删除,而new创建的数据就在堆区。而上面的if (m_height = !NUll),是先检测指针m_height是否为空,如果不为空,则释放存放在m_height中的内存编号的数据;为了防止野指针的出现,我们最后将这个指针赋值为NULL。
有了这样的析构函数,就会完全释放出堆区数据。
注意,我们的有参构造函数中,出现了重载:
Person(int age)
{
    m_age = age;
    cout << "这是 Person 的有参构造函数1调用。" << endl;
}
Person(int age, int height)
{
    m_age = age;
    m_height = new int(height);
    cout << "这是 Person 的有参构造函数2调用。" << endl;
}- 
浅拷贝 void main() { class Person p1(1); cout << "p1的年龄" << p1.m_age << endl; class Person p2(p1); cout << "p2的年龄" << p2.m_age << endl; }运行结果:(环境:Windows11(arm/Apple M VM)/Visual Studio 2022/Debug/arm64) 这是 Person 的有参构造函数1调用。 p1的年龄18 p2的年龄18 这是 Person 的析构函数调用。 这是 Person 的析构函数调用。p2也会被成功赋值:这是因为我们没有提供拷贝构造函数,所以编译器会自动提供一个拷贝构造函数,会把所以的数据值拷贝到新创建的对象上。而如果我们这样写: void main() { class Person p1(18, 158); cout << "p1的年龄" << p1.m_age << endl; cout << "p1的身高" << *p1.m_height << endl; class Person p2(p1); cout << "p2的年龄" << p2.m_age << endl; cout << "p2的身高" << *p2.m_height << endl; }则会发现程序崩溃!!! 这是因为使用 class Person p2(p1);的时候,是使用编译器提供的拷贝构造函数。编译器提供的拷贝构造函数会做浅拷贝。在这个例子里面,编译器提供的拷贝构造函数是这样的: Person(const class Person& p) { m_age = p.m_age; m_height = p.m_height; }比如说,上面的例子中(注意一下, m_age存储的是数据,而m_height存储的是一个地址):我们创建了 p1,ta 里面的属性如下:- p1.age数据值为- 18;
- p1.height数据值为- 0x0011;(当然则只是表示一个内存空间的地址,每次分配的内存空间地址是不一样的)
 但是我们使用 class Person p2(p1);,由于使用的是浅拷贝,所以会完全一模一样的拷贝p1的数据。使用class Person p2(p1);创建的p2属性如下:- p2.age数据值为- 18;
- p2.height数据值为- 0x0011;
 当我们使用析构函数的时候, p1和p2都会被析构。在堆区存放的数据遵循:先进后出。而在上面的例子中,p2的数据会清空。当清空p2的时候,会运行一次析构函数。由于p2和p1的m_height数据都是地址0x0011,所以在p2释放的时候,0x0011的数据已经释放掉了!之后p1再次释放的时候就是非法操作内存了。可能这样不能理解: 在 p2释放前,p2.height指向0x0011。此时执行析构函数,发现p2.height不是NULL,于是清空0x0011内存地址上存放的数据,并且将p2.height的指向修改为NULL。由于堆区数据采取的是先进后出,所以 p2被释放后会再次释放p1。但是,p1.m_height的数据值也是0x0011,符合if (m_height != NULL)。所以会再一次删除0x0011。但是此时0x0011并没有数据,所以是非法操作内存,最终导致程序崩溃。简单来说:浅拷贝带来的问题是:堆区数据重复释放。 
- 
深拷贝 深拷贝会重新创建一块内存空间存放数据。 比如上面的例子中, p1.m_height存放的是0x0011。利用深拷贝,就会拷贝出p2.m_height存放的数据为0x0022。而这个0x0022和0x0011内存中,存放的数据是一样的。这样堆区存放的数据一样,但是指针指向的内存地址是不同的。 为了实现深拷贝,我们需要自己重新写一下拷贝构造函数。(原因:编译器提供的拷贝构造函数是浅拷贝。) Person(const class Person& p) { m_age = p.m_age; m_height = new int(*p.m_height); cout << "这是 Person 的拷贝构造函数调用。" << endl; }m_height = new int(*p.m_height);这一句命令的意思是,先解引用出传入的p.m_height指向数据值,然后在使用new创建出一块新的内存空间存放p.m_height指向的数据值。最后将新的地址赋值给m_height。最后我们试一下: void main() { class Person p1(18, 158); cout << "p1的年龄" << p1.m_age << endl; cout << "p1的身高" << *p1.m_height << endl; class Person p2(p1); cout << "p2的年龄" << p2.m_age << endl; cout << "p2的身高" << *p2.m_height << endl; }运行结果:(环境:Windows11(arm/Apple M VM)/Visual Studio 2022/Debug/arm64) 这是 Person 的有参构造函数2调用。 p1的年龄18 p1的身高158 这是 Person 的拷贝构造函数调用。 p2的年龄18 p2的身高158 这是 Person 的析构函数调用。 这是 Person 的析构函数调用。
总结:
- 什么是浅拷贝:简单的赋值操作就是浅拷贝(主要出现在指针里面)。
- 上面是深拷贝:在堆区重新申请内存空间,将原地址指向的数据写入新的内存空间。
- 什么时候会用到析构:在堆区创建空间后,需要使用析构函数释放申请的堆区内存空间。
- 如果属性有在堆区开辟的地址,一定要自行写一个拷贝构造函数进行深拷贝,用来防止出现浅拷贝的堆区数据重复释放的问题。