前言:
本篇文章仅作为作者学习完多态的知识分享,尽力做到描述清晰易懂,如有错误也欢迎指出。
多态的概念:
多态是C++中的一个核心概念,允许对象以不同的方式表现。用现实的话来说,多态就是多种形态,不同对象完成同一个动作会有不一样的结果产生。
就像下图,同样是买火车票,当不同的人群去买同样的火车票,所需要的价格是不同的。
多态的定义以及实现
构成多态的条件
从上图可以看到基类Perons与派生类Student,基类与派生类都拥有一个同名同参数同返回值
的函数BuyTicket()此时就构成了虚函数的重写(稍后会进行讲解),并且都在函数前加上关键字virtual(虚拟的意思)。
此时通过一个fun函数参数为基类的引用,可以看到在main函数中用person对象和student对象去调用fun函数,最终student调用的是student的BuyTicket(),person调用的是person的BuyTicket()
如果将func中的参数改为普通对象,则调用的是person的BuyTicket(),
从上图可以看见,在main函数里创建了基类的对象p与派生类对象s,当用基类的指针指向基类对象就会调用基类的虚函数,而指向派生类对象(产生切片)就会调用派生类的虚函数。
对于多态:
1.不同对象传递过去,调用不同的函数
2.多态的调用看指向的对象
3.普通对象就看当前类型
构成多态的条件:
1.调用的是虚函数的重写
2.必须是基类的指针或引用(底层是指针)。
虚函数:
被virtual修饰的类函数称为虚函数。
虚函数重写:
当你在基类中声明一个虚函数时,允许派生类重写(或覆盖)该函数,以提供特定的实现。但又一个前提即刚才在例子里强调过的,派生类的虚函数与基类的虚函数必须返回值类型、函数名字、参数列表完全相同。
但在C++中构成虚函数重写是有特例的
1.在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写但这种行为是不规范的,容易让人产生误解的。所以在实际的使用过程中建议父子类都加上virtual。
如上图在派生类的 BuyTicket()前并没有加virtua但也构成了函数重写,编译器是允许这种行为的,但不允许只在派生类加virtual而基类不加。
2.C++中有协变,意思是返回类型可以不同,但必须是父子类关系的指针或引用(不常用)
在基类和派生都对虚函数BuyTicket()修改了返回类型,基类的返回类型是基类的引用,派生类的返回类型是派生类的引用,此时调用函数同样也是构成函数重写的。
3.析构函数的重写
我们来看看在类中的析构函数+virtual是否构成函数重写
从上图可以看到,在派生类与基类的析构函数名前加virtual时,虽然函数名不同但同样都构成了函数重写。
person*的指针new了一个person对象后销毁,会先调用~person(),在delete内存。因为delete分两个步骤,1.p->destructor() 调用析构函数 2.operator delete(p)。
而person*的指针new一个Student这是允许的,因为person*会指向student类里的person类(产生了切片)。在销毁时会先调用~student(),再调用~person()。
因为C++规定类的析构函数函数名统一替换为destructor这个名字。
那么为什么C++为什么要做这种特殊处理呢?
因为必须让析构函数构成函数重写,那又为什么一定要让析构函数构成函数重写?因为有个特殊场景。 上图中person与student都不加virtual,此时在主函数中用p的指针new了person类型的对象与student类型的对象,当它们析构的时候都只会调用person的析构函数。
原因是p是person类型的指针,当指向派生类student时产生了切片,p此时指向的是派生类student中的基类person,又因为两个析构函数都没有加virtual所以不会构成函数重载,所以p就只调到person的析构函数而不会调到student的析构函数。
我们期望的是p指向谁就调用谁,所以在这里我们期望的是p->destructor是一个多态调用,而不是普通调用。
重新回顾构成多态的条件
我们知道构成多态的条件有两种
1.调用的函数必须是重写的虚函数。
2.必须是基类的指针和引用。
那么为什么必须是基类的指针和引用呢?
经过学习我们可以知道,因为只有基类的指针和引用才可以即指向基类又指向父类,而父类的指针和引用只能指向父类。
那为什么不能是父类的对象呢?(这个我们后面再来讨论)
重载、覆盖(重写)、隐藏(重定义)的对比 :
虚函数的原理
虚函数表:
我们观察上图,Base类里有一个虚函数func,以及一个char类型的成员变量。如果按照我们之前所学的知识我们知道,成员函数不会存在对象里,而是放在一个公共的区域。所以正常来说Base类型的对象大小只会是一个字节,存一个char。但我们通过输出可以发现输出的值是8而不是1。
通过调试我们可以看到在b对象里,除了存储了一个_val的成员变量还多存了一个_vfptr的指针。
vfptr的意思是:v表示virtual,f表示function,ptr表示指针。所以对象中的这个指针我们叫做虚函数表指针。在C++中一个含有虚函数的类里都至少有一个虚函数表指针,在对象中的这个指针我们叫做虚函数表指针。因为虚函数的地址要被放到虚函数表中,所以虚函数表也叫做虚表。注意这里的虚表与前篇文章提到的菱形虚拟继承的虚机表是不一样的。
那么我们通过调试可以看到Base的vfptr指向的虚表存的都是虚函数。
虚函数存在的区域:
我们知道内存有栈区,堆区,数据段(静态区),代码段(常量区)。那么虚函数是存在哪个区呢?
首先可以排除堆区,因为堆的空间都是需要手动申请与释放。那么有没有可能在栈区呢?那么我们可以验证一下。
从上图调试信息可以看见,主函数的基类b与派生类的c 和 fun1()里的基类b1与派生类的c1它们的虚函数表指针都是同一个地址,那么就可以断定,虚函数表不是单独存在的,而是对象共用的。我们知道栈的生命周期是用完就销毁,而fun1函数调用完b1与c1都会进行销毁,如果销毁的话虚函数表同样是要跟着销毁,而显然是不可能的因为主函数的b和c还指向那个地址,销毁的话就变成野指针了,这显然是不可能的,所以栈也可以基本排除。
那么就剩下两种可能了,不是静态区就是常量区。那么接下来我们就来通过对米示例来验证虚函数是存在哪个区域。
认真观看上图示例,我们分别创建了栈区,堆区,静态区,常量区的变量,并都将它们所处的地址都打印出来。接着我们要取虚函数表地址,经过之前的例子我们可以知道存虚函数表是存在于对象的首地址,所以 *((int*)&p)的意思是先&p取到p的地址,然后(int*)&p将p的地址强制转换成int*类型,最后在解引用获取到地址的数值。
所以最后通过打印输出我们可以十分明确的看到虚函数表的地址离常量区的地址是最近的。由此可以得出结论,虚函数表是存在常量区的。其实放在常量区是更合理的,因为虚函数表的地址肯定是不能去手动改变的,如果允许改变那还能正确的指向吗。
再次讨论为什么不能是父类对象:
首先知道,用派生类对象赋值给父类对象是会产生切片拷贝。而用派生类对象赋值给父类的指针和引用是不会产生拷贝。
所以从上图可以看出,p1与p2都指向的是派生类里的虚表,通过虚表就可以调用相应的虚函数。而p是一个perso对象,当派生类student的s对象赋值给p时候,并没有把student类里的虚表拷贝过去,所以父类p的虚表还是父类的虚表,并不是子类的虚表。
假设如果拷贝的时候会把派生类的虚表拷贝过去,此时再用父类的指针或引用指向父类对象反而调用的虚函数是子类的虚函数,那不就彻底乱套了吗,这显然是不合理的事情。
所以编译器在将派生类对象赋值给父类对象的时候,只会将派生类里的父类对象赋值给父类对象,而不会把虚表赋值过去。
C++派生类自身的虚函数的存储位置
虚函数表其实本质上是一个指针数组,这个指针数组离存放的都是虚函数地址函数地址,在vs编译器中这个虚函数数组末尾会放置nullptr。
那么如果是派生类自身的虚函数是存放在哪里呢?
首先先说结论:派生类自身的虚函数地址同样也是存放在同一个虚表里,只不过会存在虚函数表的末尾。
那么接下来就让我们通过验证来证明这个结论。
先看上图示例,在派生类student中,重写了Buticket函数以及自身还创建了一个虚函数Fun3
从调试窗口中只能看到重写的Buticket函数以及父类继承下来的虚函数,并不能看到派生类自身的虚函数,从这开始调试窗口就变得不太可信。因此我们去查看虚函数表里的存放的地址,可以看到虚函数表里存放了四个函数地址,前三个都很明显就是继承下来的虚函数以及重写的。所以现在最后一个地址我们有理由怀疑这个地址存放的就是派生类自身虚函数的地址。
接下来我们通过一个“骚”操作来更加确定派生类自身的虚函数就是存放在虚函数末尾
以下是函数解释
typedef void (*Func_PTR)();
这一行 typedef 了一个类型别名 Func_PTR
,它是一个指向返回类型为 void
的函数的指针
void PrintVFT(Func_PTR table[ ]):
创建了一个名为PrintVFT的函数,返回类型是void,函数参数是刚刚typedef的void类型的函数指针数组
for (size_t i = 0; table[i] != nullptr; i++):
通过循环去遍历函数指针数组,结束条件是table[i] != nullptr是因为我们提前知道虚函数表的结尾存的就是nullptr。
printf("[%d]:=%p->", i, table[i]):
在每次迭代中,这行代码打印出当前索引 i 以及该索引处的函数指针的地址(即table[ i ]的值)。
Func_PTR t = table[i]; t();
将当前的函数指针赋值给变量t,接着去调用t所指向的函数。
接下来是主函数的代码
在主函数中,我们创建基类对象与派生类对象,并且跟之前一样获取它们的虚函数表地址,用val进行存储,接着调用 PrintVFT函数。
通过打印可以看到,s的虚函数表里的最后一个有效地址就是派生类自身的虚函数地址,成功证明了派生类自身的虚函数地址存在于虚函数表末尾。
关于多继承中的虚函数表位置:
我们来看上图示例,Base1与Base2都被Derive继承,接着Derive中又重写了fun1函数,并添加自身的虚函数fun3。
接着调用之前创建好的PrintVFT函数可以看到,Derive派生类中有两个虚函数表,分别是从Base1继承下来的和Base2继承下来的。并且Derive自身的虚函数会放在第一个虚函数表的末尾。
那么有没有发现虚函数表1和虚函数表2通过不同的地址都调用到了重写的fun1函数,这是为什么呢?难道在多继承中同一个重写的虚函数有两个不同的地址吗?
当我们用Base1与Base2的引用去引用派生类d并都调用它们的fun1函数可以看到调用的都是重写的派生类d的虚函数。接下来小编将通过编码来带观众观察为什么它们明明地址不同却还是能调用相同的重写的虚函数。
b1.fun1( ):
b2.fun1( ):
从上图两个不同基类的引用b1与b2,都会调用fun1函数。而b1没走几步就调用到了,而b2的调用会走很多步。
因为,从图上我们将ecx寄存器单拎出来。这里ecx存的是this指针的地址,因为b1是先继承,b2是后继承。所以b1的位置刚好就是Derive类型对象中d的首地址。
使用Derive的指针与Base1的指针同时指向d那么这两个指针指向的地址都是同一个的,两个地址重叠,而使用Base2的指针指向d时候并不会指向d的首地址。
这里一定要捋清楚此时的fun1是Derive的函数,所以fun1里的隐含的this指针类型必须是Derive*,从图上也可以看清楚,而因为Base1与Derive的指针刚好重叠,都是指向开头的位置。因为在系统内存中是不会考虑你是什么类型的地址,存的都是二进制的0101,所以只看你的内存地址对不对。
而Base2就不同了,它并不在开头的位置,所以Base2调用fun1的汇编代码走了那么多步骤本质上是在修真this指针,将原本Base2*的指针修正为Derive*,也就是ecx-8那一行就是在修正。
修正this指针指向是为了保证Derive类型对象d的成员函数以及成员变量都能正确的调用,因为如果要在fun1函数里对成员函数以及成员变量的调用都需要隐含的this指针。
实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表我们就不看 了,一般我们也不需要研究清楚,因为实际中很少用。
那么本篇文章到这就结束了,感谢各位观看。