[C.C++] 【C++】揭秘类与对象的内在机制(核心卷之深浅拷贝与拷贝构造函数的奥秘)

545 0
Honkers 2025-3-5 17:33:54 | 显示全部楼层 |阅读模式


   前景提要:该篇文章的内容接上一篇,希望大家可以先学习上一篇文章讲到的构造函数和析构函数,否则可能会看不懂,文章链接如下:【C++】揭秘类与对象的内在机制(核心卷之构造函数与析构函数的奥秘)

一、前置知识—深浅拷贝

   在讲拷贝构造和赋值重载函数的知识点之前,我们必须先学习一些有关 “拷贝” 的前置知识,因为它们都涉及到了深浅拷贝,浅拷贝就是只拷贝对应对象的值给当前对象,一般是一个字节一个字节地进行拷贝,而深拷贝则会先另外申请空间,然后再将源空间的数据拷贝到目标空间

   根据上面的描述,我们大致猜出来深浅拷贝还是跟堆上的空间有很大的联系,确实是这样,大部分拷贝出问题都是因为我们申请了堆上的空间,接下来我们就详细地讲讲,什么时候使用浅拷贝,什么时候使用深拷贝

1. 浅拷贝

   如果一个变量是内置类型,并且它没有指向堆上的空间,那么这个变量拷贝给其它对象时就可以使用浅拷贝,不会产生不良影响,如下:

  1. #include <iostream>
  2. using namespace std;
  3. int main()
  4. {
  5. int a = 10;
  6. //平常的赋值操作就是浅拷贝
  7. int b = a;
  8. cout << "a : " << a << endl;
  9. cout << "b : " << b << endl;
  10. return 0;
  11. }
复制代码

   可以看到这里我们直接使用了赋值操作将内置类型int的变量a赋值给了b,这就属于浅拷贝的范畴,这段代码也肯定没有问题,但是我们还是来看看它的运行结果,如下:

   可以看到代码没有问题,那么如果我们把浅拷贝变量这个知识点引申到浅拷贝类当中去呢?那就应该是:如果当前类的成员变量都是内置类型,并且这些成员变量没有指向堆上的空间,比如日期类,那么这个类的拷贝可以通过浅拷贝完成,如下:

  1. #include <iostream>
  2. using namespace std;
  3. class Date
  4. {
  5. public:
  6. Date(int year = 2025, int month = 1, int day = 1)
  7. {
  8. this->_year = year;
  9. this->_month = month;
  10. this->_day = day;
  11. }
  12. void Print()
  13. {
  14. cout << _year << "-" << _month << "-" << _day << endl;
  15. }
  16. private:
  17. int _year;
  18. int _month;
  19. int _day;
  20. };
  21. int main()
  22. {
  23. Date d1;
  24. Date d2 = d1;
  25. cout << "d1:";
  26. d1.Print();
  27. cout << "d2:";
  28. d2.Print();
  29. return 0;
  30. }
复制代码

   可以看到这里我们将d1直接赋值给了d2(其实这不是赋值操作,但是功能类似,我们后面讲拷贝构造时会详细讲,现在暂时理解为赋值),按照我们的预期,代码应该不会出任何问题,我们来看看运行结果,如下:

   可以看到代码确实没有问题,这就是我们的浅拷贝,也叫值拷贝,它的底层原理就是一个字节一个字节地进行拷贝

2. 深拷贝

   如果一个变量指向了堆上的一块空间,那么我们对它进行拷贝时需要进行深拷贝,也就是重新开辟一块空间,然后再将源空间的数据拷贝到目标空间,我们先不说为什么要新开一块空间再拷贝,我们先来讲讲如果一个变量指向了堆上的一块空间,但是我们使用了浅拷贝会发生什么,如下:

  1. #include <iostream>
  2. using namespace std;
  3. int main()
  4. {
  5. int* arr = (int*)malloc(5 * sizeof(int));
  6. if (arr == nullptr)
  7. {
  8. perror("malloc");
  9. return -1;
  10. }
  11. for (int i = 0; i < 5; i++)
  12. {
  13. arr[i] = i + 1;
  14. }
  15. int* tmp = arr;
  16. tmp[1] = 1000;
  17. for (int i = 0; i < 5; i++)
  18. {
  19. cout << arr[i] << " ";
  20. }
  21. cout << endl;
  22. for (int i = 0; i < 5; i++)
  23. {
  24. cout << tmp[i] << " ";
  25. }
  26. free(arr);
  27. free(tmp);
  28. return 0;
  29. }
