【C++学习】类与对象(中)

作者:一只大喵咪1201
专栏:《C++学习》
格言:你只管努力,剩下的交给时间!
【C++学习】类与对象(中)_第1张图片

类与对象(中)

  • 构造函数
    • 概念
    • 特性
  • 析构函数
    • 概念
    • 特性
  • 拷贝构造函数
    • 概念
    • 特性
  • 赋值运算符重载(=)
    • 概念
    • 特性

在上篇文章中我们已经了解了一些C++类与对象的基本知识,接下来本喵开始介绍C++中的重点内容。

如果一个类中什么都没有,没有成员函数,也没有常用变量,我们称之为空类。

我们知道,虽然是空类,但是它也是占一个字节的内存空间来表明它是存在的,那么空类中真的是什么都没有吗?

并不是,任何类在什么都不写的情况下,编译器都会自动生成6个默认的成员函数。

  • 默认成员函数:用户没有显式实现,编译器生成的成员函数称为默认成员函数。
class Date{};

这样一个空类,里面有默认的6个成员函数:

【C++学习】类与对象(中)_第2张图片
下面本喵就给大家介绍一下这几个默认成员函数。

构造函数

概念

构造函数:

  • 是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。

上面是构造函数的概念,构造函数的作用是初始化,仅看它的名字会产生误导,认为构造函数是创建一个对象,其实不是。

看这样一段代码:

class Date
{
public:
	//日期初始化
	void Init(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	//打印
	void Print()
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1;
	d1.Init(2022, 10, 3);
	d1.Print();
	return 0;
}

【C++学习】类与对象(中)_第3张图片
上面代码中,我们成功的将创建的日期对象d1初始化为2022-10-3。

如果我们创建多个对象,如:

【C++学习】类与对象(中)_第4张图片
此时就需要调用多次初始化函数,有点繁琐,有没有办法在创建对象的时候就让它自动的进行初始化呢?

答案是有的,这就要用到构造函数,在对象创建的时候就进行初始化。

【C++学习】类与对象(中)_第5张图片
用构造函数代替原本类中的初始化函数,红色框中的内容就是构造函数。

【C++学习】类与对象(中)_第6张图片
同样创建多个对象,但是不需要每创建一个对象就调用一次初始化函数进行初始化,而是在创建对象的同时,给对象传参,并且自动调用构造函数进行初始化。

【C++学习】类与对象(中)_第7张图片
从汇编代码中可以看到,每创建一个对象后,都会调用一次构造函数。

上面就是构造函数的作用,它就是用来初始化的,你可能会觉得这没有什么用,回忆一下,你在学习数据结构等内容的时候,有没有忘记写初始化的时候?反正本喵是是有,如果使用构造函数的方法,就会便面忘记写初始化函数的错误。

特性

  • 构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。

它有一些特性需要我们掌握,其特性如下:

  1. 函数名与类名相同
    【C++学习】类与对象(中)_第8张图片
    如上图,这是前面引出构造函数时写的代码,构造函数的函数名是和类名相同的。

  2. 无返回值
    同样看上面的图,构造函数是没有返回值的。

  3. 对象实例化时编译器自动调用对应的构造函数
    【C++学习】类与对象(中)_第9张图片
    在main函数中,只创建了对象,没有主动去调用构造函数,但是在汇编代码中,可以看到编译器是调用了构造函数的,所以构造函数是编译器自动去调用的。

  4. 构造函数可以重载

【C++学习】类与对象(中)_第10张图片
上图中,在日期类中写了俩个构造函数,上面的构造函数是有形参的,下面的构造函数是无形参的,这俩个构造函数构成了函数重载。

【C++学习】类与对象(中)_第11张图片
在main函数中,通过俩种方式创建了俩个对象:

在创建d1对象的时候,传了实参进行初始化,所以编译器自动调用类中的第一个构造函数,也就是有形参的那个。

在创建d2对象的时候,没有传参,所以编译器自动调用了类中的第二个构造函数,也就是没有形参的那个。

可以看到,d1初始化的结果就是传进去的实参,d2初始化的结果是构造函数中给的默认值。

注意:
【C++学习】类与对象(中)_第12张图片
在创建对象的时候,如果没有实参传给构造函数,千万不能写上图中红色框样子的代码,因为此时编译器会报错。

