【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++ 空类大小

《深入理解C++空类大小》本文主要介绍了C++空类大小,规定空类大小为1字节,主要是为了保证对象的唯一性和可区分性,满足数组元素地址连续的要求,下面就来了解一下... 目录1. 保证对象的唯一性和可区分性2. 满足数组元素地址连续的要求3. 与C++的对象模型和内存管理机制相适配查看类对象内存在C++中,规

Mysql 中的多表连接和连接类型详解

《Mysql中的多表连接和连接类型详解》这篇文章详细介绍了MySQL中的多表连接及其各种类型,包括内连接、左连接、右连接、全外连接、自连接和交叉连接,通过这些连接方式,可以将分散在不同表中的相关数据... 目录什么是多表连接?1. 内连接(INNER JOIN)2. 左连接(LEFT JOIN 或 LEFT

Java中ArrayList的8种浅拷贝方式示例代码

《Java中ArrayList的8种浅拷贝方式示例代码》:本文主要介绍Java中ArrayList的8种浅拷贝方式的相关资料,讲解了Java中ArrayList的浅拷贝概念,并详细分享了八种实现浅... 目录引言什么是浅拷贝?ArrayList 浅拷贝的重要性方法一:使用构造函数方法二:使用 addAll(

Java中switch-case结构的使用方法举例详解

《Java中switch-case结构的使用方法举例详解》:本文主要介绍Java中switch-case结构使用的相关资料,switch-case结构是Java中处理多个分支条件的一种有效方式,它... 目录前言一、switch-case结构的基本语法二、使用示例三、注意事项四、总结前言对于Java初学者

Linux内核之内核裁剪详解

《Linux内核之内核裁剪详解》Linux内核裁剪是通过移除不必要的功能和模块,调整配置参数来优化内核,以满足特定需求,裁剪的方法包括使用配置选项、模块化设计和优化配置参数,图形裁剪工具如makeme... 目录简介一、 裁剪的原因二、裁剪的方法三、图形裁剪工具四、操作说明五、make menuconfig

Node.js 中 http 模块的深度剖析与实战应用小结

《Node.js中http模块的深度剖析与实战应用小结》本文详细介绍了Node.js中的http模块,从创建HTTP服务器、处理请求与响应,到获取请求参数,每个环节都通过代码示例进行解析,旨在帮... 目录Node.js 中 http 模块的深度剖析与实战应用一、引言二、创建 HTTP 服务器:基石搭建(一

详解Java中的敏感信息处理

《详解Java中的敏感信息处理》平时开发中常常会遇到像用户的手机号、姓名、身份证等敏感信息需要处理,这篇文章主要为大家整理了一些常用的方法,希望对大家有所帮助... 目录前后端传输AES 对称加密RSA 非对称加密混合加密数据库加密MD5 + Salt/SHA + SaltAES 加密平时开发中遇到像用户的

在C#中合并和解析相对路径方式

《在C#中合并和解析相对路径方式》Path类提供了几个用于操作文件路径的静态方法,其中包括Combine方法和GetFullPath方法,Combine方法将两个路径合并在一起,但不会解析包含相对元素... 目录C#合并和解析相对路径System.IO.Path类幸运的是总结C#合并和解析相对路径对于 C

Springboot使用RabbitMQ实现关闭超时订单(示例详解)

《Springboot使用RabbitMQ实现关闭超时订单(示例详解)》介绍了如何在SpringBoot项目中使用RabbitMQ实现订单的延时处理和超时关闭,通过配置RabbitMQ的交换机、队列和... 目录1.maven中引入rabbitmq的依赖:2.application.yml中进行rabbit

C语言线程池的常见实现方式详解

《C语言线程池的常见实现方式详解》本文介绍了如何使用C语言实现一个基本的线程池,线程池的实现包括工作线程、任务队列、任务调度、线程池的初始化、任务添加、销毁等步骤,感兴趣的朋友跟随小编一起看看吧... 目录1. 线程池的基本结构2. 线程池的实现步骤3. 线程池的核心数据结构4. 线程池的详细实现4.1 初