复制代码

   这里我们从堆上申请了5个整型大小的空间来使用,随后对它们进行填充数据,然后我们就直接使用了浅拷贝的方式将arr拷贝给了tmp,然后我们对tmp的第二个元素做了修改,最后将它们进行释放,可能大家现在至少找到了一个问题,我们暂时先不管,先来看看代码运行的结果,如下:

   可以看到代码运行出错了,这就是如果一个变量指向了堆上的空间,却使用了浅拷贝导致的第一个问题,就是可能导致同一块空间被释放两次,因为arr指向的空间和tmp指向的空间其实是同一块空间,因为arr中存储的值是一块堆上空间的首地址,在拷贝时直接将arr中的值拷贝给了tmp,tmp也拿到了这块空间的地址,它们指向同一块空间,如下:

   然后我们在释放空间的时候,既释放了arr,又释放了tmp,但是它们又指向同一块空间,所以导致了空间的二次释放,最终程序崩溃了,可能就有人会说,那我释放的时候只释放一个不就好了,问题也不大啊?

   其实这个问题还真不小,首先我们是有可能忘记这件事的,最后还会导致空间被重复释放,其次,如果变量指向了堆上的空间,我们依然全部采用浅拷贝拷贝出很多变量,很可能会导致变量间的关系混乱不清,就算记住了要小心释放,也难免出错

   并且除了上面空间重复释放的问题,这里使用浅拷贝还有另外一个大坑,我们现在先将释放tmp的那条语句注释,然后运行代码看看有没有问题:

   代码这次虽然没有报错了,但是其实有很明显的逻辑错误,我们修改了tmp[1]的值,结果打印出来发现arr[1]的值也发生了变化,原因就是上面说的它们指向同一块空间,但是这并不是我们预期的结果呀,我们预期它们两个应该互不干涉呀!所以这也是这种场景下浅拷贝的一个劣势

   最后我们总结一下,如果一个变量指向了堆上的空间,对这个变量进行浅拷贝,我们会引发两个大问题,一个是空间重复释放的问题,一个是他们共用一块空间,其中一方修改了空间上的值,另一方也会跟着被改变,这是我们不期望看到的结果
   所以我们在这种情况下才必须使用深拷贝,也就是先新申请一块空间,新空间的大小至少要和源空间相等,然后再将源空间的数据拷贝过来,如下:

  1. int main()
  2. {
  3. int* arr = (int*)malloc(5 * sizeof(int));
  4. if (arr == nullptr)
  5. {
  6. perror("malloc");
  7. return -1;
  8. }
  9. for (int i = 0; i < 5; i++)
  10. {
  11. arr[i] = i + 1;
  12. }
  13. //深拷贝,先开辟同样大小的空间
  14. int* tmp = (int*)malloc(5 * sizeof(int));
  15. if (tmp == nullptr)
  16. {
  17. perror("malloc");
  18. return -1;
  19. }
  20. //进行数据的拷贝
  21. for (int i = 0; i < 5; i++)
  22. {
  23. tmp[i] = arr[i];
  24. }
  25. tmp[1] = 1000;
  26. for (int i = 0; i < 5; i++)
  27. {
  28. cout << arr[i] << " ";
  29. }
  30. cout << endl;
  31. for (int i = 0; i < 5; i++)
  32. {
  33. cout << tmp[i] << " ";
  34. }
  35. free(arr);
  36. free(tmp);
  37. }
复制代码

   最后我们来看看代码的运行结果,看看现在是否还会出现同一块空间被重复释放,修改一方影响另一方的问题,如下:

   可以看到代码没有出现上面说的那两种问题,所以我们在拷贝时碰到一个变量指向堆上的空间,那么就要使用深拷贝的方式进行拷贝,即先申请空间,再将源空间的数据拷贝到目标空间

   那么对于类类型的对象也是如此,如果一个对象中的成员变量指向了堆上的空间,那么我们如果想将这个对象拷贝给另一个对象就必须使用深拷贝,否则也会出现上面的那两个问题,并且在类中这两个问题更容易出现,更不易被发现,我们在下面的拷贝构造函数的讲解中进行解释