  • d2是一个对象,是按照类Date创建的对象
  • 但是如果按照红色框中的内容来写的话,编译器会认为d2是一个函数,没有形参,并且返回类型是Date类型

如果在创建对象的时候没有实参传给构造函数用来初始化,就只创建就行了,编译器会自己调用没有形参的构造函数。

上面例子中,有参数的构造函数和没有参数的构造函数构造了函数重载,并且写了俩个函数,我们可以用函数的缺省参数将这俩个重载函数写成一个。

【C++学习】类与对象(中)_第13张图片
这样一来,创建对象的时候,有实参传入就用实参来初始化,没有实参传入就用构造函数的缺省值来初始化。

  1. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。

在开篇就提出的,如果是一个空类,编译器会自动生成6类构造函数。构造函数便是其中之一。

上面的例子中,我们在类中定义了构造函数,但是如果没有定义呢?如果用户没有定义构造函数,编译器会自己生成一个构造函数,同样是用来初始化创建的对象。

【C++学习】类与对象(中)_第14张图片
将上面我们自己写的构造函数屏蔽掉,此时类中就没有显式构造函数了,但是此时编译器会自动生成一个构造函数来初始化对象d1。所以此时进行编译是可以正常通过的。

【C++学习】类与对象(中)_第15张图片

但是,在类中写一个构造函数,并且构造函数是有形参的,在创建对象的时候,如果没有传参就会报错。

  • 如果没有我们写的显式构造函数,那么在创建对象d1的时候,编译器会自动生成一个默认构造函数用来初始化对象d1
  • 此时我们写了一个显式构造函数,并且是有形参的,也就意味着在创建对象的时候必须传实参,但是我们在创建对象d1的时候没有传实参,还是想使用编译器自动生成的默认构造函数
  • 结果就是报错了,报错内容是没有找到合适的默认构造函数。
  • 因为此时类中已经有了一个构造函数,所以编译器就不会再自动生成一个默认的构造函数了,但是存在的构造函数还需要传参,而创建对象的时候没有传参,所以就报错了。

那么这样的代码呢?
【C++学习】类与对象(中)_第16张图片
此时类中是没有构造函数的,只能靠编译器自动生成构造函数来初始化创建的对象。

在创建对象的时候传了3个实参,但是在编译的时候报错了,报错内容是没有重载函数接受3个参数。

  • 这说明,此时编译自动生成的构造函数的形参是不能用来初始化对象d1的,原因是参数不匹配。
  • 所以我们可以推出,编译器默认生成的构造函数是没有形参的。
  1. 编译器自动生成的默认构造函数对内置类型不进行初始化,而对自定义类型调用它的默认构造函数。

这里有一个问题,编译器在没有显式构造函数的时候是会生成一个默认构造函数,但是这个默认构造函数干了什么事情?

【C++学习】类与对象(中)_第17张图片
上图中代码就是使用了编译器自动生成的默认构造函数对d1进行了初始化,通过打印的结果我们发现,初始化后的结果仍然是随机数,生成的默认构造函数好像什么都没有干。

其实不是什么都没有干,只是我们看不出来。

  • C++把类型分成内置类型(基本类型)和自定义类型。
  • 内置类型就是语言提供的数据类型,如:int/char…。
  • 自定义类型就是我们使用class/struct/union等自己定义的类型
  • 发现编译器生成默认的构造函数不对内置类型进行处理,但是会对自定类型成员调用的它的默认构造函数。

【C++学习】类与对象(中)_第18张图片

这里创建了一个栈的类,类中有显式构造函数。

【C++学习】类与对象(中)_第19张图片
这里我们用俩个栈来实现一个队列,在本喵的文章栈和队列中曾详细讲解过实现的思路和过程。
我们自己定义的栈是没有构造函数的,所以在创建对象的时候编译器会自动生成默认构造函数。

【C++学习】类与对象(中)_第20张图片
那么在创建这个对象以后会发生什么呢?

【C++学习】类与对象(中)_第21张图片

看调试结果,我们可以看到,实现队列的俩个栈都被初始化了,但是队列中的内置类型int _capacity没有被初始化,是一个随机数。

注意

  • C++语法中,内置类型不会被默认构造函数给初始化
  • 但是这是一个C++语法的缺陷,一直被诟病,所以一些编译器会自动进行优化,将内置类型初始化为0
  • 上图中为了讲解,本喵将_capacity修改成了随机数

可以看到,俩个栈的初始化都是通过队列的默认构造函数初始化的,但是int类型的数据就没有被初始化,还是随机数。

这就和我们上面说的相符,对于内置类型,编译器生成的默认构造函数不进行初始化,但是对于自定义类型,会调用自定义类型的默认构造函数。

