【C++】深度解析--拷贝构造函数(从0开始,详解浅拷贝到深拷贝,小白一看就懂!!!)

2024-04-14 10:52

本文主要是介绍【C++】深度解析--拷贝构造函数(从0开始,详解浅拷贝到深拷贝,小白一看就懂!!!),希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

目录

一、前言

 二、拷贝构造函数

🍎概念解析

🥝特性解析

 💦为什么拷贝构造函数使用传值方式会引发无穷递归调用?

 💦为什么拷贝构造函数的形参中要加入 const 修饰

 💦若未显式定义,编译器会生成默认的拷贝构造函数吗?

 💦【浅拷贝】与【深拷贝】

 💦总结

🍇 产生拷贝构造的三种形式

1.当用类的对象去初始化同类的另一个对象时 

2.当函数的形参是类的对象,调用函数进行形参和实参结合时 

3.当函数的返回值是对象,函数执行完成返回调用者时

三、拷贝构造函数的总结

四、共勉 


一、前言

        在我们前面学习的中,我们会定义成员变量成员函数,这些我们自己定义的函数都是普通的成员函数,但是如若我们定义的类里什么也没有呢?是真的里面啥也没吗?如下:

class Date {};

        如果一个中什么成员都没有,简称为空类。空类中什么都没有吗?并不是的任何一个类在我们不写的情况下,都会自动生成6个默认成员函数。
【默认成员函数概念】:用户没有显式实现,编译器会生成的成员函数称为默认成员函数

 ⭐其中上次的博客已经详细的讲解了构造函数&&析构函数的使用方法,所以本次博客将继续深度的讲解拷贝构造函数

 二、拷贝构造函数

 🍎概念解析

在现实生活中,可能存在一个与你一样的自己,我们称其为双胞胎👫

 那在创建对象时,可否创建一个与已存在对象一某一样的新对象呢?

 答案是,当然可以的啦,这也就牵扯出了我们所要学习的 -----------拷贝构造函数


 【拷贝构造函数概念】:只有单个形参该形参是对本 类 类型对象 的引用(一般常用const修饰),在用已存在的类 类型对象创建新对象时由编译器自动调用


 【代码举例】:日期类

class Date
{
public:Date(int year = 2024 ,int month = 3 ,int day = 13)    // 构造函数{_year = year;_month = month;_day = day;}Date(const Date& d)      // 拷贝构造函数{_year = d._year;_month = d._month;_day = d._day;}void print(){cout << "今天的日期是 :" << endl;cout << _year << '-' << _month << '-' << _day << endl;}~Date()                        // 析构函数{_year = 0;_month = 0;_day = 0;}
private:int _year;int _month;int _day;
};int main()
{Date d1;d1.print();// 创建一个与已存在对象一某一样的新对象Date d2(d1);   // 拷贝构造d2.print();return 0;
}

  【运行结果】:

🥝特性解析

拷贝构造函数也是特殊的成员函数,其特征如下:

1️⃣: 拷贝构造函数是构造函数的一个重载形式

2️⃣: 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用

3️⃣: 在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定义类型是调用其拷贝构造函数完成拷贝的

 💦为什么拷贝构造函数使用传值方式会引发无穷递归调用?

首先我们来看如下代码:

//全缺省构造函数
Date(int y = 2000, int m = 1, int d = 1)
{_year = y;_month = m;_day = d;
}
//拷贝构造函数
Date(Date d)
{_year = d._year;_month = d._month;_day = d._day;
}
int main(void)
{Date d1;Date d2(d1);	//调用形式return 0;
}
  • 上面这个Date(Date d)指的就是拷贝构造函数Date d2(d1);便是它的调用形式,用已经定义出来的对象 d1 来初始化 d2
  • 但是我们编译一下却看到报出了错误,说【Date类的复制构造函数不能带有Date类】,这是为什么呢?

  • 但此时若是我将形参的部分加上一个引用&就可以编过了,这是为什么呢?

可能上面的这种形式过于复杂了,我先用下面这两个函数调用的形式来进行讲解

  • 如果你看过我的C++引用详解这篇文章的话就可以知道对于Func1(d1)来说叫做【传值调用】,对于Func2(d2)来说叫做【传引用调用】

