[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 日