  • 这里在栈中我们自己定义了显式的构造函数,所以队列中的默认构造函数会调用栈的构造函数来将栈进行初始化。

【C++学习】类与对象(中)_第22张图片
在栈的构造函数中,我们加了一句打印语句,如上图中第一个红色框所示,在创建了队列对象后,该语句打印了俩次,这俩次就是在编译器生成的默认构造函数去初始化俩个栈的时候打印的。

上面是我们对自定义类型栈定义了显式构造函数,那么如果没有定义呢?在创建队列的时候编译器生成的默认构造函数会怎么初始化呢?

【C++学习】类与对象(中)_第23张图片

  • 从调试中可以看到,此时所有的内容都成了随机数,
  • 因为在创建队列对象的时候,编译器生成的默认构造函数不初始化内置类型,
  • 只对自定义类型栈调用它们的默认构造函数,由于栈类型也没有显式构造函数,所以编译器也会生成默认构造函数,默认构造函数对内置类型不进行处理,所以就都是随机数了。

知道了编译器自动生成的默认构造函数的工作原理以后,我们就可以根据自己的需求决定写不写显式构造函数:

如果编译器自动生成的默认构造函数可以满足我们的初始化需求就不需要再写构造函数。

如果编译器自动生成的默认构造函数无法满足我们的初始化需求就需要我们自己写构造函数,此时编译器就不自动生成默认构造函数了。

  1. 无参数的构造函数,全缺省的构造函数,编译器生成的构造函数,都被称为默认构造函数

上面讲解中,默认构造函数一直使用的都是编译器自动生成的构造函数,其实默认构造函数不仅这一种,还有显式的无参数的构造函数,显式的全缺省的构造函数,这些都被叫做默认构造函数。

我们知道,编译器自动生成的默认构造函数对内置类型是不作处理的,虽然很多编译器做了优化,将内置类型初始化为0,但是C++的语法并不是这样。

为了弥补这个问题,C++11中打了一个补丁,就是在成员变量声明的时候可以给缺省参数。

图
如上图中,C++11中,在定义成员变量的时候可以给缺省值,如果没有显式的构造函数来初始化成员变量,那么编译器自动生成的默认构造函数对内置类型赋予定义中的缺省值。

析构函数

概念

我们知道了构造函数是用来初始化对象的,那么析构函数呢?从析构函数的名字可以看出,它的作用是和构造函数相反的。

析构函数:

  • 与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。
  • 而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。

简单来说,析构函数就是用来清理对象中的资源的,这些资源包括malloc等开辟的动态空间,以及文件操作后的文件关闭等。

在写数据结构的时候,你有没有忘记释放动态空间的时候?

继续拿栈来举列:
【C++学习】类与对象(中)_第24张图片
在栈的类中,我们写了一个释放动态空间的成员函数,在main函数中,使用完栈以后需要将栈空间再释放掉。

如果创建多个栈,那么就需要释放多次,每次都需要调用一次Destroy成员函数,而且一旦忘记释放就会造成内存泄漏。

那么有没有办法像构造函数一样,当栈使用完以后,动态空间也自动销毁呢?
答案是有的,这就用到析构函数。

【C++学习】类与对象(中)_第25张图片

将原本类中的Destroy成员函数换成如图中的析构函数,此时就不需要我们调用它来释放动态空间了,而是在对象的生命周期结束的时候自动释放。

【C++学习】类与对象(中)_第26张图片
看汇编代码中,在执行return0的时候,栈对象的生命周期结束,在此时调用了析构函数,如图中的红色框。

【C++学习】类与对象(中)_第27张图片
再从调试的角度来看,在没有执行return0之前,栈中的数组的地址如红色框所示,并且_top和_capacity的值都是4.

【C++学习】类与对象(中)_第28张图片
在执行完return0以后,栈中的数组的地址被释放了,并且指针也被置为了空,_top和_capacity的值也都成了0。

通过上面的分析,我们知道,析构函数是在对象的生命周期结束的时候自动执行的。正好和构造函数相反,构造函数是在对象的生命周期开始的时候自动执行的。

特性

