![[C++] 多态原理的分析: 虚函数表、多态原理、多继承、菱形继承、菱形虚拟继承介绍...](https://dxyt-july-image.oss-cn-beijing.aliyuncs.com/202306251807185.webp)
Table of Contents
上一篇文章 详细介绍了什么是多态 和 多态的使用等方面的问题。但是却留下了一个最大的疑问
编译器是怎么实现多态调用的?
本篇文章的内容 就是分析多态实现原理的
多态实现原理 Link to 多态实现原理
虚函数表 Link to 虚函数表
在 介绍菱形虚拟继承的时候, 介绍了一张表: 虚基表, 此表内存储的是数据的相对偏移量。是在菱形虚拟继承中存在的。
而上一篇文章介绍了多态, 介绍了一种函数 virtual 虚函数
一个类中存在虚函数, 那么此类就会多出一个指向另一个表的指针, 此指针叫 虚表指针, 此表叫 虚函数表, 也称虚表
怎么证明, 存在虚函数的类会有一个虚表指针呢?
下面有一道面试题:
上述类,
sizeof(Base)
是多少?按照类和对象的基础知识, 类成员函数不在类中 不占类空间, 所以只计算 成员变量的大小, 最总结果为 4
但是实际查看会发现:
Base 类的大小为 8(32位环境)
这是为什么?查看Base 类对象内容:
可以看到, 除成员变量 _b 之外, 还存在一个指针 指向了一张表, 并且 这个指针处于类对象的开头位置
这个指针就是 虚表指针, 指向的表就是虚表
虚表是干什么的?仔细观察虚表的内容, 可以看出, 虚表内存储的是 类中所以虚函数的指针, 但是 在介绍类和对象的文章中就提到过, 成员函数都是一起放在一个公共代码段的, 所以其实 虚函数并不是另外存储到了虚表中
也就是说 虚表指针指向的虚表 其实只是集合了 虚函数的指针, 而不是存储了虚函数, 这些虚函数还是与普通的成员函数一起存放在一个公共代码段
也就是说, 虚表指针 指向了一个 存放有虚函数指针的地址 (虚表指针是一个二级指针, **虚表指针
可以直接取到虚表中的第一个虚函数)
拥有虚函数的类存在虚表指针, 指向虚表, 虚表内容是 虚函数指针
如果 继承体系中 父子类虚函数构成了重写, 子类虚表的内容会是什么呢?
对比子类和父类对象的内容:
可以看到, 子类对象中继承于父类的那一部分也有一个虚表指针
但是因为重写了父类虚函数, 所以虚表内的虚函数指针 是被重写之后的虚函数的指针 并且, 没有重写的虚函数与父类中的相同, 所以 子类中的虚表也可以看作是 复制父类的虚表然后覆盖了被重写的函数 ,但是并不是真的复制+覆盖
子类对象 虚表的内容与父类对象相比较, 重写的虚函数覆盖了原虚函数
那么还有一个问题: 子类对象自己的虚函数指针 会存放在哪里呢?
举个例子:
使用上面的子类 实例化对象并查看对象的内容:
在VS的监视窗口中, 子类对象, 既没有新建一个虚表存放只属于自己的虚函数的指针, 原虚表中也没有显示只属于自己的虚函数指针
子类中 只属于自己的虚函数的指针到底存放在了哪里 ?
监视窗口中没有显示, 但是在介绍继承的时候说过, VS的监视窗口是经过软件优化过后的, 会不会只是监视窗口没有显示, 但 只属于自己的虚函数指针 其实也在原续编中存储呢?
监视窗口可以看到虚表的地址, 由此就可以从内存窗口中查看到地址存储的内容:
可以看到, 需表中确实存储了 虚函数指针, 但是第三个指针不能确定是否 是只属于子类的虚函数的指针, 需要验证
怎么验证?
可以取出虚表中的指针, 然后调用指针, 如果能调用 且 执行结果符合函数, 就说明是 子类虚函数指针
32位环境下, 对象的头四个字节即为虚表指针, 怎么取头四个字节?
虚函数的返回值类型为 void, 参数为空, 所以 函数指针类型为
void(* )()
将 函数指针类型 typedef 一下:
typedef void(*VFTPTR )()
VFTPTR
即为新名字, 函数指针的特性虚表指针是一个 指向函数地址的指针, 所以
VFTPTR*
即为函数指针的类型所以, 取对象的地址, 再将其 强转为
int*
, 再解引用, 即为对象的头4个字节的值, 也就是虚表指针的地址 再将其强转为VFTPTR*
, 再赋给VFTPTR*
类型的变量, 此变量就是虚表指针:
VFTPTR* vTable = (VFTPTR*)(*(int*)&dav);
然后将 虚表指针传入此函数中:
CPP
123456789101112void 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
所以, 其实 子类对象中 无论是自己的虚函数 还是从父类中继承过来的虚函数, 无论重写与否, 其函数地址都存放在同一个虚函数表中
总结, 虚函数表 是存放类虚函数指针的一个表, 此表不直接存储在对象中 而是在对象中存储一个指向 此表的指针, 即 虚表指针
虚函数表的内容, 会 根据是否构成重写而发生变化, 这一变化是为多态的实现提供条件的
多态原理 Link to 多态原理
分析了半天的虚函数表, 说虚函数表是为多态的实现提供条件的, 那么多态究竟是怎么实现的?
在上一篇文章中介绍过, 多态需要满足两个条件:
- 必须是父类的指针 或 父类的引用 来调用 虚函数
- 被调用的函数必须是 虚函数, 并且 此 虚函数 必须被 重写
需要第二个条件的原由, 其实在上面分析虚函数表时, 已经得到答案了
只有父类的虚函数被重写时, 子类的虚函数表内 存储的才是 重写之后 的虚函数指针 否则, 子类的虚函数表内 存储的依旧是 父类未被重写 的虚函数指针
问题还剩一个, 为什么多态调用需要满足 父类指针或父类引用 调用虚函数?
还是在C++继承的分析文章中, 介绍过 子类对象赋值给父类对象会发生切割
并且延伸出
- 子类对象的地址 可以切割给 父类指针;
- 子类对象 可以直接切割给 父类引用
在多态的实现中, 这两个性质 十分的重要:
以 买票的多态为例:
当使用父类指针或父类引用接收子类的地址或对象时:
可以发现: 对象 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
?
其实没有具体的为什么。
为实现多态, C++编译器设计的就是: 当调用函数满足多态两个条件时, 编译器不会在 编译时 就根据类中的函数确认函数调用地址, 而是在运行时 查找虚表 进而确认调用函数的地址
这是C++编译器 为了实现多态而设定的一种机制, 机制是固定且明确的: 函数调用满足多态的两个条件
当满足条件时, 编译器就不在编译时确定函数调用的地址, 而是在运行时查表确定
如何证明, 多态调用函数是在运行时才确定函数地址的, 而不是在编译时? Link to 如何证明, 多态调用函数是在运行时才确定函数地址的, 而不是在编译时?
VS环境下证明的方法有一个, 那就是 查看反汇编代码 (此汇编是一个动作而不是语言)
依旧按照上面例子的操作, 查看其反汇编代码:
首先可以非常明显的看到, 多态调用 与 普通调用的反汇编代码的步骤是不一样的
尝试大致分析一下, 两种调用方式 反汇编的意思:
dword
为 4 字节
多态调用:
虽然具体意思不明确, 但是大概意思还是可以看出来的 其实就是在使用各种寄存器, 找虚表, 查虚表, 然后找到函数地址了再call
也就是说, 程序再运行时 才去找函数地址了, 能够说明 多态调用函数, 是在程序运行时才通过查表来确定函数地址的
普通调用:
普通调用, 直接就call了函数地址, 也就是说运行时就已经知道了需要调用的函数的地址
所以 普通调用是编译时就确定了函数的地址
问题: 为什么不能是 父类对象? Link to 问题: 为什么不能是 父类对象?
首先要先理解:
把子类对象 或 子类对象的地址 赋值给 父类引用 或 父类指针 并不是创建了一个全新的父类对象。而是由 父类指针指向子类对象的父类部分 或 直接给子类对象的父类部分去了别名。本质上还是在子类对象上操作的
但是 如果让 父类对象也可以实现多态 那么就需要 在子类对象给父类对象赋值的时候, 将子类对象虚表内的 仅子类拥有的 已经重写后的虚函数指针 也赋值给父类对象。
这就导致了 一个普通的父类对象 却 拥有了其他类的虚函数指针, 合理吗?
很明显是不合理的
如果一个普通的父类对象虚表内存储的是其子类对象的虚函数指针, 那么自己的虚函数指针应该存储在哪里?
所以, 不能让父类对象也实现多态, 会错误、会混乱
多继承关系的虚函数表 Link to 多继承关系的虚函数表
上面的内容都是对于单继承关系虚函数表的
但是C++ 保留了多继承, 那么就不得不继续介绍一下 多继承体系中子类的虚函数表的问题了
多继承体系中, 子类继承了两个父类, 那么父类的虚函数在子类对象中是怎么存在的呢?子类自己的虚函数在对象中又是怎么存在的呢?
以上面这个继承体系为例, 查看子类的内容:
从监视窗口可以看到, 多继承子类对象中存在多张虚表(具体要看父类个数)
由于VS对虚表进行了优化, 使用 查看虚表的函数查看这两张虚表:
取第二个虚表的地址 首先就是将对象地址跳过第一个父类的大小, 获得第二个父类的地址
然后解引用, 再转换为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
指令的偏移值就会改变:
菱形继承、菱形虚拟继承的虚函数表 Link to 菱形继承、菱形虚拟继承的虚函数表
建议设计继承体系的时候, 最好不要设计出菱形继承
菱形继承 Link to 菱形继承
上面关于继承虚函数介绍之后, 菱形继承其实没有什么需要特别注意的点
在使用的时候看一下对象的结构、内容就可以明白:
可以看到, 最子类对象的内容:
只属于子类对象的虚函数, 依旧存放在第一个虚表中
对象中虚表存放的函数指针, 是最后被重写的函数指针
子类对象中 存在两个部分:
Intermediate1
和Intermediate2
, 这两个部分中,func1 和 func3
都是被Advanced
最后重写的, 所以 两个部分虚表中存储的都是Advanced重写的func1和func3
但是
Intermediate1
部分没有重写func2
, 所以 其虚表部分存储的是父类的原func2
Intermediate2
部分重写了func2
, 所以 其虚表部分存储的是Intermediate2重写的func2
菱形虚拟继承 Link to 菱形虚拟继承
示例依旧是上面的继承体系, 不过改为虚拟继承
此时实例化子类对象, 查看虚拟继承之后的结构:
可以看到Elementary 类部分被整合在一起, 存放到了子类对象的底部, 两个腰部 子类都存放了查找父类部分的偏移量(虚基表)
还可以看到 与普通菱形继承不同的是:
菱形虚拟继承的
Elementary
类部分,func2
存储的是 被Intermediate2
重写之后的也就是说, 在菱形虚拟继承中 父类虚函数只要被重写了, 虚表内就会存储重写过后的虚函数指针 而不是像普通菱形继承那样: 如果
Intermediate1
没重写, 虚表内还存储原父类虚函数但这也导致了一个问题: 如果最终子类没有重写父类的虚函数, 腰部子类就不能同时重写这个父类虚函数
如果 腰部子类同时重写了 最终子类没有重写的父类虚函数, 则会报错:
虚函数重写不明确, 且子类继承不明确
因为腰部子类同时重写了, 就代表在同一平行层级有两个重写函数, 编译器无法判断此时究竟重写的是哪个, 也无法判断 子类继承哪个。
还可以看到, Advanced类自己的虚函数单独存放在了一个虚表内, 并且 Advanced类 对象开头存放的就是此虚表的地址:
关于多态的一些问题 Link to 关于多态的一些问题
介绍完了 菱形继承的虚函数表, 其实C++关于多态的点就已经介绍的差不多了
C++多态的细节其实是比较多的, 什么接口继承、协变、析构函数同名 等内容都是非常细节的东西, 也是比较折磨人的东西
下面有一些关于多态的问题, 可以试一试你对多态等内容的了解程度:
什么是多态 (已介绍)
什么是重载、重写、重定义?(已介绍)
多态的实现原理是什么?(已介绍)
虚函数可以 inline 吗?
答, 可以, 毕竟inline 对于编译器只是一个建议
可以添加 inline, 但是编译器会不会听取建议就不好说了。
在分析inline 与 虚函数的关系之前, 要知道一件事情: inline函数是没有地址的
因为inline函数是直接被展开的, 不存在地址
在示例分析之前, 设置一下VS对inline的优化问题
由于 类内定义的函数默认inline , 所以需要设置一下, 方便控制
首先打开项目属性:
然后进行设置
Elementary 类中, func1设置 inline, func2 不设置
说明, 虚函数确实是可以
inline
的, 编译器也是会听取建议的但是, 如果是多态调用呢?
可以看到, 编译器十分的灵活:
普通调用 inline 函数, 编译器会听取建议 进行 inline 调用, 因为普通调用可以不需要寻址
多态调用 inline 函数, 编译器会忽略 inline属性, 因为 多态调用是运行时查表寻址的
inline函数是无地址的, 所以编译器会忽略inline属性
静态成员函数可以是 虚函数 吗?
答: 不可以
因为, 静态成员函数 是可以直接指定类域调用的, 并且没有this指针
而直接指定类域调用成员变量, 是无法通过查虚表实现的
所以 静态成员函数地址不能存储到 虚表中, 也就不能 加virtual 也就不能是虚函数
虚函数表是在什么阶段生成的, 存在内存中哪各区域的?
答: 虚函数表 是在编译阶段生成的, 但是 是在对象实例化时, 在 构造函数的初始化列表阶段 给对象的虚表指针 初始化赋值的
那么虚函数表是存放在内存中的哪个区域的呢?
内存中区域的划分大致有四个: 栈区、堆区、静态区(数据段)、常量区(代码段)
虚函数表是存放在哪个区域的呢?
要判断, 就应该了解虚函数表的特性:
- 虚函数表是在编译阶段就生成的
- 虚函数表不是和对象共存亡的
- 虚函数表也不是一个全局的”表”
其实这三个特点就已经能够判断出, 虚函数表存放的位置了
常量区(代码段)
虚函数表是在编译阶段就生成的, 并且虚函数表的生命周期是整个程序, 所以一定不是在栈区和堆区
而静态区 是用来存储全局的变量等内容的, 虚函数表明显不是一种全局可以访问的”表”
所以 虚函数表最应该存放的位置 应该是 常量区
并且可以验证一下:
勉强可以验证 虚函数表的存放区域
构造函数可以是 虚函数 吗?
答: 不可以
因为 对象的虚表指针 是在执行构造函数的初始化列表时 才赋值的
如果构造函数可以是 虚函数, 也就意味着它可以多态调用
但是 多态调用是需要通过虚表指针 查虚函数表获得函数指针的, 而对象的虚表指针是在构造函数执行时才赋值的
所以 如果构造函数是虚函数, 多态调用时需要查表, 查表又需要先执行构造函数
会发生大错误
析构函数可以是 虚函数 吗?
答: 可以
并且, 最好设置为虚函数, 方便多态调用
对象访问 普通成员函数 快还是 虚函数 更快?
答: 普通调用虚函数是, 一样快
多态调用虚函数时, 由于多了一个查表的过程, 所以 会慢一些
C++菱形继承的问题?虚继承的原理?(已介绍)
什么是抽象类?抽象类的作用?(已介绍)
这些问题 都是对C++多态的理解的问题, 最好可以完全的分析清楚
[C++] 多态原理的分析: 虚函数表、多态原理、多继承、菱形继承、菱形虚拟继承介绍...
© 哈米d1ch | CC BY-SA 4.0