前言
在学完继承之后,紧接着我们来认识多态,建议继承不太熟的先把继承部分的知识点搞熟,再来学习多态,否则会走火入魔,会混乱。因为多态是建立在继承的基础之上,而且多态中还存在与继承类似的概念,所以学习多态的过程中多多与继承区别,做些对比会容易区分并且记住。
对于多态,通俗点说就是多种形态,具体来说就是不同的对象去完成某个行为时,产生出不同的状态。举两个例子,比如买票这个行为,成人买是全票,学生是半价买票,军人是优先买票;再比如,支付宝抢红包,老用户就少些,新用户就多些,这些都是一种多态行为,具体向下看看实现和原理等相关知识点吧。
多态介绍
构成条件
①通过基类的指针或引用调用虚函数;
②被调用的函数是虚函数 ,且派生类必须重写基类的虚函数,
如下图,通过Func函数进而使用引用去调用虚函数,在Person-Student的父子类中,子类重写了父类的虚函数BuyTicket,由此构成了多态,满足了不同的人购买票的条件不一样。
eg:
虚函数重写
虚函数 :被virtual修饰的类成员函数。
eg:
重写 :也叫覆盖 ,派生类的一个虚函数与基类的一个虚函数的函数名、返回值类型、参数列表完全相同,叫做派生类的虚函数重写了基类的虚函数。
eg:
以上就是构成多态的两个条件,除此之外还有两个例外,这两个例外也构成多态。
注意:
①除了两个例外,不符合重写就是符合隐藏关系,
②子类的的虚函数不加virtual关键字也可以,但建议加上。
两个例外
1.协变
基类和派生类的虚函数返回值可以不同,但必须分别是具有父子关系的类的指针或引用,这样也可以构成多态。当然,也不局限于当前基类和派生类,其他具有父子关系的也可以,但一定要父类返回父类,子类返回子类。
eg:
2.析构函数的重写
如果基类的析构函数处理成虚函数,无论派生类的析构函数加不加virtual,那么基类和派生类的析构函数构成重写,进而构成多态。
一是多态关系中,即使派生类中的函数不加virtual关键字,也依旧构成多态;
二是编译器会对每个类的析构函数做处理,编译后析构函数的名会被统一处理成destructor,所以满足了同名的条件,
存在这个特例的主要目的就是为析构函数构成多态做铺垫,所以建议在继承中析构函数定义成虚函数,那为什么要让析构函数构成多态呢?看下面这种情况:
通过new的方式实例化对象,因为是new了Person和Student类的空间,所以希望delete可以将这两块空间成功释放掉,但是如果是下面这种类的实现,就会产生问题
我们发现,delete p2调用的是Person的析构函数,new出来的Student空间没有完全释放掉,为什么?因为这是普通调用,并没构成多态,delete p2(①p2->destructor;②operator delete(p2);)中,在编译时期就确定了destructor的函数地址,再看到p2是Person类型的指针,所以调用Person的destructor,导致意想不到的错误。如何处理?见下方类实现:
只有派生类的析构函数重写基类的析构函数,才能构成多态,进而保证p1、p2指向的对象正确调用析构函数。
注意上面这种特例不要与下面两种普通情况搞混,下面两种无需构成多态也能正确调用析构函数:
一般情况下,大家只需要记住这种特例就行,或者习惯在具有继承关系的两个类的析构函数加上virtual关键字,进而构成多态,否则在需要构成多态的情境下会出现意想不到的结果。
override和final
overdide和final这是两个关键字,由c++11提供,可以帮助用户检测虚函数是否被重写,因为重写的条件比较多,有时候会因为疏忽(比如函数名并没有相同)并没有构成多态,但编译器不会报错,所以有时候会加大debug的难度。
final:表示该函数不能被重写。
注意:
①使用场景极少;
②final还可用修饰类,表示禁止其他类继承,比如class DontDerive final{};。
eg:
override:一般修饰子类的虚函数,检测此虚函数是否重写了父类的某个虚函数。
eg:
抽象类
纯虚函数:在虚函数后面加上【= 0】。
抽象类 :包含纯虚函数的类,也叫接口类。
抽象类不能实例化出对象,派生类继承抽象类后也不能实例化出对象,只有重写纯虚函数,才能实例化出对象。这属于一种间接强制重写的手段,而且纯虚函数的重写体现出了接口继承,即派生类继承的是基类虚函数的接口(函数头),使用自己的函数体,也就是基类的虚函数的出现本质就是给派生类虚函数重写的。与接口继承对应的就是实现继承,普通函数的继承就是实现继承,基类实现的函数,派生类继承以后可以使用。
eg:
多态原理
如下图,Person对象中有一个虚函数和一个属性sno,通过监视我们可以看到,Person类实例化出的对象中除了sno外还有一个指针变量__vfptr,实际上,这个指针是虚函数表指针(简称虚表指针) ,指向一个虚函数表(简称虚表) ,而在虚函数表中就存在着类中的虚函数地址。值得注意的是,一个含有虚函数的类中都至少有一个虚表指针,且指向一个虚表。
eg:
到底多态的原理是什么呢?每个包含虚函数的类都会有一个虚函数表,虚函数表是一个存放着虚函数地址的指针数组,其中每个指针指向该类的虚函数。当进行函数调用时,通过对象的虚函数指针找到对应的虚函数表,然后根据函数在虚函数表中的索引找到实际要调用的函数,这个过程不是在编译时发生的,而是在运行时发生的;不满足多态的函数调用则是在编译时就确认函数地址然后运行时直接调用。
单继承和多继承的虚表
单继承的虚表
如下图,可以看到父类Person的虚表中有func2和func3,func1不是虚函数,所以不存在虚表中,而在子类Student的虚表之中,我们可以看到,不仅存在父类中的虚函数,且重写了对应虚函数,但是并没有看到子类自己的虚函数,其实有的,只是编译器的监视窗口并未显示,下面会用代码打印出两个虚表的虚函数,具体看一下两个类中虚函数情况。不过,在此之前先总结一下子类的虚表生成过程:
①先将基类中的虚表内容拷贝一份到派生类虚表中;
②如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数;
③派生类自己的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
注意:虚函数并不存在虚表中,虚表也不存在类和对象中。而是,虚表中存在着虚函数的地址,虚函数和普通函数一样,存在于代码段中,只是地址存在了虚表中而已,并且对象中存的是虚表指针,不是虚表,而虚表也是存在于代码段之中的(不同平台结果不一样)。
eg:
打印类的虚函数的代码:
VFPTR是被typedef的一个函数指针,函数传入一个虚表指针,遍历虚表中的虚函数地址,分别调用一下对应虚函数。虚表本质上就是一个存放虚函数地址的指针数组,最后会有一个nullptr,所以调用到空指针为止。
那如何使用这个函数呢,也就是如何取到一个类的虚表指针呢?
在vs下,虚表指针总是在一个类的开头,32位下指针大小为4个字节,所以取到前4个字节就能拿到虚表指针,那如何取到对象的前4个字节呢?
①先取对象p的地址,强转成一个int*的指针;
②再解引用就得到了前4个字节;
③将这4个字节强转成VFPTR*,因为函数需要传入一个VFPTR*。
看到运行结果,在子类的虚表中,除了继承的父类的虚函数,还有自己的虚函数。
cpp
typedef void(*VFPTR)();
void PrintVFTable(VFPTR* table)
{
for (size_t i = 0; table[i] != nullptr; ++i)
{
printf("vft[%d]:%p->", i, table[i]);
VFPTR pf = table[i];
pf();
}
cout << endl;
}
运行:
多继承的虚表
多继承的情况与单继承差不多,就是派生类多了一个或多个基类之后,派生类对象的虚函数地址是放在哪一个基类的虚表中,看下面的例子:
我们发现,派生类的虚函数重写了所有父类的虚函数,则所有父类的虚函数都会被覆盖,但是在监视窗口中并没有发现派生类的虚函数地址放在了哪个基类的虚表中,运行打印类的虚函数的代码试试看:
观察上图发现:在多继承中派生类的虚函数会放在第一个继承基类的虚表中。结论很号看出,但是C类中的B类的前四个字节如何取出的呢?
方法一:在C类中,先继承的基类肯定先放在前面,所以先取地址c强转成char类型指针(因为后面要加上A大小的字节,所以不能直接强转成int类型的指针),再跨过A大小个字节,强转为int指针类型,然后再强转成VFPTR*,防止类型不匹配。
方法二:利用切片取到C中B的部分,再按照之前的方法取到前4个字节,相比之下,方法二更容易理解。
面试常见问题
1.inline函数可以是虚函数吗?
内联函数不可以是虚函数,或者说内联函数与虚函数本质上是相斥的,因为内联函数直接在代码中展开,无函数地址,而作为虚函数,函数地址将会存进虚表中。但作为虚函数时再加上inline关键字,编译也不会报错,因为inline对编译器是一种建议,编译器会自动忽略inline。
2.静态成员函数可以是虚函数吗?
不可以,因为静态成员函数没有this指针,虚函数地址存进虚表中,需要this指针取到虚函数地址进而调用虚函数。
3.构造函数、拷贝构造函数、operator=可以是虚函数吗?
构造函数不可以是虚函数,因为对象中的虚表指针是在构造函数中初始化的(可用监视窗口验证),调用构造函数时还找不到虚表来存储构造函数的地址,
拷贝构造函数也是如此,
而operator=函数语法上可以是虚函数(不会报错),但无实际价值。因为将函数定义成虚函数是为了构成多态,而基类的派生类operator=函数的返回值始终不同,所以始终构成不了多态,所以无实际作用。
- 对象访问普通函数快还是访问虚函数更快?
若定义成了虚函数的同时也构成了多态,那么普通函数快,若没构成多态,则一样快。
5.虚函数表是在什么阶段生成的,存在哪里?
虚表在编译阶段就生成了,在一般情况下,虚表是存在代码段的常量区中。
后记
通过以上的学习,想必大家也是知道我所说的易混淆之处在哪了,比如虚表与虚基表、重写与隐藏等等,这些都是要重点关注的知识点,也是面试当中经常会问到的点,除此之外,还有多态的原理,比较难以理解,需要多花些时间去理解。除了这些基本的知识点之外,最最重要的最后的常见问题,都是面试当中面试官的"杀手锏",不专门去了解的话,还是很难回答的,建议在面试之前看一遍。以上就是多态相关的重点内容介绍了,好好学,拜拜!