1. 拷贝构造函数

   拷贝构造函数时一个特殊的构造函数,特殊到我们可以单独把它分为一类,但是它的核心功能还是给一个刚刚开辟好的对象进行初始化,只是它和普通的构造函数不同,它的第一个参数必须是当前类类型对象的引用,拷贝构造会根据这个对象来对当前对象进行初始化

   我们可以举一个例子进行说明,由于拷贝构造函数也是一种构造函数,所以只能在创建新对象或者传值传参时会用到,我们后面会细讲,现在我们只要知道最普通的拷贝构造函数的使用就是在新创建对象时使用,如下:

  1. #include <iostream>
  2. using namespace std;
  3. class Date
  4. {
  5. public:
  6. Date(int year = 2025, int month = 1, int day = 1)
  7. {
  8. this->_year = year;
  9. this->_month = month;
  10. this->_day = day;
  11. }
  12. private:
  13. int _year;
  14. int _month;
  15. int _day;
  16. };
  17. int main()
  18. {
  19. //调用构造函数初始化对象d1
  20. Date d1(2025, 1, 12);
  21. //调用默认拷贝构造函数初始化d2
  22. //d2的初始化完全依据传来的d1对象
  23. Date d2(d1);
  24. return 0;
  25. }
复制代码

   可以看到上面我们就使用了拷贝构造函数对d2进行初始化,并且d2的初始化是依据d1决定的,我们来看看代码运行结果:

   可以看到代码没有问题,这就是一种拷贝初始化,当然,C++为了提升可读性,也提供了另外一种写法,如下:

  1. //这里不是赋值操作,而是拷贝构造
  2. //等价于Date d2(d1);
  3. Date d2 = d1;
复制代码

   此时我们要注意,上面这条语句不是赋值,而是调用了拷贝构造对d2进行了初始化,和使用括号是一个含义,不要误认为是赋值操作了,这只是C++为了提升可读性而提供的另一种调用拷贝构造的写法

   那么现在我们简单接触了拷贝构造函数,接下来我们还是按照编译器自动生成的拷贝构造能干嘛?如果编译器自动生成的拷贝构造不够用,又该怎么写一个正确的拷贝构造函数来学习拷贝构造函数

1. 默认生成的拷贝构造函数能干什么?

   1. 对于内置类型的成员来说,编译器自动生成的拷贝构造函数是直接把传过来的对象浅拷贝到新对象中,也就是进行值拷贝,一个字节一个字节进行拷贝,这也是为什么我们讲拷贝构造之前讲深浅拷贝的原因,我们必须理解默认生成的拷贝构造的底层是浅拷贝一个对象
   我们在上面的深浅拷贝部分也讲过,只要我们的类中没有成员指向堆中的空间,那么使用浅拷贝即可,也就是当我们给一个对象进行拷贝构造初始化时,只要对象中的成员没有指向堆中的空间,那么直接用编译器默认生成的拷贝构造即可,因为默认生成的拷贝构造函数就采用的是浅拷贝的方式
   比如日期类,它的成员都是int类型的变量,没有指向堆上的空间,所以我们上面演示的时候,使用默认的拷贝构造才没有出错,如果内置类型的成员变量指向了堆上的空间,就涉及到深拷贝了,我们必须自己写拷贝构造,我们在下一部分讲解

   2. 对于自定义类型的成员来说,编译器自动生成的拷贝构造函数则是,直接调用对应自定义类型成员的拷贝构造对自定义类型的成员进行初始化,和我们前面讲的构造和析构的原理相似,这里就不多讲了

   那么上面就是我们对默认生成的拷贝构造函数的分析,这里简单总结一下,对于内置类型的成员,默认的拷贝构造会进行浅拷贝,所以如果内置类型的成员指向了堆上的空间就不能使用默认的拷贝构造,需要自己写,而对于自定义类型的成员则简单多了,就是直接去调用自定义类型的拷贝构造对自定义成员进行初始化