  1. 析构函数名是在类名前加上字符 ~
    【C++学习】类与对象(中)_第29张图片
    还是上面例子中的代码,我们可以看到,析构函数的函数名是在类名的前面加一个“~”符号,这个符号我们在C语言中是在按位取反的时候用的。

  2. 无参数无返回值类型。
    可以看到,析构函数和构造函数一样,是没有返回类型的,并且析构函数是没有任何参数的,就是一种默认析构函数的形式。

  3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数,并且析构函数不能重载
    一个类中有且仅有一个析构函数,因为清理资源一个函数就够了。由于析构函数没有参数,所以它不能重载,只能是这样的形式。

  4. 对象生命周期结束时,C++编译系统系统自动调用析构函数。
    在前面引出析构函数的时候,从汇编代码中可以看到,在执行return0的时候,调用了析构函数,而在return0的时候也就是对象生命周期结束的时候。

  5. 编译器自动生成的析构函数,对自定义类型的对象会调用它的默认析构函数
    和构造函数一样,在没有显式析构函数的时候,编译器会自动生成默认析构函数,而默认析构函数会清理相应的资源。

【C++学习】类与对象(中)_第30张图片

还是使用构造函数时候用俩个栈实现队列的例子,我们在栈类中定义了显式析构函数,如上图中的红色框,并且在析构函数中写一个打印语句,在调用析构函数的时候就会打印。

【C++学习】类与对象(中)_第31张图片
在我们自己定义的队列中没有显式析构函数。
【C++学习】类与对象(中)_第32张图片
可以看到,打印了俩次析构函数中的打印语句,这俩次打印是在队列对象结束的时候自动调用析构函数的结果,因为需要释放俩个栈的动态空间,所以会打印俩次。

【C++学习】类与对象(中)_第33张图片
通过汇编语言我们可以看到,在执行return0的时候,也就是队列对象的生命周期结束的时候,调用了编译器自动生成的默认析构函数。

所以可以确定,编译器自动生成的默认析构函数会调用自定义类型的默认析构函数。和构造函数的原理是一样的。

上面的例子是肯定有资源需要释放的情况,那么如果没有资源需要释放呢?

【C++学习】类与对象(中)_第34张图片

继续拿日期类来举列,我们在Date类中定义了显式析构函数,并且在析构函数中将年月日都置为了0,在调试的过程中我们可以看到,当执行完return0后,类中的成员变量全被置为了0

【C++学习】类与对象(中)_第35张图片
从汇编代码中可以看到,在执行return0的时候是调用了显式的析构函数的.

如果没有显式析构函数呢?

【C++学习】类与对象(中)_第36张图片
在将显式析构函数屏蔽以后,此时类中是没有显式析构函数的,在执行完return0以后,成员变量也没有任何的变化,这让我们不禁怀疑,编译器到底自动生成默认析构函数没有?还是生成了但是什么都没有做?

【C++学习】类与对象(中)_第37张图片
通过汇编代码我们可以看到,在执行return0后,并没有调用默认析构函数,也就是说编译器没有自动生成默认析构函数。

所以我们就可以得出这样的结论:

  • 有显式析构函数的情况下,在对象的生命周期结束的时候都回调用析构函数
  • 在没有显式析构的情况下,如果有资源需要被释放,编译器回自动生成默认析构函数,如果没有资源需要被释放,编译器不会自动生成默认析构函数。
  • 编译器生成的默认析构函数不会处理内置类型,这一点和编译器生成的构造函数一样。
  1. 析构函数也是按需要来写。
    当对象有资源申请时,如果编译器自动生成的析构函数可以满足需求,那么就可以不写显式析构函数,如果不能满足需求,就需要写析构函数。
    当对象没有资源申请时,就可以不写析构函数,让编译器自己去处理。

拷贝构造函数

概念

那在创建对象时,可否创建一个与已存在对象一某一样的新对象呢?当然是可以的,第一反应是不是复制一个对象就可以了?我们知道,类都是被封装起来的,里面的成员变量是不可以之间访问的,如果复制的话还需要在类中再写专门的取值函数。

有没有办法之间复制另一个类中的所有值呢?答案是有的,这就需要用到拷贝构造函数。

我们拆解一下,拷贝,顾名思义就是复制,构造,就是初始化,拷贝构造就是初始化一个和另外一个对象一模一样的对象。

拷贝构造函数:

  • 只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。

继续拿日期类来举列:
【C++学习】类与对象(中)_第38张图片
我们在日期类中定义了一个拷贝构造函数,如图中的红色框。

图
在主函数中,创建的d2对象是完全拷贝d1的,打印的结果也是d1对象中的日期。

【C++学习】类与对象(中)_第39张图片
通过汇编代码,我们可以看到,在创建d2的时候,调用了拷贝构造函数。

这样我们就完成了一个对象的拷贝,而不需要写单独的取值函数来复制d1中的成员变量。

特性

同样,拷贝构造函数也是有特性的。

1.拷贝构造函数是构造函数的一个重载形式
【C++学习】类与对象(中)_第40张图片
可以看到,拷贝构造函数和构造函数构成重载函数,只有参数类型是不同的。

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

首先我们来看,为什么拷贝构造函数的参数必须是类对象的引用类型。
【C++学习】类与对象(中)_第41张图片
在主函数中,创建一个对象d2,其初始化的内容是复制的d1,该语句执行的时候,就会调用拷贝构造函数。

【C++学习】类与对象(中)_第42张图片
由于拷贝构造函数的形参是一个Date的对象,所以在调用该函数的时候,会创建一个Date类型的临时对象,该对象的名字叫做d,d的内容是传过来的实参对象。此时又形成了Date d(d1)的形式,所以又会自动调用一个拷贝构造函数,如此反复就会导致无限递归。

【C++学习】类与对象(中)_第43张图片
如上图中那样,此时就会形成无限递归。所以C++标准规定,拷贝构造函数的参数必须是一个类对象引用类型,而且只能是一个。
【C++学习】类与对象(中)_第44张图片
所以说必须得写成这样,而且也只能是这样。

  1. 若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。

和构造函数,析构函数一样,如果没有显式拷贝构造函数的定义,那么编译器就会自动生成默认拷贝构造函数。

在拷贝构造函数引出时我们所举的例子是写了显式拷贝构造函数的,如果我们没有显式定义会怎么样呢?

【C++学习】类与对象(中)_第45张图片
将显式拷贝构造函数屏蔽后,此时类对象中就没有拷贝构造函数了。

【C++学习】类与对象(中)_第46张图片
此时我们拷贝对象d1的内容,创建对象d2时,同样拷贝成功了。

【C++学习】类与对象(中)_第47张图片
通过汇编代码我们可以看到,在创建对象d2的时候,将对象d1的地址给了eax寄存器,之后通过一系列寄存器将d1中的内容拷贝到了d2对象中去。

这里虽然没有生成默认的拷贝构造函数,但是它的这一系列操作就类似于拷贝构造函数的功能。因为操作的都是内置类型,编译器对这些类型是了如指掌的,所以不用函数,只按字节就可以实现完完全全的复制。

【C++学习】类与对象(中)_第48张图片
在通过调试,我们可以看到,在执行完d2对象创建的语句以后,d2中的内容和d1完全一样。

所以我们说,没有显式拷贝构造函数的时候,编译器自动生成了默认的拷贝构造函数(其实是通过按字节拷贝实现的)。这种方式只是内置类型的拷贝,而且是按字节拷贝的,所以叫做浅拷贝。

我们看到,只有内置类型时,浅拷贝是足够用的,而且不需要我们写显式拷贝构造函数,那么如果是一个栈呢?默认的拷贝构造函数可以满足需求吗?

【C++学习】类与对象(中)_第49张图片
同样是上面使用过的栈,我们可以看到,图中的代码是没有构造拷贝函数的。

【C++学习】类与对象(中)_第50张图片
当我们创建一个栈对象st2,内容拷贝st1时,如果使用编译器自动生成的默认拷贝构造函数,就会得到两个一模一样的栈。

但是,这样的栈是我们需要的吗?

  • st1和st2中的_top和_capacity是一模一样的,这一点符号我们的要求,如图中绿色框那样。
  • st1和st2中的成员变量也是一模一样的,这一点同样符号我们的要求,如图中蓝色框那样。
  • 但是,st1和st2中的俩个数组的地址都是一样的,如如中的红色框那样,这一点就不符合要求了。我们需要的是一个新的栈,这个栈只是内容和原理的相同,但是数组不能和原来是同一个数组。
  • 如果是同一个数组,那么st1和st2中的数组中的值就会相互影响,而我们需要的是俩个独立的栈。
  1. 编译器自动生成的默认拷贝构造函数无法满足需求的时候,需要自己写拷贝构造函数,实现深拷贝。
    根据上面的分析我们可以知道,此时依靠编译器自动生成的默认拷贝函数是无法满足需求的,也就是浅拷贝是无法满足需求的,我们必须字节写拷贝构造函数,也就是自己实现深拷贝。

【C++学习】类与对象(中)_第51张图片
我们在栈类中自己写一个显式拷贝构造函数,如上图中的代码,我们来看此时的调试结果:

【C++学习】类与对象(中)_第52张图片