【注意】:

“值” 调用的时候, 形参是实参的拷贝,改变形参的值并不会影响外部实参的值。
         
“引用” 调用的时候,形参是实参的别名,共同拥有一个地址,改变形参的值,就相当于对实参本身进行操作。

两者之间的区别:“值” 调用  会比  传 “引用” 调用 中间多一步  拷贝的操作

如果对以上两个概念还不清楚的老铁可以去看看这两篇文章:
C语言的传值调用
C++的传引用调用

class Date
{
public:// 构造函数// 通常都会先 运行 构造函数 在运行 InitDate(int year = 2024,int month = 4,int day = 12){_year = 2024;_month = 4;_day = 12;}//拷贝构造函数Date(Date& d){cout << "调用拷贝构造" << endl;_year = d._year;_month = d._month;_day = d._day;}void Print(){std::cout << "year:" << _year << std::endl;std::cout << "month:" << _year << std::endl;std::cout << "day:" << _year << std::endl;}// 析构函数~Date(){cout << "调用析构构造" << endl;cout << endl;_year = 0;_month = 0;_day = 0;}
private:int _year;int _month;int _day;
};
// 传值调用
void Func1(Date d1)
{cout << "Func1函数的调用" << endl;
}
// 传引用带哦用
void Func2(Date& d2)
{cout << "Func2函数的调用" << endl;
}int main()
{Date d;Func1(d);Func2(d);return 0;
}

  • 通过运行上述代码观察可以发现,Func1传值调用,会去调用Date类的拷贝构造函数,然后再调用本函数;但是Func2传引用调用,却直接调用了本函数
  • 这就源于我们之前讲过的,对于【传值调用】会产生一个临时拷贝,所以此时d1d的拷贝;对于【传引用调用】不会产生拷贝,此时d2d的别名

        所以,从上述代码我们可以得出一个结论:在传值调用时,形参中有【自定义类型】会调用拷贝构造,在传引用调用时,形参中有【自定义类型】不会调用拷贝构造

  •  所以为什么说使用传值方式编译器直接报错,因为会引发无穷递归调用?
  •  对于 自定义类型(自己定义的类型) 的 传值调用 来说都会去调用拷贝构造,那此时我们转换回Date类的拷贝构造函数这里。通过下面的这张图其实你可以看出自定义类型的传值调用引发的递归问题是多么严重!

  •  通过Date d2(d1)需要实例化对象d2,所以要调用对应的构造函数,也就是拷贝构造函数,但是在调用拷贝构造函数之前要先传参,那刚才说了【自定义类型传参调用】就会引发拷贝构造,那调用拷贝构造就又需要传参数进来,传参数又会引发拷贝构造。。。于是就引发了这么一个无限递归的问题
  •  所以编译器就规定了对于拷贝构造这一块的参数不可以是【传值传参】,而要写成下面这种【传引用传参】的形式。此时d就是d1的别名,那因为是d2去调用的拷贝构造,此时this指针所接收的便是d2的地址,初始化的即为d2的成员变量

 所以 在写 拷贝构造的时候  单个的形参,必须是对本类 类型对象的引用(&)。

 正确写法:

Date(Date& d)
{_year = d._year;_month = d._month;_day = d._day;
}Date d2(d1);

 💦为什么拷贝构造函数的形参中要加入 const 修饰

 面这种拷贝构造的形式并不是很规范,一般的拷贝构造函数都写成下面这种形式

Date(const Date& d)
{_year = d._year;_month = d._month;_day = d._day;
}
  • 那此时就会同学很疑惑,为什么要在前面加上一个const呢?这一点其实我们在strcpy中其实也有说到过,有的时候你可能会不小心把代码写成下面这样👇
Date(Date& d)
{// 写反了d._year = _year;d._month = _month;d._day = _day;
}
  • 但若是将const加上后,编译器便报出了错误❌

  • 因此可以看到,加上这个const之后,程序的安全性就得到了提升,这就是它的第一个作用①

 它还有第二点作用,我们再来看看

  • 我在实例化这个d1对象的时候在前面加上了一个const,此时这个对象就具有常属性,不可以被修改,然后此时再去使用d1对象初始化d2对象会发生什么呢?