2. 怎么写拷贝构造函数

   由于编译器默认生成的拷贝构造函数已经实现了浅拷贝,所以需要我们写拷贝构造的场景一般都是需要深拷贝,只需要浅拷贝的场景通通不用自己写,为了更好地知道什么时候需要写,什么时候不需要写,我们下面总结两个小技巧
   一种技巧是只看当前类中是否出现了指向堆上资源的内置类型成员,只要出现了我们就必须自己写拷贝构造函数,另一种技巧就是,我们去看有没有显示地写析构函数,如果显示写了析构,说明类中有资源需要释放,所以我们此时也必须要手动地写拷贝构造

   那么说清楚了什么时候必须要写拷贝构造,那么我们接下来就来一起学习一下怎么写拷贝构造函数,我们还是先罗列出它的简单特点方便我们理解,如下:
   1. 拷⻉构造函数是构造函数的⼀个重载,也就是拷贝构造和构造函数的函数名相同,只是参数不同
   2. 拷⻉构造函数也可以多个参数,但是第⼀个参数必须是当前类类型对象的引⽤,否则编译器会报错,我们之后会讲原因,第一个参数后⾯的参数必须有缺省值
   3. C++规定⾃定义类型对象进行拷贝行为必须调⽤拷⻉构造,所以自定义类型传值传参和传值返回都会调⽤拷⻉构造完成

   现在我们大致知道了写拷贝构造需要什么了,首先就是拷贝构造函数名和构造函数名相同,都是类名,并且拷贝构造的第一个参数必须是一个当前类类型对象的引用,现在我们就借这个机会写一下Stack类的拷贝构造,顺便复习构造和析构,如下:

  1. #include <iostream>
  2. using namespace std;
  3. class Stack
  4. {
  5. public:
  6. //为了方便初学者理解,后面的代码还是带上this指针
  7. //默认构造(全缺省版本)
  8. Stack(int n = 10)
  9. {
  10. this->_arr = (int*)malloc(n * sizeof(int));
  11. if (this->_arr == nullptr)
  12. {
  13. perror("malloc");
  14. return;
  15. }
  16. this->_top = 0;
  17. this->_capacity = n;
  18. }
  19. //析构
  20. ~Stack()
  21. {
  22. if (this->_arr)
  23. free(this->_arr);
  24. this->_arr = nullptr;
  25. this->_top = this->_capacity = 0;
  26. }
  27. //拷贝构造,第一个参数必须是当前类类型的对象的引用
  28. //后面可以有其它参数,但是必须有缺省值
  29. Stack(const Stack& st)
  30. {
  31. this->_arr = (int*)malloc(sizeof(int) * st._capacity);
  32. if (this->_arr == nullptr)
  33. {
  34. perror("malloc");
  35. return;
  36. }
  37. for (int i = 0; i < st._top; i++)
  38. {
  39. this->_arr[i] = st._arr[i];
  40. }
  41. this->_top = st._top;
  42. this->_capacity = st._capacity;
  43. }
  44. //这里写一个插入函数方便后面调试
  45. void push(int x)
  46. {
  47. this->_arr[_top++] = x;
  48. }
  49. private:
  50. int* _arr;
  51. int _top;
  52. int _capacity;
  53. };
  54. int main()
  55. {
  56. Stack st1(20);
  57. st1.push(5);
  58. st1.push(2);
  59. Stack st2(st1);
  60. return 0;
  61. }
复制代码

   接下来我们就来调试一下,看看我们写的这个拷贝构造有没有帮我们实现深拷贝,我们等下也可以试着把这里的深拷贝改成浅拷贝,看看会不会出错,现在我们先测试一下这段刚刚写的代码,如下:

   可以看到代码完全没有问题,我们既重新给st2开辟了空间(从st1和st2的地址不同看出),又成功拷贝了数据,所以我们这个拷贝构造函数是没有问题的,接下来我们把这里的深拷贝改成浅拷贝看看会发生什么,做法就是直接注释掉我们写的拷贝构造,直接使用编译器生成的浅拷贝拷贝构造,运行结果如下:

   可以看到代码直接报错了,这是因为st1和st2使用了浅拷贝,它们两个的成员变量_arr指向了同一块空间,导致st1调用析构函数释放了一次这块空间,st2调用析构函数又释放了一次这块空间,最终大致程序出错

   当然,这里浅拷贝还有另外一个问题,就是它们占用同一块空间,所以st1作出的修改会影响st2,st2作出的修改会影响st1,导致不符合预期要求,所以这里我们必须向上面一样,自己写一个深拷贝的拷贝构造函数

   上面就是拷贝构造的基本知识,我们现在来更加深入研究一下拷贝构造函数还会在哪里被用到,以及怎么用的,同时探讨一下为什么第一个参数必须是当前类类型对象的引用,不加引用又会发生什么?

   拷贝构造函数还会在函数的传值调用以及中被用到,我们都知道,调用函数时会开辟新的函数栈帧,里面会存放我们的形参,如果我们采用传值传参一个类对象,那么编译器会调用拷贝构造函数对它进行初始化,如下:

  1. Stack Func(Stack st)
  2. {
  3. return st;
  4. }
  5. int main()
  6. {
  7. Stack st1(20);
  8. st1.push(5);
  9. st1.push(2);
  10. Stack st2 = Func(st1);
  11. return 0;
  12. }