  • 可以看到,俩个栈st1和st2中的数组的地址不一样了,如图中的红色框。
  • st1和st2中的_top和_capacity相同,如图中的绿色框。
  • st1和st2中数组的成员相同,如图中的蓝色框。

此时我们便实现了深度拷贝,所以说,浅拷贝也就是编译器生成的默认拷贝构造函数无法满足需求时,需要我们自己写深拷贝,也就是自己写拷贝构造函数。

  1. 拷贝构造函数的形参应该用const来修饰
    上面所有的例子中,为了方便讲解,拷贝构造函数的形参都没有用const来修饰,但实际上是需要用const来修饰的。

【C++学习】类与对象(中)_第53张图片
如上图中的红色框,在拷贝构造函数的形参前面加一个const关键字,它的目的是防止在函数中复制的时候写反,比如:

【C++学习】类与对象(中)_第54张图片
如图中红色框所示,这样就是写反了,也就是赋值的方向写反了,不要说你不会犯这种错误,如果犯了,是很难找出来的,需要一步步调试,会浪费大量的时间,如果在拷贝构造函数的前面加上const关键字,当你写反的时候编译器就会报错,会很快的定位到错误。

最后我们得出的结论就是:

  • 类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;
  • 一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝。
  • 编译器自动生成的默认拷贝构造函数,对于内置类型会处理,而定于自定义类型是不会处理的,需要我们自己写深拷贝,这一点于构造函数和析构函数相反。

赋值运算符重载(=)

运算符重载:

还是拿日期类来举列:

class Date
{
public:
	Date(int year = 1970, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};

这是一个日期类,我们知道,其中成员变量是被封装起来的,在类域外是无法访问的。

	Date d1(2022, 10, 10);
	Date d2(2022, 10, 9);

现在创建了俩个日期对象,想知道这俩个日期对象的关系,是相等,大于,小于还是什么?
该怎么比较呢?由于外部是无法访问类的成员变量的,可以在类中定义成员函数来比较。

  • C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
  • 函数名字为:关键字operator后面接需要重载的运算符符号。
  • 函数原型:返回值类型 operator操作符(参数列表)

这样一来,我们就将原本只能处理内置类型的==,>,<等等运算符重载成为可以处理自定义类型的运算符。

注意:

  • 不能通过连接其他符号来创建新的操作符:比如operator@ ,也就是重载的运算符只能是原本就存在的运算符。
  • 重载操作符必须有一个类类型参数
  • 用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不 能改变其含义
  • 作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this
  • . * :: sizeof ?: . 注意以上5个运算符不能重载。 第一个运算符是.加 *,几乎是见不到这个运算符的。

【C++学习】类与对象(中)_第55张图片
我们在Date类中将运算符==重载,此时就可以进行俩个类的比较了。

说明:

  1. 运算符重载也是在定义一个函数,如上图中,该还是的函数名是operator==,返回类型是bool类型,形参只有一个,是const Date& d,所以,在使用重载运算符==的时候,也可以进行显式调用,如:
    【C++学习】类与对象(中)_第56张图片
  2. 运算符==是有俩个操作数的,双等号左右各一个操作数,但是在定义的时候:【C++学习】类与对象(中)_第57张图片
    我们可以看到,形参只有一个,但是实际上它是有俩个的:
    【C++学习】类与对象(中)_第58张图片
    实际上是如上图中的样子的,还有一个this指针,我们知道,这个指针是存在的,但是不能写在形参里的,它是编译器给加上去的。

下面本喵来给大家实现一下其他的运算符:

class Date
{
public:
	Date(int year = 1970, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}

	bool operator==(const Date& d)
	{
		return (_year == d._year) && (_month == d._month) && (_day == d._day);
	}

