目录
🍎 解释特性2:拷贝构造函数的参数只有一个且必须使用引用传参,使用传值方式会引发无穷递归调用
🍐解释特性3:若未显示定义,系统生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝我们叫做浅拷贝,或者值拷贝
一、前言
在我们前面学习的类中,我们会定义成员变量和成员函数,这些我们自己定义的函数都是普通的成员函数,但是如若我们定义的类里什么也没有呢?是真的里面啥也没吗?如下:
class Date {};
如果一个类中什么成员都没有,简称为空类。空类中什么都没有吗?并不是的,任何一个类在我们不写的情况下,都会自动生成6个默认成员函数。
【默认成员函数概念】:用户没有显式实现,编译器会生成的成员函数称为默认成员函数
⭐其中上次的博客已经详细的讲解了构造函数&&析构函数的使用方法,所以本次博客将继续深度的讲解拷贝构造和赋值运算符的重载问题。⭐
二、拷贝构造函数
💦拷贝构造函数概念
我们在创建对象时,可否创建一个与另一个对象一摸一样的新对象呢?
int main() { Date d1(2022, 5, 18); // 将 d1 的 数据拷贝给 d2 让 d2 进行初始化 Date d2(d1); return 0; }
能否让d2的值跟d1一样呢?也就是说我拿d1去初始化d2,此时调用的函数就是拷贝构造函数。
🔑拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类 类型对象创建新对象时由编译器自动调用
💦拷贝构造函数特性
拷贝构造函数也是特殊的成员函数,其特征如下:
- 拷贝构造函数是构造函数的一个重载形式。
- 拷贝构造函数的参数只有一个且必须使用引用传参,使用传值方式会引发无穷递归调用。
- 若未显示定义,系统生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝我们叫做浅拷贝,或者值拷贝。
如下即为拷贝构造函数:
Date(Date& d) { _year = d._year; _month = d._month; _day = d._day; }
🍎 解释特性2:拷贝构造函数的参数只有一个且必须使用引用传参,使用传值方式会引发无穷递归调用
为什么传值传参会引发无穷递归呢?
我们先举一个普通的func函数作为例子://传值传参 void Func(Date d) {} int main() { Date d1(2022, 5, 18); Func(d1); return 0; }
此函数调用传参是传值传参。在C语言中,把实参传给形参是把实参的值拷贝给形参,而我的实参d1是自定义类型的,需要调用拷贝构造,传值传参是要调用拷贝构造的,但是我如果不想调用拷贝构造呢?就需要引用传参,因为此时d就是d1的别名
void Func(Date& d) {}
此时再回到我们刚才的例子:如若我不传引用传参,就会疯狂的调用拷贝构造:
Date(Date d) { _year = d._year; _month = d._month; _day = d._day; }
为了避免出现无限递归调用拷贝构造,所以要加上引用,加上引用后,d就是d1的别名,不存在拷贝构造了。同类型的传值传参是要调用拷贝构造的
Date(const Date& d) {} //最好加上const,对d形成保护
🍐解释特性3:若未显示定义,系统生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝我们叫做浅拷贝,或者值拷贝
看如下代码:
class Date { public: //构造函数 Date(int year = 1, int month = 1, int day = 1) { _year = year; _month = month; _day = day; } //拷贝构造函数 /* Date(const Date& d) { _year = d._year; _month = d._month; _day = d._day; } */ void Print() { cout << _year << "-" << _month << "-" << _day << endl; } private: int _year; int _month; int _day; }; void Func(Date& d) { d.Print(); } int main() { Date d1(2023, 10, 23); Date d2(d1); Func(d1); d2.Print(); }
运行效果图展示:
为什么我这里没有写拷贝构造函数,它也会自动完成拷贝构造呢?由此我们要深思,拷贝构造与构造和析构是不一样的,构造和析构都是针对自定义类型才会处理而内置类型不会处理,而默认拷贝构造针对内置类型的成员会完成值拷贝,浅拷贝,也就是像把d1的内置类型成员按字节拷贝给d2。
由此得知,对于日期类这种内置类型的成员是不需要我们写拷贝构造的,那是不是所有的类都不需要我们写拷贝构造呢?来看下面的例子:🔑 栈类(需要深拷贝的类)
class Stack { public: //构造函数 Stack(int capacity = 10) { _a = (int*)malloc(sizeof(int) * capacity); assert(_a); _top = 0; _capacity = capacity; } //不写拷贝构造,编译器调用默认拷贝构造 /* Stack(const Stack& st) { _a = st._a; _top = st._top; _capacity = st._capacity; } */ //析构函数 ~Stack() { cout << "~Stack():" << this << endl; free(_a); _a = nullptr; _top = _capacity = 0; } private: int* _a; int _top; int _capacity; }; int main() { Stack st1(10); Stack st2(st1); }
运行结果出现报错:
我们通过调试看到运行崩溃了,可见栈的拷贝构造函数不能像日期类一样不写而让编译器去调用默认拷贝构造(就是按照日期类的模式写了拷贝构造也会出错),因为此时的st1(指针)和st2(指针)指向的就是同一块空间,通过调试可以看出:
st1和st2指向同一块空间会引发一个巨大的问题:析构函数那出错,因为我st2会先析构,析构完后我st1再析构,不过我st1指向的空间已经被st2析构过了,因为它俩指向同一块空间,同一块空间我释放两次就会有问题。 出了析构存在问题,增删查改那也会有问题,这个后续会谈到。
其实刚才写的栈的拷贝构造就是浅拷贝,真正栈的拷贝构造应该用深拷贝来完成,此部分内容我等后续会专门出一篇博文详解,这里大家先简单接触下。
综上,我们可以得知,浅拷贝针对日期类这种是没有问题的,而类的成员若是指向一块空间的话就不能用浅拷贝了。🔑 字符串类(需要深拷贝的类)
class MyString { public: // 默认构造函数 MyString(const char* str = "winter") { _str = (char*)malloc(sizeof(char)*(strlen(str) + 1)); if (_str == nullptr) { perror("malloc fail!"); exit(-1); } strcpy(_str, str); } // 析构函数 ~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 指向的空间被释放两次,引发程序崩溃。
加入深入拷贝构造函数:
class MyString { public: // 默认构造函数 MyString(const char* str = "winter") { _str = (char*)malloc(sizeof(char)*(strlen(str) + 1)); if (_str == nullptr) { perror("malloc fail!"); exit(-1); } strcpy(_str, str); } // 析构函数 ~MyString() { cout << "~String()" << endl; free(_str); } //拷贝构造函数 MyString(const MyString& s) { // 给新对象申请一段和原对象一样大小的空间 _str = (char*)malloc(sizeof(char) * (strlen(s._str) + 1)); if (_str == nullptr) { perror("malloc fail!"); exit(-1); } // 把原对象的数据一一拷贝给新对象 strcpy(_str, s._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; }
⭐总结:
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这样自己直接管理资源的类,需要自己实现深拷贝。
三、赋值运算符重载
💦运算符重载
如下的日期类:
class Date { public: //构造函数 Date(int year = 1, int month = 1, int day = 1) { _year = year; _month = month; _day = day; } void Print() { cout << _year << "-" << _month << "-" << _day << endl; } private: int _year; int _month; int _day; };
我能否按照如下的方式对日期类的对象进行大小比较呢?
很明显是不可以的,从波浪线提示的警告就能看出。我们都清楚内置类型是可以直接进行比较的,但是自定义类型是不能直接通过上述的运算符进行比较的,为了能够让自定义类型使用各种运算符,于是就提出了运算符重载的规则。
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
- 函数名字为:关键字operator后面接需要重载的运算符符号。
- 函数参数:运算符操作数
- 函数返回值:运算符运算后结果
- 函数原型:返回值类型 operator操作符(参数列表)
- 注意:
- 运算符重载函数的参数由运算符决定,比如运算符==应有两个参数,双操作数运算符就有两个参数,单操作数运算符(++或--)就有一个参数
就比如我现在写一个日期比较相等的运算符重载(传值传参会引发拷贝构造,所以要加上引用,最好再加上const,以便于提高效率):
bool operator==(const Date& d1, const Date& d2) //避免传值传参调用拷贝构造 { return d1._year == d2._year && d1._month == d2._month && d1._day == d2._day; }
仔细观察我的截图:毕竟我都写好了运算符重载,可是我调用运算符重载的方式怎么还能跟调用普通函数一样呢?与其这样还取名运算符重载又有何意义,所以真正的调用应该如下:
调用的时候直接和内置类型进行运算符操作那样,编译器会自动处理成调用运算符重载的样子
- 注意:上述的运算符重载就算完成了吗?当然不是,按理说我们要把运算符重载函数放成类里的成员函数。
并且,这里的参数也不能像如上的方式写:
如若直接把运算符重载函数放到类里,编译器会报错(运算符函数的参数太多)。报错的原因就在于成员函数存在隐含的this指针。 这也就意味着实际的参数有3个,因此我们要少写一个参数:
bool operator==(const Date& d)//编译器会处理成 bool operator(Date* const this, const Date& d) { return _year == d._year && _month == d._month && _day == d._day; }
并且我在调用成员函数的时候也要做出改变:
if (d1.operator==(d2)) { cout << "==" << endl; }
和刚才一样,为了凸显出运算符重载的意义,我们调用的时候可以直接像内置类型一样操作运算符,因为编译器会帮我们处理:
if (d1 == d2)//编译器会处理成对应重载运算符调用if (d1.operator==(d2))或者if (d1.operator==(&d1, d2)) { cout << "==" << endl; }
- 现在,我们来写一个日期类的比较大小来练练手:
class Date { public: Date(int year = 1, int month = 1, int day = 1) { _year = year; _month = month; _day = day; } //日期比较大小 bool operator<(const Date& d) { if (_year > d._year || _year == d._year && _month > d._month || _year == d._year && _month == d._month && _day > d._day) return false; else return true; } void Printf() { cout << _year << "-" << _month << "-" << _day << endl; } private: int _year; int _month; int _day; }; int main() { Date d1(2023, 10, 23); cout << "d1 的日期为: " << endl; d1.Printf(); Date d2(2022, 10, 22); cout << "d2 的日期为: " << endl; d2.Printf(); cout << endl; cout << "d1与d2日期的大小比较:"; if (d1 < d2) { cout << "<" << endl; } else { cout << ">" << endl; } Date d3(d1); d3 = d2; return 0; }
接下来,再来总结下运算符重载的注意点:
- 不能通过连接其他符号来创建新的操作符:比如operator@
- 重载操作符必须有一个类类型(对自定义类型成员才可运算符重载)或者枚举类型的操作数
- 用于内置类型的操作符,其含义不能改变,例如:内置的整型+,不能改变其含义
- 作为类成员的重载函数时,其形参看起来比操作数数目少1成员函数的操作符有一个默认的形参this,限定为第一个形参
- .* 、:: 、sizeof 、?: 、. 注意以上5个运算符不能重载。这个经常在笔试选择题中出现。
💦赋值运算符重载
前面我们已经学习了拷贝构造,是拿同类型的对象去初始化另一个对象,那如果我不想用拷贝构造呢?
int main() { Date d1(2022, 5, 17); Date d2(2022, 5, 20); Date d3(d1);//拷贝构造 -- 一个存在的对象去初始化另一个要创建的对象 d2 = d1; //赋值重载/复制拷贝 -- 两个已经存在的对象之间赋值 }
可不可以直接拿d1去赋值给d2呢?这就是我们要谈的赋值运算符重载。赋值运算符重载和上文的运算符重载是有点相似的。有了运算符重载的基础,写一个赋值重载还是很简单的。
//d2 = d1; -> d2.operator=(&d2, d1); void operator=(const Date& d) { _year = d._year; _month = d._month; _day = d._day; }
但是这里的赋值重载是存在一定问题的,我们C语言的赋值是支持连等赋值的,如下:
int i = 0, j, k; k = j = i;
我们把i赋值给j,随后把j作为返回值再赋值给k。要知道C++是建立在C的基础上的,刚刚我们写的赋值重载支持连等吗?
很显然是不支持的,原因就是当我把d1赋值给d2后,没有一个返回值来赋给d3,这就导致出错。改正如下:
此外:这里的赋值重载还可进一步改进:
- 改进1:刚才我们写的赋值重载是传值返回,传值返回会生成一个拷贝,会调用拷贝构造。如果出了作用域要让其对象还在我们就可以用传引用返回:
- 改进2:有可能会存在这样的情况:d1=d1,像这样自己给自己赋值的情况还要再调用赋值重载函数属实没必要,所以我们还可以加个if条件判断。
修正如下:
//d2 = d1; -> d2.operator=(&d2, d1); Date& operator=(const Date& d) { if (this != &d) //不推荐写成if (*this != d) ,怕的是万一没有重载!=呢?,因为这里是对象的比较 { _year = d._year; _month = d._month; _day = d._day; } return *this; }
- 注意:
operator赋值也是默认成员函数,我们不写赋值重载,编译器也会默认生成,不过编译器完成的依旧是值拷贝或浅拷贝,像这个日期类就可以不写赋值重载。
赋值重载和拷贝构造一样,我们不写,它会对内置类型完成值拷贝,而像栈这样的就不能不写了,因为我们要写一个深拷贝的赋值重载才可以,理由和拷贝构造类似。 具体实现等真正谈到深拷贝再来。
- 补充:
Date d3 = d1 是拷贝构造,不是赋值, 拿一个对象初始化另一个对象是拷贝构造。如下d2 =d1才是赋值:void TestDate2() { Date d1(2022, 5, 18); Date d3 = d1; //等价于 Date d3(d1); }
void TestDate2() { Date d1(2022, 5, 18); Date d2(2022, 5, 20); Date d3 = d1; //等价于 Date d3(d1); 是拷贝构造 d2 = d1; //两个已经存在的对象才是赋值 }
四、共勉
以下就是我对C++类的默认成员函数--------拷贝构造函数&&赋值运算重载的理解,如果有不懂和发现问题的小伙伴,请在评论区说出来哦,同时我还会继续更新对C++ 类的默认成员函数-------const成员&&const取地址操作符重载的理解,请持续关注我哦!!!