C++草原三剑客之一:多态 Hello,各位大佬好,今天我们来讲讲C++中的最后一位 " 刺客 " :多态。
时光流逝,愿你与珍爱之人,携手余生。
Hello,各位大佬好,今天我们来讲讲C++中的最后一位 " 刺客 " :多态。
目录
1 多态的概念
通俗来讲的话,就是多种形态。多态分为编译时多态(静态多态)和运行时多态(动态多态),这里我们重点讲讲运行时多态。编译时多态(静态动态)主要就是我们前面所讲的函数重载和函数模板,它们传不同的参数就可以调用不同的函数,通过参数的不同以达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的匹配是在编译时完成的,因此把编译时多态归为静态,把运行时多态归为动态。运行时动态,具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就会达到多种形态。
2 多态的定义及实现
2.1 定义
多态是一个继承关系下的类类型的对象,去调用同一函数,产生了不同的行为。
2.2 实现动态所需要的条件
1>.必须是基类指针引用去调用虚函数。
2>.被调用的函数必须是虚函数。
说明:要实现多态的效果,第一必须是基类的指针或是引用,因为只有基类的指针或引用才能既指向派生类对象,又能指向基类对象;第二派生类必须对基类的虚函数构成重写 / 覆盖,只有重写或覆盖了,派生类才能有不同的函数,多态的不同形态效果才能达到效果。
2.3 虚函数
类成员函数前面加上virtual关键字进行修饰,那么这个成员函数被称之为是虚函数。注意非成员函数是不能加virtual关键字修饰的。
class person
{
public:
virtual void BuyTucket()
{
cout
2.4 虚函数的重写 / 覆盖
虚函数的重写 / 覆盖:派生类中有一个跟基类完全相同的虚函数(即派生类中的虚函数与基类中的虚函数的返回值类型,函数名字,参数列表(参数列表的类型)完全相同,称之为是派生类的虚函数重写了基类的虚函数)。
对于这个虚函数的重写,我们这里还需要特别来强调一下,就是这个虚函数的重写在这里确实是将基类中的虚函数给重写了,只不过这里的重写只是将函数的实现给重写了,换句话说,其实就是在多态的场景下,基类虚函数的实现过程被替换成了派生类虚函数的实现过程,在编译时就被替换好了。
class A
{
public:
virtual void func(int val = 0)
{
std::cout
对于这个虚函数的重写这个知识点,我们还需要注意一个知识点:在重写基类的虚函数时,派生类的虚函数在不加virtual这个关键字时,也可以构成重写(因为继承后基类的虚函数被继承下来了,在派生类中依旧保持虚函数的属性)(这里大家如果不怎么明白的话,就可以去参照前面的那个代码所写的那个过程),也就是说,如果有一个虚函数构成重写的话,那么派生类中的那个虚函数实际上就是基类中的那个虚函数,实现部分的代码给换成了派生类中的这个虚函数的实现部分的代码(就比如说上面那个代码,虚函数func构成重写,基类中的func函数前面没有加virtual这个关键字,func函数还是形成了重写,原因就是派生类中的那个func函数其实就是基类中的那个func函数,并将实现部分的代码替换成了派生类中的那个func函数的实现部分的代码),但是该种写法并不是很规范,不建议我们这样去使用,不过在考试的选择题中,经常会故意埋这个坑,让你判断是否构成多态,因此我们这里需要注意一下。
class person
{
public:
virtual void BuyTicket()
{
cout
2.5 虚函数重写的一些其他问题
2.5.1 协变(了解)
派生类重写基类的虚函数时,与基类虚函数的返回值不同(这个问题打破了 " 三同 " 条件中的返回值不同)。即基类虚函数返回基类对象的指针或引用,派生类虚函数返回派生类对象的指针或者引用时,称之为是协变。(这里注意一下,就是基类虚函数返回基类对象的指针或引用,不一定是返回当前基类对象的指针或引用,当然也可以是其它基类对象的指针或引用)协变的实际意义其实并不大,所以我们这里只需了解一下即可。
class A
{ };
class B:public A
{ };//A类和B类构成继承,在这个继承体系中,A类是基类,B类是派生类。
class person
{
public:
virtual A* BuyTicket()
{ }
virtual person* Print()
{ }
//返回值既可以是当前这个基类person类类型对象的指针(引用),也可以是其他的继承体系中基类对象的指针(引用)。
};
class student
{
public:
virtual B* BuyTicket()
{ }
virtual student* Print()
{ }
};
2.5.2 析构函数的重写
将基类的析构函设置为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数的名字不同显然不符合重写的规则,但是实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称会统一处理成destructor,所以基类的析构函数加上virtual修饰,派生类的析构函数就会构成重写。下面,我们就通过代码来看一看为什么析构函数一定要构成重写?
class A
{
public:
virtual ~A()//我们这里假设virtual为(1)
{
cout
注意:这个问题在面试中经常会被考察,大家一定要结合类似上面的样例才能讲清楚,为什么基类中的析构函数建议设计为虚函数。
2.6 override和final关键字
从上面我们可以看出,C++对虚函数重写的要求比较严格,但是有些情况下由于疏忽,比如说函数名写错、参数写错等等导致无法构成重写,而这种错误在编译时期是不会报出的,只有在程序运行时且没有得到预期结果才来找debug会得不偿失,因此C++11提供了override这个关键字,可以帮助用户检测是否构成了重写。如果我们不想让派生类重写这个虚函数的话,那么就可以用final这个关键字去修饰一下就可以了。
class car
{
public:
virtual void drive() { }
virtual void print() final { }//在虚函数的后面加上一个final,就说明这个虚函数不能构成重写操作
};
class xiaomisu7 :public car
{
public:
void dirve() override
{ }//编译器会报错,我们在dirve这个函数的后面加上了override这个关键字后,编译器在编译过程中就会检查这个dirve函数是否构成重写,到基类中去找,发现没有构成重写,就会报错。
void print() { }//编译器在这里也会报错,基类中print函数被final关键字修饰,因此不能构成重写。
};
2.7 重载 / 重写 / 隐藏的对比
为了更加方便的去表示,这里我们选择采用图片的形式来展示:
3 纯虚函数和抽象类(了解知识)
在虚函数的后面写上=0,则这个函数为纯虚函数,纯虚函数不需要定义实现(实现没啥意义,因为要被派生类重写,但是语法上可以实现),只要声明即可,包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数的话,那么派生类也是抽象类,纯虚函数,在某种程度上强制了派生类重写虚函数,因为不重写的话就实例化不出对象来。
class tea
{
public:
virtual void drink() = 0;//对纯虚函数的一个简单声明
};
class hc :public tea
{ };//hc继承了tea类,也就相当于是继承了纯虚函数,因此hc这个类也就相当于是抽象类,因此,它也不能实例化出对象。
class lc :public tea
{
public:
virtual void drink()
{
cout
4 多态的原理
4.1 虚函数表指针
前情提要,我们先来看下面一段代码:
class base
{
public:
virtual void func()
{
cout
b对象中的这个指针,我们将它称之为是虚函数表指针,一个含有虚函数的类中至少都有一个虚函数表指针,这个虚函数表指针指向虚表,因为一个类中所有的虚函数的地址都要被放到这个类对象的虚函数表中,简称虚表(对于虚函数表指针和虚函数表这个东西,我们可以通过VS2022编译器的调试窗口就可以看到)。
4.2 多态的原理
4.2.1 实现原理
多态是如何实现的:这个问题我们在这里要使用一段代码才会解释清楚。
class person
{
public:
virtual void BuyTicket() { cout
从底层的角度看func函数中的ptr->BuyTicket(),ptr是如何做到当其指向person对象时调用的是person::BuyTicket(),ptr指向student对象时调用的是student:BuyTicket()的呢?通过4.1中的解释,我们可以得知每一个含有虚函数的类中,它的空间中除了这个类的成员变量,其实还有一个虚函数标志,它指向虚表,而这个虚表本质上就是一个函数指针数组,我们来将上述代码画成如下所示:
(画完上面那幅图,我们这里首先要了解一个东西,就是student类它经常了person,但是student的虚表中却没有继承person的虚函数的地址,这是因为student类中的虚函数的地址将person的虚函数的地址给覆盖了,在student的虚表中,这个知识我们先知道一下,后面会讲到。)
我们上面这幅图将实际的内存空间给画了出来,现在我们来说一下,为什么在执行 " ptr->BuyTicket(); " 这一句代码会做到ptr指向谁就调用谁的虚函数,(这里我们这里从汇编的角度出发来解释一下这个问题,因为这些代码它们最终都会被变成一条条指令,编译器就是靠识别这些指令去进行一系列操作的)编译器在运行到这里时会先判断是否构成多态,如果构成多态的话,会将这条指令变成到这个指针指向的对象的虚表中去找相应的函数,注意派生类里面的虚表其实是继承基类的,ptr是person类类型的指针,不管你传什么样的派生类过来,他在这里都会进行切割,因此ptr它始终都指向父类的那一部分,因此就能通过_vfptr这个指针找到虚表中所对应的函数,通过这种方式就实现了指向谁就调用谁的BuyTicket()函数。
4.2.2 动态绑定与静态绑定
1>.对不满⾜多态条件(指针或者引⽤+调⽤虚函数)的函数调⽤是在编译时绑定,也就是编译时确定调⽤函数的地址,叫做静态绑定。
2>.满⾜多态条件的函数调⽤是在运⾏时绑定,也就是在运⾏时到指向对象的虚函数表中找到调⽤函数的地址,也就做动态绑定。(这两种绑定方式所生成的编译指令是完全不一样的,可以通过调用去看看汇编窗口)
4.3 虚函数表
1>.基类对象的虚函数表中存放基类所有虚函数的地址。
2>.派⽣类由两部分构成,继承下来的基类和⾃⼰的成员,⼀般情况下,继承下来的基类中有虚函数表指针,⾃⼰就不会再⽣成虚函数表指针。但是要注意的这⾥继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同⼀个,就像基类对象的成员和派⽣类对象中的基类对象成员也独⽴的。
3>.派⽣类中重写的基类的虚函数,派⽣类的虚函数表中对应的虚函数就会被覆盖成派⽣类重写的虚函数地址。
4>.派⽣类的虚函数表中包含,基类的虚函数地址,派⽣类重写的虚函数地址,派⽣类⾃⼰的虚函数地址三个部分。
5>.虚函数表本质是⼀个存虚函数指针的指针数组,⼀般情况这个数组最后⾯放了⼀个0x00000000标记。(这个C++并没有进⾏规定,各个编译器⾃⾏定义的,vs系列编译器会再后⾯放个0x00000000标记,g++系列编译不会放)
6>.虚函数存在哪的?虚函数和普通函数⼀样的,编译好后是⼀段指令,都是存在代码段的,只是虚函数的地址⼜存到了虚表中。
7>.虚函数表存在哪的?这个问题严格说并没有标准答案C++标准并没有规定,我们写下⾯的代码可以对⽐验证⼀下。(vs下是存在代码段(常量区),我们可以通过下面这段代码来判断一下虚函数表是存放在那个区域的。)
int main()
{
int i = 0;//i存放在栈中
static int j = 0;//j存放在静态区中
int* p1 = new int;//p1中存放的地址是堆中的地址
const char* p2 = "xxx";//p2中存放的地址是常量区的
cout
OK,今天我们就先讲到这里了,那么,我们下一篇再见,谢谢大家的支持!