	//!=
	bool operator!=(const Date& d)
	{
		return !((_year == d._year) && (_month == d._month) && (_day == d._day));
	}
	//>
	bool operator>(const Date& d)
	{
		if (_year > d._day)
		{
			return true;
		}
		else if (_year == d._year && _month > d._month)
		{
			return true;
		}
		else if (_year == d._year && _month == d._month && _day > d._day)
		{
			return true;
		}
		return false;
	}
	bool operator<=(const Date& d)
	{
		return !(*this > d);
	}
	//>=
	bool operator>=(const Date& d)
	{
		return *this > d || *this == d;
	}
	bool operator<(const Date& d)
	{
		return !(*this >= d);
	}
	
private:
	int _year;
	int _month;
	int _day;
};

这是关系运算符的重载。

【C++学习】类与对象(中)_第59张图片
它的结果和我们设计的逻辑是相符的。

说明:
【C++学习】类与对象(中)_第60张图片
重载号的运算符是可以进行复用的,如上图中,重载了运算符>号,在重载<=的时候,就可以通过this指针来复用>号,不需要重新来写。

包括加减乘除等运算符也可以重载。

这些运算符中有一个最重要的运算符,就是赋值运算符重载,也就是等号,这是一个默认的常用函数。

概念

我们平常在写程序的时候,经常会用到等号来给变量赋值。比如:

int a = 10;
int b = a;

这样对于内置类型是可以直接赋值的,但是对于自定义类型呢?肯定是不能够直接赋值的,所以我们就需要给赋值运算符重载。继续拿日期类来举列:
【C++学习】类与对象(中)_第61张图片
如上图中,将赋值运算符在类体中重载。
【C++学习】类与对象(中)_第62张图片

可以看到,在赋值之前,日期是构造函数中的默认值,1970-1-1,赋值之后,就将d1中的日期赋值给了d2,变成了2022-10-10。

说明:
【C++学习】类与对象(中)_第63张图片

  • 形参的类型是const Date&类型的,之所以采用引用类型,是因为引用能够避免临时变量的拷贝,节省内存空间。用const修饰是为了防止形参被改变。
  • 返回类型是Date&,使用引用同样是为了避免返回参数时占用内存空间,这样可以节省空间。
  • 引用返回时,必须要求在函数的声明周期结束以后,引用返回的值仍然是存在的,它的空间没有返回给操作系统,所以这里返回的是this指针解引用。
  • 虽然this指针在赋值重载函数结束后销毁了,但是this指针指向的内容是这个类对象,该类对象还是存在的,可以引用返回。

赋值重载中,形参和返回都是使用的引用类型,当频繁调用该函数的时候,可以节省很多的内存空间。

特性

既然赋值运算符重载还是也是六个默认成员之一,所以它也是有特性的。

  1. 赋值运算符重载格式
  • 参数类型:const T&,传递引用可以提高传参效率
  • 返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
  • *this :要复合连续赋值的含义

在上面引出赋值运算符重载的时候,就说过采用引用的意义,除了节省空间,还有其他的意义。

	int i, j, k;
	i = j = k = 10;

像这种连续赋值的情况,先将10赋值给k,再将5赋值给j,再将j赋值给i。所以为了实现连续赋值,在赋值运算符重载函数中,其返回类型必须是一个数值,在我们所举的例子中就是一个日期对象。

  1. 赋值运算符只能重载成类的成员函数不能重载成全局函数
    赋值运算符重载只能在类中定义,不能定义成全局函数,否则编译会报错。
    【C++学习】类与对象(中)_第64张图片
    原因:赋值运算符如果不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。