int main(void)
{const Date d1;Date d2(d1);return 0;
}
  • 可以看到,编译器报出了错误,说【没有匹配的构造函数】,其实这里真正的问题还是在于权限放大,这点我在C++引用中也重点讲解过,如果不懂的同学去看一看。
  •   本来这个d1对象被const所修饰具有常性,但是呢在将其当做参数传入给一个不具有常性的对象接收时,那么在拷贝构造函数内部便可以去修改这个对象的内容,也就造成了问题。不要以为这种问题不会发生,我们在写程序的时候一定要严谨,尽可能地考虑到多种情况

  • 但是给形参加上const做修饰之后,便可以做到【权限保持】,此时程序的安全性又增加了↑

小结一下,对于const Date& d这种不是做输出型参数,加上前面的const的好处在于

① 防止误操作将原对象内容修改
② 防止传入const对象造成【权限放大】

💦若未显式定义,编译器会生成默认的拷贝构造函数吗?

 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝

  • 此时我将上面所写的拷贝构造去除之后,再去进行一个拷贝的操作,通过下面的运行结果可以看出,d1和d2均完成了初始化操作,而且和构造函数一样,对于内置类型也会去进行处理。其实在这里就是调用了编译器默认为我们生成的拷贝构造
//以下为有参构造
Date(int year = 2000, int month = 1, int day = 1)
{_year = year;_month = month;_day = day;
}

内置类型会处理,那自定义类型呢?也会处理吗?

  • 此时我在Date类中声明了一个Time类的对象作为成员函数,并且去除了Date类中上面所写的【拷贝构造函数】,然后再用d1去初始化d2,你认为此刻会发生什么呢?
class Time
{
public:Time(){_hour = 1;_minute = 1;_second = 1;}Time(const Time& t){_hour = t._hour;_minute = t._minute;_second = t._second;cout << "Time::Time(const Time&)" << endl;}
private:int _hour;int _minute;int _second;
};
class Date
{
public://构造..//析构
private:int _year;int _month;int _day;Time _t;	//内置自定义类型的成员
};
  • 通过调试观察可以发现,即使是Date类中没有写拷贝构造函数d2依旧是完成了初始化工作.这个Time类我们在说析构函数的时候有讲到过,那此时要去析构Date类中的自定义类型成员_t,便要调用Time类的析构函数,但是要先调用编译器为Date类自动生成的析构函数,然后再去调用Time类的析构函数,此时自动生成的析构函数就派上了用场【忘记了再翻上去看看】
  • 既然构造、析构都可以自动生成,那么拷贝构造作为类的默认成员函数编译器也是会自动为我们生成。那么此时就会调用默认生成的拷贝构造去拷贝其内部自定义类型_t的时候就会去调用Time类的显式拷贝构造完成初始化工作 

 因此对于像Date这种日期类来说,我们可以不用去自己去实现拷贝构造,编译器自动生成的就够用了,那其他类呢,像Stack这样的,我们继续来看看

 💦【浅拷贝】与【深拷贝】

  • 继续延用我们上面所讲到过的Stack,而且没有写上拷贝构造函数,首先实例化出对象st1,接下去便通过st1去初始化st2,通过上面的学习可以知道会去调用编译器自动生成的【拷贝构造】来完成,不过真的可以完成吗?我们来运行一下试试💻
