🎈个人主页:库库的里昂
✨收录专栏:C++从练气到飞升
🎉鸟欲高飞先振翅,人求上进先读书。
一、拷贝构造函数的引入
对于上章节的学习我们认识并了解了两大默认成员函数:构造函数和析构函数。构造函数主要用来进行对象的成员变量初始化操作,而析构函数主要用来对战斗后的战场做清理工作。当我们不写这些函数时,编译器会自动生成默认的构造与析构函数,但有时候,编译器生成的并不能满足我们对代码的需求,这就需要我们自己去写了(比如Stack类),所以要根据情况的不同而去选择性的写。
此外就引入一个问题,假设我们需要创建一个对象和已经存在的对象一模一样那应该怎么办呢?显然易见的答案就是拷贝,但真的只是简简单单的拷贝吗?通过对比如下两种类的拷贝:
1. 以日期类为例:进行的值拷贝是不会发生错误的
#define _CRT_SECURE_NO_WARNINGS 1 #include<iostream> #include<assert.h> using namespace std; class Date { public: Date(int year = 1,int month = 1,int day = 1) { _year = year; _month = month; _day = day; } void Printf() { cout << _year<<"/" << _month << "/" << _day << endl; } private: int _year = 1; int _month; int _day; }; void func1(Date d) { d.Printf(); } int main() { Date d1(2023, 9, 12); func1(d1); return 0; }
2. 以栈类为例:进行的值拷贝会发现发生错误
#define _CRT_SECURE_NO_WARNINGS 1 #include<iostream> #include<assert.h> using namespace std; class Stack { public: Stack(size_t n = 4) { cout << "Stack(size_t n=4)" << endl; if (n == 0) { a = nullptr; top = capacity = 0; } else { a = (int*)malloc(sizeof(int) * n); if (a == nullptr) { perror("realloc fail"); exit(-1); } top = 0; capacity = n; } } void Init() { a = nullptr; top = capacity = 0; } void Push(int x) { if (top == capacity) { size_t newcapacity = capacity == 0 ? 4 : capacity * 2; int* tmp = (int*)realloc(a, sizeof(int) * newcapacity); if (tmp == nullptr) { perror("realloc fail"); exit(-1); } if (tmp == a) { cout << capacity << "原地扩容" << endl; } else { cout << capacity << "异地扩容" << endl; } a = tmp; capacity = newcapacity; } a[top++] = x; } ~Stack() { cout << "~Stack()" << endl; free(a); a = nullptr; top = capacity = 0; } int Top() { return a[top - 1]; } void Pop() { assert(top > 0); --top; } void Destroy() { free(a); a = nullptr; top = capacity = 0; } bool Empty() { return top == 0; } private: int* a; int top; int capacity; }; void func2(Stack s) { } int main() { Stack s1; func2(s1); return 0; }
报错原因:
🌟同样的拷贝方式为什么对于日期类不会报错,而对于栈类就会报错呢?
解决方式:
- 采用引用
void func2(Stack& s) { 引用没有值拷贝的问题,s就是s1别名,没有两个对象指向同一块空间的这种说法(这是一个对象) }
🌟这里有一个误解:采用引用它不是也会析构吗? —> 同一个对象不会析构两次,s是s1的别名,s不析构,不调用析构函数
但是采用引用,s的修改也会影响s1,那如何让s改变且不影响s1?这就需要引入拷贝构造函数
二、拷贝构造函数
1. 拷贝构造函数的概念
在现实生活中,可能存在一个与你一样的自己,我们称其为双胞胎
那在创建对象时,可否创建一个与已存在对象一某一样的新对象呢?
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用
2. 拷贝构造函数的特征
拷贝构造函数也是特殊的成员函数,其特征如下:
- 🌏拷贝构造函数是构造函数的一个重载形式。
- 🌏拷贝构造函数的参数只有一个且必须是同类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。
#include<iostream> using namespace std; class Date { public: Date(int year = 1,int month = 1,int day = 1) { _year = year; _month = month; _day = day; } Date(Date& d) { cout << "Date(Date& d)" << endl; _year = d._year; _month = d._month; _day = d._day; } void Printf() { cout << _year<<"/" << _month << "/" << _day << endl; } private: int _year = 1; int _month; int _day; }; int main() { Date d1; 以下两种写法是等价的 Date d2(d1); 调用拷贝构造 Date d3 = d1; 调用拷贝构造 定义了一个日期类对象d1,然后想再创建一个和d1一模一样的日期类对象d2,也就是用d1去拷贝d2 return 0; }
😽 注意—>拷贝构造的错误写法:引发无穷递归
对于下述代码按常规理解就是创建d2对象的时候,把d1传过去,然后用形参d接收,再把d的值赋值给this指针(this指针指向的是d2,也就是赋值给了d2)并不会发现有任何错误
//Date d2(d1); Date(Date d) { _year = d._year; _month = d._month; _day = d._day; }
其实编译器是会报错因为底层发生了错误
😽根据下述图解来探索一下引发无穷递归的原因
如上图所示,执行date d2(d1);调用拷贝构造函数, d1传参给拷贝构造的形参d, 形参d在接收实参d1的时候,又要去调用拷贝构造来创建d,所以会出现 date d(d1),而拷贝的过程中又会调用自身的拷贝构造函数,会无休止的递归下去。
😽 对于上述的错误可以采用下述方法进行规避:
采用引用的方法:Date d2(d1);调用拷贝构造,d1传给了d,且d是d1的别名,this指针就是d2,这样d1就拷贝给了d2,此时就不会再去无穷无尽的调用拷贝构造
//Date d2(d1); Date(Date& d) { _year = d._year; _month = d._month; _day = d._day; }
3. 拷贝构造函数针对自定义类型,自定义类型的对象在拷贝的时候,C++规定必须要调用拷贝构造函数
- 🌏若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
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 { private: // 基本类型(内置类型) int _year = 1970; int _month = 1; int _day = 1; // 自定义类型 Time _t; }; int main() { Date d1; 用已经存在的d1拷贝构造d2,此处会调用Date类的拷贝构造函数 但Date类并没有显式定义拷贝构造函数,则编译器会给Date类生成一个默认的拷贝构造函数 Date d2(d1); return 0; }
😽注意:建议写拷贝构造函数时加上const
Date(const Date& d)//d是d1的别名,权限缩小 { cout << "Date(Date& d)" << endl; _year = d._year; _month = d._month; _day = d._day; }
表明拷贝构造函数中没有对传递进来的对象做任何修改,也是防止拷贝构造函数对对象进行修改,实际上不加const也是可以照常运行的。不过还是建议:不需要改变对象时,传引用时加上const
- 🌏编译器生成的默认拷贝构造函数已经可以完成字节序的值拷贝了,还需要自己显式实现吗?当然像日期类(浅拷贝)这样的类是没必要的。但是对于栈类(深拷贝)的对象,是必须要显示写的,不然会出现析构两次的问题:
typedef int DataType; class Stack { public: Stack(size_t capacity = 10) { _array = (DataType*)malloc(capacity * sizeof(DataType)); if (nullptr == _array) { perror("malloc申请空间失败"); return; } _size = 0; _capacity = capacity; } void Push(const DataType& data) { // CheckCapacity(); _array[_size] = data; _size++; } ~Stack() { if (_array) { free(_array); _array = nullptr; _capacity = 0; _size = 0; } } private: DataType* _array; size_t _size; size_t _capacity; }; int main() { Stack s1; s1.Push(1); s1.Push(2); s1.Push(3); s1.Push(4); Stack s2(s1); return 0; }
- s1对象调用构造函数创建,在构造函数中,默认申请了10个元素的空间然后里面存了4个元素12 3 4
- s2对象使用s1拷贝构造,而Stack类没有显式定义拷贝构造函数,则编译器会给Stack类生成一份默认的拷贝构造函数,默认拷贝构造函数是按照值拷贝的,即将s1中内容原封不动的拷贝到s2中。因此s1和s2指向了同一块内存空间。
- 当程序退出时,s2和s1要销毁。s2先销毁,s2销毁时调用析构函数,已经将0x11223344的空间释放了,但是s1并不知道,到s1销毁时,会将0x11223344的空间再释放一次,一块内存空间多次释放,肯定会造成程序崩溃。
😽注意:
类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝。
😽综上所述栈类是要自己写拷贝构造的具体如下:
深拷贝就是去堆上重新申请一块空间,把s1中_array指向的空间中的内容,拷贝到新申请的空间,再让s2中的_array指向该空间
Stack(const Stack& s) { cout << "Stack(Stack& s)" << endl; //深拷贝 _array = (DataType*)malloc(sizeof(DataType) * s._capacity); if (NULL == _array) { perror("malloc申请空间失败"); return; } memcpy(_array, s._array, sizeof(DataType)*s._size ); _size = s._size; _capacity = s._capacity; }
😽总结:
我们不写,编译默认生成的拷贝构造,跟之前的构造函数特性不一样:
- 内置类型,值拷贝(浅拷贝)
- 自定义的类型,调用他的拷贝
- Date不需要我们实现拷贝构造,默认生成就可以用
- Stack需要我们自己实现深拷贝的拷贝构造,默认生成会出问题(析构两次)
4. 拷贝构造函数的典型调用场景
- 使用已存在对象创建新对象
- 函数参数类型为类类型对象
- 函数返回值类型为类类型对象
class Date { public: Date(int year, int minute, int day) { cout << "Date(int,int,int):" << this << endl; } Date(const Date& d) { cout << "Date(const Date& d):" << this << endl; } ~Date() { cout << "~Date():" << this << endl; } private: int _year; int _month; int _day; }; Date Test(Date d) { Date temp(d); return temp; } int main() { Date d1(2022, 1, 13); Test(d1); return 0; }
为了提高程序效率,一般对象传参时,尽量使用引用类型,返回时根据实际场景,能用引用尽量使用引用。
本次的内容到这里就结束啦。希望大家阅读完可以有所收获,同时也感谢各位读者三连支持。文章有问题可以在评论区留言,博主一定认真认真修改,以后写出更好的文章。你们的支持就是博主最大的动力。