[C++] 多态原理的分析: 虚函数表、多态原理、多继承、菱形继承、菱形虚拟继承介绍...
多态实现原理
虚函数表
virtual 虚函数
上述类,
sizeof(Base)
是多少?按照类和对象的基础知识,类成员函数不在类中 不占类空间,所以只计算 成员变量的大小,最总结果为 4
但是实际查看会发现:
Base 类的大小为 8(32位环境)
这是为什么?查看Base 类对象内容:
可以看到,除成员变量 _b 之外,还存在一个指针 指向了一张表,并且 这个指针处于类对象的开头位置
这个指针就是 虚表指针,指向的表就是虚表
**虚表指针
可以直接取到虚表中的第一个虚函数)虚函数的返回值类型为 void,参数为空,所以 函数指针类型为
void(* )()
将 函数指针类型 typedef 一下:
typedef void(*VFTPTR )()
VFTPTR
即为新名字,函数指针的特性虚表指针是一个 指向函数地址的指针,所以
VFTPTR*
即为函数指针的类型所以,取对象的地址,再将其 强转为
int*
,再解引用,即为对象的头4个字节的值,也就是虚表指针的地址 再将其强转为VFTPTR*
,再赋给VFTPTR*
类型的变量,此变量就是虚表指针:
VFTPTR* vTable = (VFTPTR*)(*(int*)&dav);
然后将 虚表指针传入此函数中:
void PrintVTable(VFTPTR vTable[]) { // 虚表指针地址是一个二维数组,所以形参可以为 VFTPTR 类型的数组 // 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数 cout << " 虚表地址>" << vTable << endl; for (int i = 0; vTable[i] != nullptr; ++i) { printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]); VFTPTR f = vTable[i]; // 取函数指针 f(); // 函数指针调用函数 } cout << endl; }
此函数循环体的结束标志是,遇到
nullptr
是因为在VS 平台下,虚表的结尾是一个空指针继续调用函数:
可以看到 函数指针调用函数,第三个函数指针的执行结果确实与 子类自己的虚函数执行结果相同
由此可以证明 原虚函数表中确实存储了 属于子类自己的虚函数的指针,但是 VS的监视窗口中没有显示
个人感觉这是一个BUG
多态原理
- 必须是父类的指针 或 父类的引用 来调用 虚函数
- 被调用的函数必须是 虚函数,并且 此 虚函数 必须被 重写
- 子类对象的地址 可以切割给 父类指针;
- 子类对象 可以直接切割给 父类引用
stu1
和 eld1
的虚表内存储的都是 重写后的虚函数的指针
并且,父类指针 ptrPer
指向的就是 stu1 的父类部分
;父类引用 quoPer
就是 eld1父类部分的别名
Advanced类
中的 Func1
重写了 Base类
中的 Func1
,此时:ptrBas
只是 Advanced对象
中的**Base
部分的指针**,所以 它指向的内容并没有 Advanced
类中的Func2
所以 执行ptrBas->Func2()
时,默认会普通调用 Base
类中的Func2()
Func1
在Advanced类
中重写了,并且 其函数指针被存入了虚表中。但 其实Base类
中也是有Func1
函数的,为什么 调用时去虚表中找重写后的函数指针并调用,而不是普通调用Base
中的Func1
?如何证明,多态调用函数是在运行时才确定函数地址的,而不是在编译时?
dword
为 4 字节
多态调用:
虽然具体意思不明确,但是大概意思还是可以看出来的 其实就是在使用各种寄存器,找虚表,查虚表,然后找到函数地址了再call
也就是说,程序再运行时 才去找函数地址了,能够说明 多态调用函数,是在程序运行时才通过查表来确定函数地址的
普通调用:
普通调用,直接就call了函数地址,也就是说运行时就已经知道了需要调用的函数的地址
所以 普通调用是编译时就确定了函数的地址
问题:为什么不能是 父类对象?
多继承关系的虚函数表
取第二个虚表的地址 首先就是将对象地址跳过第一个父类的大小,获得第二个父类的地址
然后解引用,再转换为4字节的指针,再转换为函数指针
Advanced
重写过的同一个函数,为什么 两张虚表中的 Advanced:: func1
的地址不同?Advanced:: func1
的函数指针都不是Advanced:: func1
真正的函数指针
编译器在Advanced:: func1
的函数指针上又套了一层,即 虚表中存储、显示的函数指针只是一层外壳于
Base1
部分的Advanced:: func1
的调用:根据反汇编代码分析
ptr1->func1
的调用过程:
- 首先是找到虚表中
func1
的函数指针(0x00c31401)
存储到寄存器eax
中,call eax
进行调用- 执行
call eax
跳转到 地址为00C31401
的汇编代码处,此地址处的汇编指令为jmp 00C327A0
- 执行
jmp 00C327A0
之后 开始建立函数栈帧
00C327A0
即为Advanced::func1
的地址,因为跳转之后,进入函数开始建立函数栈帧所以 虚表中存储的函数指针
0x00c31401
并不直接是Advanced::func1
的地址 可以看作是一层壳子
于
Base2
部分的Advanced:: func1
的调用:观察动图 可以发现
调用
Base2
部分的Advanced:: func1
,比起调用Base1
部分的,显然要更加复杂:
首先同样是找到虚表中
func1
的函数指针(0x00c31393)
存储到寄存器eax
中,call eax
进行调用执行
call eax
跳转到 地址为00C31393
的汇编代码处,但是此地址处的汇编指令为jmp 00C32820
,很明显并不是00C327A0
,即Advanced::func1
真正的地址跳转至 地址为
00C32820
的汇编代码处,发现 还有两个指令:
sub ecx,8
和jmp 00c31401
执行了
jmp 00c31401
之后,再跳转至了jmp 00C327A0
指令处执行
jmp 00C327A0
进入Advanced::func1
开始建立函数栈帧可以看到,虽然
ptr2->func1();
最后执行的也是jmp 00C327A0
,但是其中间包含的过程更多
Base1
部分的func1
,是通过Base1
虚表中的壳子 直接跳转到 真正的函数指针,然后建立栈帧Base2
部分的func1
,则是通过Base2
部分虚表中的壳子,找到Base1
虚表中的壳子,然后跳转 到 真正的函数指针,再建立栈帧的func1
是重写的父类的虚函数,即 此虚函数并不属于父类,而是属于子类的。所以在调用时,需要知道 子类对象的地址,再由此地址找到虚函数指针 进行对函数的调用。
而由于 Base1
是子类继承的第一个父类,所以子类对象的地址就是 ptr1
指向的地址,所以可以直接 找到虚函数指针进行调用
但是,Base2
是子类的第二个父类,所以ptr2
指向的地址与子类对象的地址相隔了一个 Base1
的大小,所以才会有此指令:
sub ecx, 8
此指令就是去找子类对象地址的,8
即为 Base1
的大小
找到子类对象地址之后,再像 ptr1->func1
那样 去找虚函数真正的指针 进而调用函数当
Base1
大小改变时,sub
指令的偏移值就会改变:
菱形继承、菱形虚拟继承的虚函数表
建议设计继承体系的时候,最好不要设计出菱形继承
菱形继承
-
只属于子类对象的虚函数,依旧存放在第一个虚表中
-
对象中虚表存放的函数指针,是最后被重写的函数指针
子类对象中 存在两个部分:
Intermediate1
和Intermediate2
,这两个部分中,func1 和 func3
都是被Advanced
最后重写的,所以 两个部分虚表中存储的都是Advanced重写的func1和func3
但是
Intermediate1
部分没有重写func2
,所以 其虚表部分存储的是父类的原func2
Intermediate2
部分重写了func2
,所以 其虚表部分存储的是Intermediate2重写的func2
菱形虚拟继承
还可以看到 与普通菱形继承不同的是:
菱形虚拟继承的
Elementary
类部分,func2
存储的是 被Intermediate2
重写之后的也就是说,在菱形虚拟继承中 父类虚函数只要被重写了,虚表内就会存储重写过后的虚函数指针 而不是像普通菱形继承那样:如果
Intermediate1
没重写,虚表内还存储原父类虚函数但这也导致了一个问题:如果最终子类没有重写父类的虚函数,腰部子类就不能同时重写这个父类虚函数
如果 腰部子类同时重写了 最终子类没有重写的父类虚函数,则会报错:
虚函数重写不明确,且子类继承不明确
因为腰部子类同时重写了,就代表在同一平行层级有两个重写函数,编译器无法判断此时究竟重写的是哪个,也无法判断 子类继承哪个。
关于多态的一些问题
-
什么是多态 (已介绍)
-
什么是重载、重写、重定义?(已介绍)
-
多态的实现原理是什么?(已介绍)
-
虚函数可以 inline 吗?
答,可以,毕竟inline 对于编译器只是一个建议
可以添加 inline,但是编译器会不会听取建议就不好说了。
在分析inline 与 虚函数的关系之前,要知道一件事情:inline函数是没有地址的
因为inline函数是直接被展开的,不存在地址
在示例分析之前,设置一下VS对inline的优化问题
由于 类内定义的函数默认inline ,所以需要设置一下,方便控制
首先打开项目属性:
然后进行设置
Elementary 类中,func1设置 inline,func2 不设置
说明,虚函数确实是可以
inline
的,编译器也是会听取建议的但是,如果是多态调用呢?
可以看到,编译器十分的灵活:
-
普通调用 inline 函数,编译器会听取建议 进行 inline 调用,因为普通调用可以不需要寻址
-
多态调用 inline 函数,编译器会忽略 inline属性,因为 多态调用是运行时查表寻址的
inline函数是无地址的,所以编译器会忽略inline属性
-
-
静态成员函数可以是 虚函数 吗?
答:不可以
因为,静态成员函数 是可以直接指定类域调用的, 并且没有this指针
而直接指定类域调用成员变量,是无法通过查虚表实现的
所以 静态成员函数地址不能存储到 虚表中,也就不能 加virtual 也就不能是虚函数
-
虚函数表是在什么阶段生成的,存在内存中哪各区域的?
答:虚函数表 是在编译阶段生成的,但是 是在对象实例化时,在 构造函数的初始化列表阶段 给对象的虚表指针 初始化赋值的
那么虚函数表是存放在内存中的哪个区域的呢?
内存中区域的划分大致有四个:栈区、堆区、静态区(数据段)、常量区(代码段)
虚函数表是存放在哪个区域的呢?
要判断,就应该了解虚函数表的特性:
- 虚函数表是在编译阶段就生成的
- 虚函数表不是和对象共存亡的
- 虚函数表也不是一个全局的”表”
其实这三个特点就已经能够判断出,虚函数表存放的位置了
常量区(代码段)
虚函数表是在编译阶段就生成的,并且虚函数表的生命周期是整个程序,所以一定不是在栈区和堆区
而静态区 是用来存储全局的变量等内容的,虚函数表明显不是一种全局可以访问的”表”
所以 虚函数表最应该存放的位置 应该是 常量区
并且可以验证一下:
勉强可以验证 虚函数表的存放区域
-
构造函数可以是 虚函数 吗?
答:不可以
因为 对象的虚表指针 是在执行构造函数的初始化列表时 才赋值的
如果构造函数可以是 虚函数,也就意味着它可以多态调用
但是 多态调用是需要通过虚表指针 查虚函数表获得函数指针的,而对象的虚表指针是在构造函数执行时才赋值的
所以 如果构造函数是虚函数,多态调用时需要查表,查表又需要先执行构造函数
会发生大错误
-
析构函数可以是 虚函数 吗?
答:可以
并且,最好设置为虚函数,方便多态调用
-
对象访问 普通成员函数 快还是 虚函数 更快?
答:普通调用虚函数是,一样快
多态调用虚函数时,由于多了一个查表的过程,所以 会慢一些
-
C++菱形继承的问题?虚继承的原理?(已介绍)
-
什么是抽象类?抽象类的作用?(已介绍)
作者: 哈米d1ch 发表日期:2022 年 7 月 30 日