typedef int DataType;
class Stack
{
public:// 构造函数 Stack(size_t capacity = 10){_array = (DataType*)malloc(sizeof(DataType) * capacity);if (nullptr == _array){perror("malloc申请空间失败");return;}_capacity = capacity;_size = 0;}拷贝构造 //Stack(const Stack& st)//{//	//根据st的容量大小在堆区开辟出一块相同大小的空间//	_array = (DataType*)malloc(sizeof(DataType) * st._capacity);//	if (nullptr == _array)//	{//		perror("fail malloc");//		exit(-1);//	}//	memcpy(_array, st._array, sizeof(DataType) * st._size);		//将栈中的内容按字节一一拷贝过去//	_size = st._size;//	_capacity = st._capacity;//}void Push(const DataType& data){// 扩容..._array[_size] = data;++_size;}DataType Top(){return _array[_size - 1];}// 析构函数~Stack(){if (_array){free(_array);_array = nullptr;_capacity = 0;_size = 0;}}
private:DataType* _array;size_t _capacity;size_t _size;
};int main()
{Stack st1;st1.Push(1);st1.Push(2);st1.Push(3);st1.Push(4);Stack st2(st1);return 0;
}

 运行结果出现报错:

  • 其实,根本的原因就是在于我们要使用到数组栈,便要去内存中开辟一块空间,那么s1开辟了一块空间后_array就指向堆中的这块内存地址,接着s2去拷贝了s1,里面的数据是都拷贝过来了,但是s2的_array也指向了堆中的这块空间

 s1 和 s2 指向了同一个空间,这是错误的。

  • 那此时我去往s1里面push数据的之后,s2再去push,就会造成【数据覆盖的情况】。假设现在s1push了【1】、【2】、【3】,那么它的size就是3,但是s1与s2二者的size是独立的,不会影响,所以此时s2的size还是0,再去push【4】、【5】、【6】的话还是会从0的位置开始插入,也这就造成了覆盖的情况

不仅如此,二者指向同一块数据空间还会造成其他的问题 

  • 现在定义出来两个Stack对象,那此时我想问谁会先去进行析构呢?

  • 揭晓一下,s2会先去析构,在C/C++内存分布一文中我们有讲到过【栈区】是里面的一个区域,原理都清楚是先进后出的,所以后实例化出的对象s2会先去进行一个析构的操作,接着再去析构对象s1。不过呢通过调试可以观察到s1和s2的_array都指向堆中的同一块空间,因此当s2去调用析构函数释放了这块空间后,那么s1对象的_array就已经是一个野指针了,指向了堆中的一块随机地址,那再去对这块空间进行析构的话就会出现问题⚠

👉所以来总结一下指向同一块空间的问题 

  1. 插入删除数据会互相影响
  2. 析构两次会造成程序的奔溃

 那要如何去解决这个问题呢?此时就要涉及到【深拷贝】了

 💬调用编译器自动为我们生成的拷贝构造函数去进行拷贝的时候会造成【浅拷贝】的问题,那什么又叫做深拷贝呢?

  •  因为浅拷贝是原封不动地拷贝,会使得两个指针指向同一块空间,那若是我们再去自己申请一块空间来使用,让两个对象具有不同的空间,此时便不会造成上面的问题了

 接下去我就来实现一下如何去进行【深拷贝】

Stack(const Stack& st)
{//根据st的容量大小在堆区开辟出一块相同大小的空间_array = (DataType *)malloc(sizeof(DataType) * st._capacity);if (nullptr == _array){perror("fail malloc");exit(-1);}memcpy(_array, st._array, sizeof(DataType) * st._size);		//将栈中的内容按字节一一拷贝过去_size = st._size;_capacity = st._capacity;
}

  • 而且两块空间是独立的,所以在对象进行析构的时候也不会造成二次析构的问题

 💬 但是这样自己去写拷贝构造感觉很麻烦诶,哪些类需要这样去深拷贝呢?

  • 你可以观察在当前这这个类中是否存在显式的析构函数,若是存在的话,表示当前这个类涉及资源管理了【资源管理指得就是去堆中申请空间了】,此时你一定要自己去是实现拷贝构造以达到一个深拷贝;若是不涉及资源管理的话,直接使用编译器自动生成的进行浅拷贝就可以了
  •  像Date日期类这种只存在【年】、【月】、【日】这种内置类型的浅拷贝就可以了;像是复杂一些的,例如:链表、二叉树、哈希表这些都会涉及资源的管理,就要考虑到深拷贝了

经过上面的【浅拷贝】与 【深拷贝】 的深度解析,我们再来写一个字符串类,练练手

class MyString {
public:// 默认构造函数MyString(const char* str = "winter"){_str = (char*)malloc(sizeof(char) * (strlen(str) + 1));if (_str == nullptr){perror("malloc fail!");exit(-1);}memcpy(_str, str, sizeof(char) * (strlen(str) + 1));}// 析构函数~MyString(){cout << "~String()" << endl;free(_str);}void MyPrintf(){cout << _str << endl;//printf("%s\n", _str);}private:char* _str;
};int main()
{MyString s1("hello C++");MyString s2(s1);s1.MyPrintf();cout << endl;s2.MyPrintf();cout << endl;
}

 如图:指向了同一块空间

