【C++】类和对象2:默认成员函数+操作符重载
本篇是类和对象的第二站🚌
主要讲述类的几个默认成员函数,以及操作符重载
本篇博客会涉及到很多之前C++专栏里面提到的知识点,建议连起来观看。
感谢你关注慕雪,欢迎来我的寒舍坐坐❄慕雪的寒舍
[TOC]
默认成员函数
当我们创建一个类的时候,即便类里面啥都不放,都会自动生成下面6个默认成员函数
它们都有啥功能呢?且听我一一道来
1.构造函数
众所周周知,当我们写C语言的顺序表、链表等代码的时候,一般都会写一个Init
函数来初始化内容。
1 | void Init() |
但是这样有一个缺点,就是不够智能,需要我们自己来调用它进行初始化。
于是C++就整出来了一个构造函数来解决这个问题
1.1特性
构造函数:名字和类名相同,创建类对象的时候编译器会自动调用,初始化类中成员变量,使其有一个合适的初始值。构造函数在对象的生命周期中只调用一次
构造函数有下面几个特性:
- 函数名和类名相同
- 无返回值
- 构造函数可以重载
- 对象实例化的时候,编译器会自动调用对应的构造函数
- 如果你自己不写构造函数,编译器会自己创建一个默认的构造函数
1.2基本使用
下面用一个队列来演示一下构造函数
1 | class Queue{ |
可以看到,在创建对象q1的时候,编译器就自动调用了类中的构造函数,帮我们初始化了这个队列
除了上面这种最基本的无参构造函数以外,一般写构造函数的时候,我们都会带一个有缺省值的参数,这样可以更好地灵活使用这个队列
1 | Queue(int Capacity=4) |
调用这种构造函数也更加灵活,我们可以根据数据类型的长度,来创建不同容量的队列,避免多次realloc
造成的内存碎片
1 | Queue q1;//调用无参的构造函数 |
多种构造函数是可以同时存在的,不过!它们需要满足函数重载的基本要求
当你调用一个无参的函数,和一个全缺省的函数的时候,编译器会懵逼!
1 | Queue(); |
正确的重载应该是下面的情况
1 | Queue(); |
编译器在创建对象的时候,就会智能选择这两个构造函数其中之一进行调用。但是同一个对象只会调用一个构造函数
1.3编译器默认生成的构造函数
上面提到过,如果我们不写构造函数,编译器会自己生成一个。
但测试过以后,你会发现,这个默认生成的构造函数,好像啥事都没有干——或者说,它把_a _b _c
都初始化成了随机值!
实际上,编译器默认生成的构造函数是不会处理内置类型的
- 内置类型:int、char、float、double……
- 外置类型:自定义类型(其他的类)
在处理的时候,编译器忽略内置类型;外置类型会调用它的构造函数
1 | class Date{ |
可以看到,编译器调用了自己的构造函数的同时,还调用了外置类型Queue
的构造函数,搞定了它的初始化
如果我们去掉Date的构造函数,就能看到下面的情况。Queue
成功初始化,但是内置类型的年月日都是随机值
一般情况下一个C++类都需要自己写构造函数,下面这两个情况除外
- 类里面的成员都是自定义类型成员(且有自己的构造函数)
- 如果还有内置类型成员,声明时给了缺省值
注:只有类在声明变量的时候才可以给缺省值
1 | //下面的情况就不需要写 |
1.4初始化列表
除了上面的方式之外,还有一种构造函数的使用方式为初始化列表
1 | Date(int year=2022,int month=2,int day=30) |
- 每个成员变量只能在初始化列表中出现一次
- 类中包含以下成员必须在初始化列表中进行初始化
- 引用
- const成员
- 自定义类型成员
一般情况下,建议使用初始化列表进行初始化。因为对于自定义类型的成员变量,初始化列表的优先级是高于{ }
里面的内容的。
这里还有非常重要的一点!
成员变量在类中声明的顺序就是初始化列表的顺序,而并非初始化列表自己的顺序!
- 怎么理解呢?看下面这个代码
1 | class Date{ |
即便我们把_day
放在了初始化列表的首位,但由于它是在最后声明的。所以构造函数走初始化列表的时候,会依据声明顺序,依次初始化年、月、日。
- 这会引起什么问题?再来看看一个错误示例
1 | class Date{ |
当我们用上面这个初始化列表的时候,我们本意是想在初始化完_day
以后,将_day
的值赋给_month
。但由于_month
的声明顺序在_day
之前,所以_month(_day)
会先执行,此时的_day
尚为随机值,这就导致月份变成随机值了!
这只是一个示例,实际上肯定不会用天数初始化月数,范围不一样
最好的办法,就是声明顺序和初始化列表的顺序保持一致!
1.5 explicit关键字
构造函数不仅可以构造与初始化对象,对于单个参数的构造函数,还具有隐式类型转换的作用。
1 | class Date |
当我们调用赋值的时候,实际上编译器会先用2019构造出一个date类型对象,再调用赋值重载(这里还没有写)赋值给d1。这就是一个隐式类型转换
如果我们用explicit
修饰了这个构造函数,那么编译器将不会进行此类隐式类型转换!
1.6规范命名类的成员变量
为了更好的使用构造函数,以及区分类内外的函数类型
一般我们定义类中的成员变量的时候,都会使用一个下划线进行标明_YEAR
在一些地方,你会看到函数名前面也带了一个_
,这一般表明该函数是另外一个函数的子函数,同样是用于区分的。
不同人的代码风格不同,你可以选择你自己喜欢的风格,但不能影响我们程序的正常使用
比如下面这种情况,就会影响类的构造了
1 | class Date{ |
请问year=year
里面的这个year,到底是成员变量,还是构造函数的传参呢?编译器又双懵逼了
实际上,编译器在找year的时候,会先在当前{ }
中找,找到了传参的year,就不会去找其他地方的year了。所以这个语句实际上是传参过来的year自己给自己赋值,编译器会报错。
1.7初始化列表/函数体/缺省值
在VS2019里面测试了一下运行顺序
- 缺省值/初始化列表(缺省值会被处理成初始化列表)
- 函数体内
在下面的测试用例中,我用注释标出了初始化的顺序
1 |
|
运行输出如下,其中第二行的dft是编译器在初始化列表阶段给变量_f
调用的默认构造函数(因为inclass
的构造函数写了全缺省)
1 | init class | _e |
而且这里我们能看出,即便采用 inclass _e = inclass(4,"_e");
这种形式给自定义类型赋值,最终编译器也会优化成在初始化列表阶段直接调用构造函数;
2.析构函数
和构造函数相对应,析构函数是对象在出了生命周期后自动调用的函数,用来爆破对象里的成员(如进行free操作)
生命周期是离这个对象最近的{ }
括号
2.1特性
- 析构函数名是在类名前加
~
- 无参数,无返回值
- 一个类只能有一个析构函数
- 如果你没有自己写,编译器会自动生成一个析构函数
和构造函数一样,编译器自己生成的析构函数不会处理内置类型;会调用外置类型的析构函数
2.2基本使用
析构函数的定义和我们在外部写的Destroy
函数一样,主要执行free操作
1 |
|
假设我们在main函数里面定义了两个对象,你能说出q1和q2谁先进行析构函数的调用吗?
可以看到,先调用的是q2的析构函数
因为在底层操作中,编译器会给main函数开辟栈帧
栈遵从后进先出的原则,q2是后创建的,所以在析构的时候会先析构
3.拷贝构造
3.1特性和使用
拷贝构造是一个特殊的构造函数,它的参数是另外一个Date类型。在用已有的类类型对象来创建新对象的时候,由编译器自动调用
因为拷贝的时候我们不会修改d的内容,所以传的是const
另外,我们必须进行传引用调用!
这里补充说明一下,下面的这个函数,在传参的时候,编译器会去调用Date的拷贝构造
1 void func(Date d);
如果你没有写拷贝构造,或者拷贝构造里面不是传引用,编译器会就递归不断创建新的对象进行值拷贝构造,程序就死循环辣
1 | //拷贝构造,如果不写的时候,编译器会默认生成一个 |
和构造、析构不同的是,编译器自己生成的拷贝构造终于有点用了
- 它会对内置类型进行按内存存储的字节序完成拷贝,这种称为值拷贝(又称浅拷贝)
- 对外置类型会调用它的构造函数
3.2外置类型拷贝问题
但是!如果你使用了外置类型,该类型中包含malloc的时候,编译器默认生成的构造函数就不能用辣!
因为这时候,编译器默认生成的拷贝构造会进行值拷贝,拷贝完了之后,就会出现q1和q2指向同一个空间的情况。修改q2会影响q1,free的时候多次释放同一个空间会报错,不符合我们的拷贝构造的要求
注意注意,malloc不行的原因是,数据是存在堆区里面,拷贝的时候,q2的_a
得到的是一个地址,而不是拷贝了新的数据内容。
如果你在类里面定义了一个
int arr[10]
数组,这时候拷贝构造就相当于memcpy,是可以完成拷贝的工作的。
如何解决这个问题呢?我们需要使用深拷贝
这里我还没有学到那个地方,后续写深浅拷贝的博客的时候,再来填上这个坑
黑马16分钟视频速成完毕,前来填坑
3.3深拷贝
3.3.1new和delete
这里先给大家从C语言转到C++,讲解一下new和delete关键字,它们分别对应malloc和free
非常简单!比malloc的使用简单多了!
1 | int main() |
怎么样?是不是超级简单!
3.3.2深拷贝实现
在上面写道过,编译器会自动生成拷贝构造函数,完成值拷贝工作。但是队列的代码里面包含堆区的空间,需要我们正确释放。这时候就需要自己写一个拷贝构造完成深拷贝👇
1 | //拷贝构造 |
用下面这个队列的代码来测试深拷贝
1 |
|
3.3.3深拷贝效果
先注释掉Queue
的拷贝构造函数析构函数(不然会报错)
看一看,发现在不写拷贝构造函数的时候,q2和q1的_a
指向了同一个地址
取消析构函数的注释,可以看到两次释放同一片空间,发生了报错
如果我们把写好的深拷贝构造加上,就不会出现这个问题
当你加上给_a
里面初始化一些数据,以及打印_a
数据的函数后,就可以看到,不仅q2的_a
有了自己全新的地址,其内部的值也和q1一样了
这样写出来的拷贝构造,即便把队列中的int* _a
修改为char*
或者其他类型,都能正确完成拷贝工作
这里有一个小点哈,就是打印char* _a
的地址的时候,咱需要用printf
而不是cout
,因为cout会把_a
直接当作字符串打印了,效果就变成了下面这样
用printf来控制输出格式为%x
即可
1 | printf("_a:%x ",_a); |
4.运算符重载
4.1定义
在讲解赋值运算符重载之前,我们可以来认识一下完整的运算符重载
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名为:关键字 operator
运算符,如operator=
函数原型:返回值类型 operator操作符(参数列表),如Date operator=();
下面有几点注意:
- 重载操作符必须有一个自定义类型的操作数(即操作符重载对内置类型无效)
- 不能通过其他符号来创建新的操作符
- 对于类类型的操作符重载,形参比操作数少一个传参(因为有一个默认的形参this指针)
- 这5个操作符是不能重载的:
.*
、::
、sizeof
、? :
、.
4.2基本使用
以下是在全局定义的操作符重载,用于判断日期是否相等
1 | bool operator==(const Date& d1, const Date& d2) |
当我们在main函数中使用d1==d2
的时候,编译器就会自动调用该操作符重载
当然,你也可以自己来传参使用,如if(operator==(d1,d2))
但是这样非常不方便,和调用一个而普通函数没啥区别,压根算不上操作符重载。所以我们一般是在类里面定义操作符重载的
当我们把它放入类Date
中间,就需要修改成下面这样
1 | bool operator==(const Date& d2) |
编译器在调用的时候,会优化成下面这样
1 | bool operator==(Date* this, const Date& d2) |
而在main里面使用的时候,这个重载后的操作符和原本的使用方法完全相同
1 | Date d1(2022,6,1) |
后续会以日期类为样板,实现更多的操作符重载
4.3赋值运算符重载
因为每一个类都有不同的成员,编译器不可能智能的进行赋值操作。这时候就需要我们自己写一个赋值运算符重载来进行赋值操作了
以日期类为例,赋值操作其实就是把内置类型成员一一赋值即可
1 | Date& operator=(const Date& d){ |
编写赋值重载代码的时候,需要注意下面己点:
- 返回值和参数类型(注意要引用传参,不然会调用拷贝构造)
- 检测是否自己给自己赋值(避免浪费时间)
- 因为返回的是
*this
,出了函数后没有销毁,所以可以用传引用返回 - 一个类如果没有显式定义赋值运算符重载,编译器也会自己生成一个,完成对象按字节序的值拷贝(浅拷贝)。
如果类中有自定义类型,编译器会默认调用它的赋值运算符重载。
4.4深拷贝
同样的,对于使用了动态内存管理的成员变量而言,在进行赋值重载的时候,也需要考虑:原有维护的空间是否足够保存新的数据?
- 如果长度足够,我们可以考虑擦除原有数据,并将新数据拷贝上去。
- 如果长度不够,我们需要重新申请一片新空间,并释放原有空间。
下面是一个需要深拷贝的场景的代码示例。
示例
1 | Queue &operator=(const Queue &q) |
尝试用下面的代码调用赋值重载
1 | Queue q3; |
可以看到,因为q1原有长度足够存放,所以它没有申请新的空间,且q1中的内存地址和q3也不一样,符合我们深拷贝的预期
1 | this:0x7fff1f9ba7f0 _a:0x561ca4ace300 size: 0 capa: 4 |
自拷贝判断
但是,上面实现的这个赋值重载有一个严重的问题:没有进行自拷贝判断!假设我们让q1=q1
;
函数一进来,会把q1的内容拷贝给q1自己,这部分其实就有点浪费,没有意义!而如果有人不小心把这里面的capa判断写错了,变成>=
的判断的话,那问题就更大了!
- 因为q1自己和自己的capa肯定是相同的
- 所以程序会进入if的分支,将原有空间删除,申请新空间
- 再进行拷贝。
可这样一操作,q1原本的空间就被销毁了,原有保存的数据都被错误清空了!!!后续的memcpy也是在拷贝个寂寞。不仅浪费了时间,还搬起石头砸自己的脚。
结果就是:我调用了q1=q1
的自赋值,成功的在不侵入你这个对象的情况下把对象里面的内容清空了……
1 | Queue &operator=(const Queue &q) |
用上面这个错误代码进行自赋值的错误情况测试,会发现q1中的内容已经变成了未初始化的随机值。
1 | Queue& operator= |
所以,这里不仅capa的判断条件不能写错,还一定要加上自赋值判断!
1 | Queue &operator=(const Queue &q) |
当然,这个简单的demo其实也有些不合理的地方,比如如果我的传入的对象capa比当前已有capa小很多呢?虽然这种情况也是当前capa大于传入的capa,不需要申请新空间,但会造成严重的空间浪费
- q1的capa是10000
- q2的capa是10
- 执行
q1=q2
,上述赋值重载代码不会对q1的空间进行操作,而是直接拷贝q2的内容到q1已有空间中。 - 假设在这之后有很长一段时间q1都没有被插入新的值
- 此时就出现了严重的空间浪费(q1申请了大量空间却没有使用)
所以,这个demo只是用来给你演示不写自拷贝(或者说是自赋值)检测可能出现的问题,实际场景可能更加复杂。
自拷贝判断会影响性能吗?
之前在B站看到了一个视频,up的观点是不应该写自拷贝判断,因为它是一个没有必要的判断,应该从程序本身不进行自拷贝操作来解决这个问题。
其中评论区的讨论主要在于,自拷贝判断根本不会有多大的性能损耗,但不写自拷贝判断可能产生的问题更多。
自拷贝会影响性能是肯定的,我们要关注的应该是它是否利大于弊。
而且,你不能妄想于整个项目,所有参与的程序员都能做到按规范编写,不会不小心写出自拷贝的问题。对于代码规范和静态代码检测而言,不写自拷贝判断都是一个不允许的情况。
因此,加上自拷贝肯定是个更优解,因为加上是“准没错”,但不加上字拷贝导致出现其他的bug,恐怕大伙就要来找你麻烦了……毕竟在某种程度上,这算是一个比较低级的错误。
我还看到了一个有趣的评论:与其有时间思考这个问题,还不如给他加上省事。不动脑子的方法才是最好的方法。
4.5拷贝构造和赋值重载的调用问题
当赋值操作符和拷贝构造同时存在的时候,什么时候会调用赋值,什么时候会调用拷贝构造呢?
1 | Date& operator=(const Date& d) |
在这拷贝构造和赋值重载两个函数中添加cout
进行打印提示,可以看到:
- 如果对象在之前已经存在,就会调用赋值重载
- 如果是一个全新的变量在定义的时候初始化,就调用的是拷贝构造
5.const成员
5.1用const修饰类的成员函数
将const修饰的类成员函数称之为
const成员函数
,const修饰类成员函数,实际修饰的是该成员函数隐含的this指针
,表明在该成员函数中不能对类的任何成员进行修改。
基本的修饰方法如下,在函数的括号后加const即可
1 | void Print()const |
实际修饰的是该函数隐含的this指针
this指针本身是Date*const
类型的,修饰后变为const Date* const
类型
1 | void Print(const Date* const this) |
①实例-权限问题
这么说好像有点迷糊,我们用实例来演示一下为什么需要const修饰成员函数
1 | class Date{ |
假设我们需要在函数中调用Print
函数,在main中是可以正常调用的
1 | int main() |
但当你用一个函数来进行这个操作的时候,事情就不一样了
1 | void TEST(const Date& d) |
这时候我们进行了引用调用,因为在TEST中我们不会修改d1的内容,所以用const
进行了修饰
- 这时候TEST中的
d.Print()
函数调用,传入的是const Date*
指针,指针指向的内容不能被修改 - main中的
d1.Print();
函数调用,传入的是Date*
指针
于是就会发生权限冲突问题👇
这时候如果我们在函数后面加了const,就可以避免此种权限放大问题。这样不管是main函数还是TEST函数中对Print()函数
的调用,就都可以正常打印了!
总结一下:
- const对象不可以调用非const成员函数(权限放大)
- 非const对象可以调用const成员函数(权限缩小)
- const成员函数内不可以调用其他非const成员函数(权限放大)
- 非const成员函数可以独调用其他const成员函数(权限缩小)
②什么时候需要使用?
众所周周知,const修饰指针有下面两种形式
- 在
*
之前修饰,代表该指针指向对象的内容不能被修改(地址里的内容不能改) - 在
*
之后修饰,代表该指针指向的对象不能被修改(指向的地址不能改)
this指针本身就是类型名* const
类型的,它本身不能被修改。加上const之后,this指向的内容,既类里面的成员变量也不能被修改了。
知道了这一点后,我们可以合理的判断出:只要是需要修改类中成员变量的函数,就不需要在()
后面加const修饰
如果一个函数中不需要修改成员变量,就可以加const进行修饰
注意:如果你用了声明和定义分离的写法,那么声明和定义的函数都需要加上const修饰
③出错提醒
这里有一点需要提醒的是,如果你对某一个函数进行了const修饰,那么这个函数里面包含的其他类里面的函数,都需要进行const修饰。不然就会报错
出现该报错的情况如下
这个情况也提醒我们,不能在const修饰的函数中,调用非const修饰的成员函数
5.2取地址及对const取地址重载
最后两个默认成员函数,编译器会自动生成。这两个函数一般都不需要重载,毕竟返回的本身就是一个this指针,没有什么奇怪的地方
1 | class Date{ |
只有特殊情况,我们需要让&只获取特定内容的时候,才需要手动重载这两个函数
6.构造,析构顺序
下面这个代码是一个很好的示例(22.12.31)
1 |
|
最终打印的结果如下
1 | 1 init this:: 00D4E464 |
总结:
- 构造顺序和写代码的运行顺序一致
- 析构时候,堆区若手动delete,那么肯定是按delete的顺序析构的
- 自动析构的时候,遵循
栈-静态-全局
的顺序析构
日期类的实现
类和对象第一站🚌中提到过,在项目协作的时候,我们一半要用定义和声明分离的形式来些一个项目。
下面就让我们用日期类来演示这样的操作
在类中定义的函数会被默认设置为内联,我们的目标就是:短小函数在.h
中定义,长函数在.h
中声明,在.cpp
中定义
至于源码和解析嘛……大家直接来我的gitee仓库看吧!【传送门】
注释写的很详细了⏲有啥问题可以在下面留言哦
特殊:对<<和>>的重载
这里的<<和>>
主要是在使用cin和cout
的时候需要使用
①简单了解io
在cplusplus网站上,你可以看到下面这一副图。在使用cin和cout的时候,我们其实分别调用了不同头文件的内容。
- cin:istream
- cout、cerr、clog:ostream
实际上,流是一个类型的对象,这个对象完成了输入和输出的操作
流操作是系统GUI支持的(了解一下就行,我也不懂)
在cout的定义中,你可以看到,实际上cout为了完成自动识别类型进行输出操作的工作,对各种类型进行了操作符重载operator<<
显然,这部分重载中不包含自定义类型,所以我们需要来仿照这里的函数,进行重载操作
这里涉及到了友元函数,在类和对象的下一篇博客中我会写道。不过现在你只需要知道,友元函数是某一个类的朋友,目的是在类外访问类里面的成员变量
友元函数可以直接访问类的私有成员,它是定义在类外部的普通函数,不属于任何类,但需要在类的内部声明,声明时需要加friend关键字。
②实现
最后实现的效果如下,头文件中在最前面进行声明
1 | //这两个是友元函数(因为需要在类外面访问类里面的成员变量) |
程序运行的效果如下,和我们直接使用cout、cin是一样的!
当你写了一个离谱日期后,程序也会进行正确的报错
③疑惑解答
你可能会想,干嘛用友元啊,直接在类里面定义这个函数重载不就可以了?
之所以在外头定义该函数,是因为类里面定义的函数,默认会带有一个隐含的this指针传参,作为操作符的左操作数。
然后你的函数使用就得变成下面这样😱
1 | d1<<cout; |
虽然也能跑起来并完成工作,但这样写也太怪了!
结语
最后的最后,今天是5月20日,用下图给大家送上祝福😂