复制代码

   大家猜一猜上面的代码中一共会调用多少次拷贝构造,语法层面上一共会调用三次拷贝构造,是不是有点意外,当然,编译器可能会做优化,这个我们后面再说,我们先来看看语法层面的三次拷贝构造怎么来的

   首先我们要进行传值传参,要将实参st1传值传参给形参st,这里会直接将st1拷贝构造一个临时对象出来当作形参,在这个函数中对这个临时对象的修改就是对形参st的修改,可以这样说,这个拷贝构造出来的临时对象就是我们的形参,这是上面代码中的第一次拷贝构造

   接下来就该进行传值返回了,这里就稍微有点不一样了,因为我们的形参st在函数结束后会调用析构进行销毁,所以我们的形参st是不能直接作为返回值的,那么该怎么办呢?这是还是会借助临时对象,我们会重新用st拷贝构造一个临时对象作为返回值,这是第二次拷贝构造

   最后一次拷贝构造就出现在函数调用结束后,返回了一个临时对象,将这个临时对象拷贝构造给st2,这次拷贝构造结束之后,这个返回的临时对象也就跟着析构了,所以综上,上面的代码中至少出现了三次拷贝构造,我们再画个图方便理解:

   但是其实一些强大的编译器会对上面的过程作优化,比如在传值返回时,原本会生成临时对象,然后将这个临时对象返回,再讲临时对象拷贝构造给st2,优化为直接将形参st拷贝构造给st2,省略中间的临时对象,节约了开销,如下:

   根据上面的分析,编译器确实帮我们自动完成了优化,减少了拷贝,是不是非常神奇,接下来我们来讲一下拷贝构造的另一个大坑,就是我们在用临时对象拷贝构造st2时,有一个小细节,就是临时对象具有常性,可以看作临时对象都被const修饰了,如果要让临时对象拷贝构造给st2,上面的拷贝构造函数的第一个形参必须加上const,否则就会出错

   问题就在于我们之前学过的权限的放大,由于临时对象具有常性,所以它可以看作是一个const对象,此时如果临时对象传引用给拷贝构造函数,并且拷贝构造函数的第一个参数没有被const修饰,此时就相当于const对象传给了普通对象的引用,发生了权限的放大,最终会报错

   所以我们在写拷贝构造的时候最好将当前类类型对象的引用加上const,因为本身我们就是拿这个对象来拷贝出另一个对象,并不需要修改这个对象,所以加上const也没有问题,反而让代码的健壮性更好,可以同时给普通对象和const对象使用,间接也让我们的临时对象可以使用了

   随后我们来解决最后一个关于拷贝构造函数的问题,就是拷⻉构造函数的第⼀个参数必须是当前类类型对象的引用,不能使用传值⽅式传参,否则编译器会直接报错,这又是为什么呢?
   这主要还是因为一个规定:类对象传值传参必须调用拷贝构造对形参进行初始化,那么如果我们的拷贝构造函数没有加引用,这里的拷贝构造就属于一个传值传参的函数,在我们给拷贝构造函数传参时我们需要调用拷贝构造,随后我们又去调用拷贝构造,结果又需要拷贝构造进行传值传参,就这样形成了无穷递归

   可能上面的描述不是特别清楚,这里我们简单画个图来理解理解,如下:

   从上图我们可以看出,由于拷贝构造本身就是传值传参,所以一旦调用了拷贝构造,我们就要对拷贝构造进行传值传参,而对这个拷贝构造进行传值传参又要调用下一个拷贝构造,就这样无限循环,陷入了无穷递归
   所以我们在写拷贝构造的时候必须把引用加上,这样调用拷贝构造的时候,拷贝构造本身不会进行传值传参,就不会像上面那样无穷递归了,所以总结一下,一旦涉及到了深拷贝,我们就要写拷贝构造函数,这个拷贝构造函数最好用const,并且必须带引用

   那么上面的分享就是今天所有的内容了,这里我们详细讲了深浅拷贝和拷贝构造,属于类和对象中比较难的一部分,希望大家可以好好下去理解理解,有什么不懂可以问我,那么今天就到这里
   bye~

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

Honkers

荣誉红客

关注
  • 4008
    主题
  • 36
    粉丝
  • 0
    关注
这家伙很懒,什么都没留下!

中国红客联盟公众号

联系站长QQ:5520533

admin@chnhonker.com
Copyright © 2001-2025 Discuz Team. Powered by Discuz! X3.5 ( 粤ICP备13060014号 )|天天打卡 本站已运行