  2. 用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝,内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值
    这个性质和拷贝构造函数是一样的。
    【C++学习】类与对象(中)_第65张图片
    将类中的赋值运算符重载函数屏蔽以后,此时就没有显式定义的赋值运算符了。
    【C++学习】类与对象(中)_第66张图片

可以看到,在没有显式定义的时候,同样可以完成赋值,这是因为编译器自动生成了默认的赋值运算符函数。
【C++学习】类与对象(中)_第67张图片
从汇编代码中可以看到,同样将d1中的成员变量赋值给了d2。红色框中是编译器自动生成的默认赋值运算符重载函数的内联展开形式,因为类中的成员函数,编译器会默认它是内联函数。

日期类不显式定义赋值运算符重载函数同样可以实现赋值,是因为它的成员变量全部都是内置类型,那么有自定义类型时还可以实现赋值吗?

【C++学习】类与对象(中)_第68张图片
继续拿出我们之前使用的栈,可以看到,此时栈类中只有构造函数和拷贝构造函数,没有赋值运算符重载函数。
【C++学习】类与对象(中)_第69张图片

如上图中,可以看到,在执行完赋值语句以后,st2中不仅_top,_capacity成员变量和st1中的相等,并且st2中的数组地址也和st1中的相等,这就不满足我们的要求了,因为此时对st2中数组的操作会影响到st1的数组,而要求是俩个对象互不影响的,所以就需要我们自己定义显式赋值运算符重载函数。

【C++学习】类与对象(中)_第70张图片
我们自己定义一个显式赋值运算符重载函数,被赋值对象,也就是st2需要冲洗开辟一块空间,这一点和构造函数很像。

【C++学习】类与对象(中)_第71张图片
此时可以看到,当执行完赋值语句以后,st1和st2中的数组是不同的,其他成员变量相同,此时满足需求。

总的来说,只有内置类型时,不需要定义显式赋值运算符重载函数,因为编译器会自动生成默认的,并且按字节来赋值,但是有自定义类型的时候,如果编译器自动生成的重载函数不能满足要求,就需要我们自己定义显式的赋值运算符重载函数。

同样的,和前几个默认函数一样,当存在自定义类型,并且没有定义显式的赋值运算符重载函数时,编译器对于内置类型会自行处理,而对于自定义类型,回调用它的默认赋值运算符重载函数。

继续拿俩个栈实现一个队列来举列:

【C++学习】类与对象(中)_第72张图片
这是用俩个栈实现队列的类,其中的俩个栈都是采用上面我们定义的栈,可以看到,这个队列类中并没有赋值运算符重载函数。

【C++学习】类与对象(中)_第73张图片
在执行完赋值语句以后可以看到,俩个队列中的四个栈所在的数组都是不同的,其他成员变量相同,这是因为,在赋值的时候,编译器自动生成了默认赋值运算符重载函数处理了内置类型,调用了俩个栈的赋值运算符重载函数经常了空间的开辟和内容上的复制。

【C++学习】类与对象(中)_第74张图片

从汇编代码中可以看到,在执行赋值的时候,调用了编译器自动生成的默认赋值运算符重载函数。

总结来讲,如果类中未涉及到资源管理,赋值运算符是否实现都可以;一旦涉及到资源管理则必须要实现。

赋值函数运算符重载函数和拷贝构造函数很相似,都是拷贝,而且都是默认函数会对内置类型进行处理,对自定义类型调用它的默认函数。

在有一种情况下,赋值运算符会被当作拷贝构造来处理:

int main()
{
	Date d1(2022, 10, 10);
	Date d2 = d1;
	return 0;
}

上面代码是在创建日期对象d2的时候给它赋值d1,相当于给d2在初始化,所以此时赋值运算就是相当于构造函数,是在初始化,而不是在赋值。

前置++和后置++:

在运算符中,还有一种运算符,就是前置++和后置++,那么它们的运算符重载是什么样子的呢?
根据前面的学习我们可以写出:

Date operator++()
{
	//..............
}

你会发现,这也区分不开前置后后置啊,因为他们都一样啊,都是给自己加1,区别就在于返回的值是加之前还是加之后的。

  • 前置++
    【C++学习】类与对象(中)_第75张图片
    前置++因为返回的是加之后的值,所以在_day++之后,直接返回this指针指向的内容即可。
  • 后置++
    【C++学习】类与对象(中)_第76张图片
    C语言标准规定,后置++的运算符重载函数,形参必须使用一个int类型,变量名可以写也可以不写,写的话就是int i 这样的,由于后置++返回的是加之前的值,所以要用一个临时变量来将加之前的值保存起来,待加之后再将加之前的值返回,如图中蓝色框所示。

【C++学习】类与对象(中)_第77张图片
由上图中可以看到,前置++和后置++的结果都和我们预想的一样。前置++和后置++的运算符重载函数构造了函数重载,因为只有形参不同,返回类型不是评判函数重载的标准。

以上内容就是六大默认函数中的四个最重要的默认函数,构造函数,析构函数,拷贝构造函数,赋值运算符重载函数,由于篇幅的原原因,剩下的俩个默认成员在下篇文章中讲解。

你可能感兴趣的:(C++学习,c++,学习)