那么会引发什么问题呢?会导致 _str 指向的空间被释放两次,引发程序崩溃。

加入深入拷贝构造函数: 

// 拷贝构造函数MyString(const MyString& s){//给新对象申请一段和原对象一样大小的空间_str = (char*)malloc(sizeof(char) * (strlen(s._str) + 1));if (_str == nullptr){perror("malloc fail!");exit(-1);}//把原对象的数据一一拷贝给新对象memcpy(_str, s._str, sizeof(char) * (strlen(s._str) + 1));}

 💦总结

⭐总结: 

1️⃣:你可以观察在当前这这个类中是否存在显式的析构函数,若是存在的话,表示当前这个类涉及资源管理了【资源管理指得就是去堆中申请空间了】,此时你一定要自己去是实现拷贝构造以达到一个深拷贝;若是不涉及资源管理的话,直接使用编译器自动生成的进行浅拷贝就可以了

2️⃣: 像Date日期类这种只存在【年】、【月】、【日】这种内置类型的浅拷贝就可以了;像是复杂一些的,例如:链表、二叉树、哈希表这些都会涉及资源的管理,就要考虑到深拷贝了

🍇 产生拷贝构造的三种形式

深刻理解了拷贝构造之后,我们再来看看产生拷贝构造的三种形式 

1.当用类的对象去初始化同类的另一个对象时 

Date d1;
Date d2(d1);
Date d3 = d2;	//也会调用拷贝构造

 在实例化对象d2和d3的时候都去调用了拷贝构造,最后它们初始化后的结果都是一样的

2.当函数的形参是类的对象,调用函数进行形参和实参结合时 

void func(Date d)	//形参是类的对象
{d.Print();
}int main(void)
{Date d1;func(d1);	//传参引发拷贝构造return 0;
}

函数func()的形参是类的对象,此时在外界调用这个函数并传入对应的参数时,就会引发拷贝构造, 

3.当函数的返回值是对象,函数执行完成返回调用者时

Date func2()
{Date d(2023, 3, 24);return d;
}int main(void)
{Date d1 = func2();d1.Print();return 0;
}

 可以看到,这一种方式也会引发拷贝构造,当函数内部返回一个Date类的对象时,此时外界再使用Date类型的对象去接收时,就会引发拷贝构造。 

三、拷贝构造函数的总结

 ✨总结:

1. 拷贝构造算是六大默认成员函数中较难理解的了。主要就是要理清【内置类型】和【自定义类型】是否会调用拷贝构造的机制。还有在实现这个拷贝构造时要主要的两点:一个就是在形参部分要进行引用接收,否则会造成无穷递归的现象;还有一点就是在前面加上const进行修饰,可以防止误操作和权限放大的问题
2. 一般的类,自己生成拷贝构造就够用了,只有像Stack这样自己直接管理资源的类,需要自己实现深拷贝。

四、共勉 

以下就是我对 拷贝构造函数 的理解,如果有不懂和发现问题的小伙伴,请在评论区说出来哦,同时我还会继续更新对C++的理解请持续关注我哦!!!  

这篇关于【C++】深度解析--拷贝构造函数(从0开始,详解浅拷贝到深拷贝,小白一看就懂!!!)的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/902824

相关文章

关于C++中的虚拟继承的一些总结(虚拟继承,覆盖,派生,隐藏)

1.为什么要引入虚拟继承 虚拟继承是多重继承中特有的概念。虚拟基类是为解决多重继承而出现的。如:类D继承自类B1、B2,而类B1、B2都继承自类A,因此在类D中两次出现类A中的变量和函数。为了节省内存空间,可以将B1、B2对A的继承定义为虚拟继承,而A就成了虚拟基类。实现的代码如下: class A class B1:public virtual A; class B2:pu

C++对象布局及多态实现探索之内存布局(整理的很多链接)

本文通过观察对象的内存布局,跟踪函数调用的汇编代码。分析了C++对象内存的布局情况,虚函数的执行方式,以及虚继承,等等 文章链接:http://dev.yesky.com/254/2191254.shtml      论C/C++函数间动态内存的传递 (2005-07-30)   当你涉及到C/C++的核心编程的时候,你会无止境地与内存管理打交道。 文章链接:http://dev.yesky

C++的模板(八):子系统

平常所见的大部分模板代码,模板所传的参数类型,到了模板里面,或实例化为对象,或嵌入模板内部结构中,或在模板内又派生了子类。不管怎样,最终他们在模板内,直接或间接,都实例化成对象了。 但这不是唯一的用法。试想一下。如果在模板内限制调用参数类型的构造函数会发生什么?参数类的对象在模板内无法构造。他们只能从模板的成员函数传入。模板不保存这些对象或者只保存他们的指针。因为构造函数被分离,这些指针在模板外

墨刀原型工具-小白入门篇

墨刀原型工具-小白入门篇 简介 随着互联网的发展和用户体验的重要性越来越受到重视,原型设计逐渐成为了产品设计中的重要环节。墨刀作为一款原型设计工具,以其简洁、易用的特点,受到了很多设计师的喜爱。本文将介绍墨刀原型工具的基本使用方法,以帮助小白快速上手。 第一章:认识墨刀原型工具 1.1 什么是墨刀原型工具 墨刀是一款基于Web的原型设计工具,可以帮助设计师快速创建交互原型,并且可以与团队

C++工程编译链接错误汇总VisualStudio

目录 一些小的知识点 make工具 可以使用windows下的事件查看器崩溃的地方 dumpbin工具查看dll是32位还是64位的 _MSC_VER .cc 和.cpp 【VC++目录中的包含目录】 vs 【C/C++常规中的附加包含目录】——头文件所在目录如何怎么添加,添加了以后搜索头文件就会到这些个路径下搜索了 include<> 和 include"" WinMain 和

解析 XML 和 INI

XML 1.TinyXML库 TinyXML是一个C++的XML解析库  使用介绍: https://www.cnblogs.com/mythou/archive/2011/11/27/2265169.html    使用的时候,只要把 tinyxml.h、tinystr.h、tinystr.cpp、tinyxml.cpp、tinyxmlerror.cpp、tinyxmlparser.

C/C++的编译和链接过程

目录 从源文件生成可执行文件(书中第2章) 1.Preprocessing预处理——预处理器cpp 2.Compilation编译——编译器cll ps:vs中优化选项设置 3.Assembly汇编——汇编器as ps:vs中汇编输出文件设置 4.Linking链接——链接器ld 符号 模块,库 链接过程——链接器 链接过程 1.简单链接的例子 2.链接过程 3.地址和

C++必修:模版的入门到实践

✨✨ 欢迎大家来到贝蒂大讲堂✨✨ 🎈🎈养成好习惯,先赞后看哦~🎈🎈 所属专栏:C++学习 贝蒂的主页:Betty’s blog 1. 泛型编程 首先让我们来思考一个问题,如何实现一个交换函数? void swap(int& x, int& y){int tmp = x;x = y;y = tmp;} 相信大家很快就能写出上面这段代码,但是如果要求这个交换函数支持字符型

十四、观察者模式与访问者模式详解

21.观察者模式 21.1.课程目标 1、 掌握观察者模式和访问者模式的应用场景。 2、 掌握观察者模式在具体业务场景中的应用。 3、 了解访问者模式的双分派。 4、 观察者模式和访问者模式的优、缺点。 21.2.内容定位 1、 有 Swing开发经验的人群更容易理解观察者模式。 2、 访问者模式被称为最复杂的设计模式。 21.3.观察者模式 观 察 者 模 式 ( Obser

【操作系统】信号Signal超详解|捕捉函数

🔥博客主页: 我要成为C++领域大神🎥系列专栏:【C++核心编程】 【计算机网络】 【Linux编程】 【操作系统】 ❤️感谢大家点赞👍收藏⭐评论✍️ 本博客致力于知识分享,与更多的人进行学习交流 ​ 如何触发信号 信号是Linux下的经典技术,一般操作系统利用信号杀死违规进程,典型进程干预手段,信号除了杀死进程外也可以挂起进程 kill -l 查看系统支持的信号