July.cc Blogs

本篇文章

手机用户建议
PC模式 或 横屏
阅读


C++ 2022 年 7 月 30 日

[C++] 多态原理的分析: 虚函数表、多态原理、多继承、菱形继承、菱形虚拟继承介绍...

编译器是怎么实现多态调用的?
上一篇文章 详细介绍了什么是多态 和 多态的使用等方面的问题。但是却留下了一个最大的疑问
编译器是怎么实现多态调用的?
本篇文章的内容 就是分析多态实现原理的

多态实现原理

虚函数表

在 介绍菱形虚拟继承的时候,介绍了一张表:虚基表,此表内存储的是数据的相对偏移量。是在菱形虚拟继承中存在的。
而上一篇文章介绍了多态,介绍了一种函数 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);

然后将 虚表指针传入此函数中:

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

所以,其实 子类对象中 无论是自己的虚函数 还是从父类中继承过来的虚函数,无论重写与否,其函数地址都存放在同一个虚函数表中
总结,虚函数表 是存放类虚函数指针的一个表,此表不直接存储在对象中 而是在对象中存储一个指向 此表的指针,即 虚表指针
虚函数表的内容,会 根据是否构成重写而发生变化,这一变化是为多态的实现提供条件的

多态原理

分析了半天的虚函数表,说虚函数表是为多态的实现提供条件的,那么多态究竟是怎么实现的?
在上一篇文章中介绍过,多态需要满足两个条件:
  1. 必须是父类的指针父类的引用 来调用 虚函数
  2. 被调用的函数必须是 虚函数,并且 此 虚函数 必须被 重写
需要第二个条件的原由,其实在上面分析虚函数表时,已经得到答案了
只有父类的虚函数被重写时,子类的虚函数表内 存储的才是 重写之后 的虚函数指针 否则,子类的虚函数表内 存储的依旧是 父类未被重写 的虚函数指针
问题还剩一个,为什么多态调用需要满足 父类指针或父类引用 调用虚函数?

还是在C++继承的分析文章中,介绍过 子类对象赋值给父类对象会发生切割
并且延伸出
  1. 子类对象的地址 可以切割给 父类指针
  2. 子类对象 可以直接切割给 父类引用
在多态的实现中,这两个性质 十分的重要:
以 买票的多态为例:
当使用父类指针或父类引用接收子类的地址或对象时:
可以发现:对象 stu1eld1 的虚表内存储的都是 重写后的虚函数的指针 并且,父类指针 ptrPer 指向的就是 stu1 的父类部分;父类引用 quoPer 就是 eld1父类部分的别名
这样就可以赋予 父类指针 或 父类引用 不同的对象,来多态调用虚函数:
但这只是满足了多态调用的两个条件。编译器是如何选择多态调用还是普通调用的?
用以下类 举个例子:
Advanced类中的 Func1 重写了 Base类中的 Func1,此时:
由于 ptrBas 只是 Advanced对象中的**Base部分的指针**,所以 它指向的内容并没有 Advanced类中的Func2 所以 执行ptrBas->Func2() 时,默认会普通调用 Base类中的Func2()
不同的是,Func1Advanced类中重写了,并且 其函数指针被存入了虚表中。但 其实Base类中也是有Func1 函数的,为什么 调用时去虚表中找重写后的函数指针并调用,而不是普通调用Base中的Func1
其实没有具体的为什么。
为实现多态,C++编译器设计的就是: 当调用函数满足多态两个条件时,编译器不会在 编译时 就根据类中的函数确认函数调用地址,而是在运行时 查找虚表 进而确认调用函数的地址
这是C++编译器 为了实现多态而设定的一种机制,机制是固定且明确的:函数调用满足多态的两个条件
当满足条件时,编译器就不在编译时确定函数调用的地址,而是在运行时查表确定

如何证明,多态调用函数是在运行时才确定函数地址的,而不是在编译时?

VS环境下证明的方法有一个,那就是 查看反汇编代码 (此汇编是一个动作而不是语言)
依旧按照上面例子的操作,查看其反汇编代码:
首先可以非常明显的看到,多态调用 与 普通调用的反汇编代码的步骤是不一样的
尝试大致分析一下,两种调用方式 反汇编的意思:

dword 为 4 字节

多态调用:

虽然具体意思不明确,但是大概意思还是可以看出来的 其实就是在使用各种寄存器,找虚表,查虚表,然后找到函数地址了再call

也就是说,程序再运行时 才去找函数地址了,能够说明 多态调用函数,是在程序运行时才通过查表来确定函数地址的

普通调用:

普通调用,直接就call了函数地址,也就是说运行时就已经知道了需要调用的函数的地址

所以 普通调用是编译时就确定了函数的地址


问题:为什么不能是 父类对象?

首先要先理解:
把子类对象 或 子类对象的地址 赋值给 父类引用 或 父类指针 并不是创建了一个全新的父类对象。而是由 父类指针指向子类对象的父类部分 或 直接给子类对象的父类部分去了别名。本质上还是在子类对象上操作
但是 如果让 父类对象也可以实现多态 那么就需要 在子类对象给父类对象赋值的时候,将子类对象虚表内的 仅子类拥有的 已经重写后的虚函数指针 也赋值给父类对象。
这就导致了 一个普通的父类对象 却 拥有了其他类的虚函数指针,合理吗?
很明显是不合理的
如果一个普通的父类对象虚表内存储的是其子类对象的虚函数指针,那么自己的虚函数指针应该存储在哪里?
所以,不能让父类对象也实现多态,会错误、会混乱

多继承关系的虚函数表

上面的内容都是对于单继承关系虚函数表的
但是C++ 保留了多继承,那么就不得不继续介绍一下 多继承体系中子类的虚函数表的问题了
多继承体系中,子类继承了两个父类,那么父类的虚函数在子类对象中是怎么存在的呢?子类自己的虚函数在对象中又是怎么存在的呢?
以上面这个继承体系为例,查看子类的内容:
从监视窗口可以看到,多继承子类对象中存在多张虚表(具体要看父类个数)
由于VS对虚表进行了优化,使用 查看虚表的函数查看这两张虚表:

取第二个虚表的地址 首先就是将对象地址跳过第一个父类的大小,获得第二个父类的地址

然后解引用,再转换为4字节的指针,再转换为函数指针

可以看到,两张虚表分别存储了父类的虚函数指针,并且 子类自己的虚函数指针是存储在第一张虚表内
但是,有没有发现虚表中诡异的一点
明明是 Advanced 重写过的同一个函数,为什么 两张虚表中的 Advanced:: func1 的地址不同
答案是,编译器进行了 套壳
两张虚表中存储的 Advanced:: func1 的函数指针都不是Advanced:: func1真正的函数指针 编译器在Advanced:: func1的函数指针上又套了一层,即 虚表中存储、显示的函数指针只是一层外壳
编译器的这个处理,可以从多态调用的反汇编代码中分析出来:

Base1部分的 Advanced:: func1 的调用:

Advanced func1
Advanced func1

根据反汇编代码分析 ptr1->func1 的调用过程:

  1. 首先是找到虚表中func1的函数指针(0x00c31401) 存储到寄存器 eax 中,call eax进行调用
  2. 执行 call eax 跳转到 地址为 00C31401 的汇编代码处,此地址处的汇编指令为 jmp 00C327A0
  3. 执行 jmp 00C327A0 之后 开始建立函数栈帧

00C327A0 即为 Advanced::func1 的地址,因为跳转之后,进入函数开始建立函数栈帧

所以 虚表中存储的函数指针 0x00c31401 并不直接是 Advanced::func1 的地址 可以看作是一层壳子

Base2部分的 Advanced:: func1 的调用:

Base2 Advanced func1
Base2 Advanced func1

观察动图 可以发现

调用 Base2 部分的 Advanced:: func1,比起调用 Base1 部分的,显然要更加复杂:

  1. 首先同样是找到虚表中func1的函数指针(0x00c31393) 存储到寄存器 eax 中,call eax进行调用

  2. 执行 call eax 跳转到 地址为 00C31393 的汇编代码处,但是此地址处的汇编指令为 jmp 00C32820,很明显并不是 00C327A0 ,即 Advanced::func1 真正的地址

  3. 跳转至 地址为 00C32820 的汇编代码处,发现 还有两个指令:

    sub ecx,8jmp 00c31401

  4. 执行了 jmp 00c31401 之后,再跳转至了 jmp 00C327A0 指令处

  5. 执行 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 指令的偏移值就会改变:

菱形继承、菱形虚拟继承的虚函数表

建议设计继承体系的时候,最好不要设计出菱形继承

菱形继承

上面关于继承虚函数介绍之后,菱形继承其实没有什么需要特别注意的点
在使用的时候看一下对象的结构、内容就可以明白:
可以看到,最子类对象的内容:
  1. 只属于子类对象的虚函数,依旧存放在第一个虚表中

  2. 对象中虚表存放的函数指针,是最后被重写的函数指针

    子类对象中 存在两个部分:Intermediate1Intermediate2,这两个部分中,func1 和 func3 都是被Advanced 最后重写的,所以 两个部分虚表中存储的都是 Advanced重写的func1和func3

    但是Intermediate1 部分没有重写 func2 ,所以 其虚表部分存储的是 父类的原func2

    Intermediate2 部分重写了 func2,所以 其虚表部分存储的是 Intermediate2重写的func2

菱形虚拟继承

示例依旧是上面的继承体系,不过改为虚拟继承
此时实例化子类对象,查看虚拟继承之后的结构:
可以看到Elementary 类部分被整合在一起,存放到了子类对象的底部,两个腰部 子类都存放了查找父类部分的偏移量(虚基表)

还可以看到 与普通菱形继承不同的是:

菱形虚拟继承的 Elementary 类部分,func2 存储的是 被Intermediate2 重写之后的

也就是说,在菱形虚拟继承中 父类虚函数只要被重写了,虚表内就会存储重写过后的虚函数指针 而不是像普通菱形继承那样:如果Intermediate1没重写,虚表内还存储原父类虚函数

但这也导致了一个问题:如果最终子类没有重写父类的虚函数,腰部子类就不能同时重写这个父类虚函数

如果 腰部子类同时重写了 最终子类没有重写的父类虚函数,则会报错:

虚函数重写不明确,且子类继承不明确

因为腰部子类同时重写了,就代表在同一平行层级有两个重写函数,编译器无法判断此时究竟重写的是哪个,也无法判断 子类继承哪个。

还可以看到,Advanced类自己的虚函数单独存放在了一个虚表内,并且 Advanced类 对象开头存放的就是此虚表的地址:

关于多态的一些问题

介绍完了 菱形继承的虚函数表,其实C++关于多态的点就已经介绍的差不多了
C++多态的细节其实是比较多的,什么接口继承协变析构函数同名 等内容都是非常细节的东西,也是比较折磨人的东西
下面有一些关于多态的问题,可以试一试你对多态等内容的了解程度:
  1. 什么是多态 (已介绍)

  2. 什么是重载、重写、重定义?(已介绍)

  3. 多态的实现原理是什么?(已介绍)

  4. 虚函数可以 inline 吗?

    答,可以,毕竟inline 对于编译器只是一个建议

    可以添加 inline,但是编译器会不会听取建议就不好说了。

    在分析inline 与 虚函数的关系之前,要知道一件事情:inline函数是没有地址的

    因为inline函数是直接被展开的,不存在地址

    在示例分析之前,设置一下VS对inline的优化问题

    由于 类内定义的函数默认inline ,所以需要设置一下,方便控制

    首先打开项目属性:

    然后进行设置

    Elementary 类中,func1设置 inline,func2 不设置

    说明,虚函数确实是可以inline的,编译器也是会听取建议的

    但是,如果是多态调用呢?

    可以看到,编译器十分的灵活:

    1. 普通调用 inline 函数,编译器会听取建议 进行 inline 调用,因为普通调用可以不需要寻址

    2. 多态调用 inline 函数,编译器会忽略 inline属性,因为 多态调用是运行时查表寻址的

      inline函数是无地址的,所以编译器会忽略inline属性

  5. 静态成员函数可以是 虚函数 吗?

    答:不可以

    因为,静态成员函数 是可以直接指定类域调用的, 并且没有this指针

    而直接指定类域调用成员变量,是无法通过查虚表实现的

    所以 静态成员函数地址不能存储到 虚表中,也就不能 加virtual 也就不能是虚函数

  6. 虚函数表是在什么阶段生成的,存在内存中哪各区域的?

    答:虚函数表 是在编译阶段生成的,但是 是在对象实例化时,在 构造函数的初始化列表阶段 给对象的虚表指针 初始化赋值

    Constructor_Virtual-List
    Constructor_Virtual-List

    那么虚函数表是存放在内存中的哪个区域的呢?

    内存中区域的划分大致有四个:栈区、堆区、静态区(数据段)、常量区(代码段)

    虚函数表是存放在哪个区域的呢?

    要判断,就应该了解虚函数表的特性:

    1. 虚函数表是在编译阶段就生成的
    2. 虚函数表不是和对象共存亡的
    3. 虚函数表也不是一个全局的”表”

    其实这三个特点就已经能够判断出,虚函数表存放的位置了

    常量区(代码段)

    虚函数表是在编译阶段就生成的,并且虚函数表的生命周期是整个程序,所以一定不是在栈区和堆区

    而静态区 是用来存储全局的变量等内容的,虚函数表明显不是一种全局可以访问的”表”

    所以 虚函数表最应该存放的位置 应该是 常量区

    并且可以验证一下:

    勉强可以验证 虚函数表的存放区域

  7. 构造函数可以是 虚函数 吗?

    答:不可以

    因为 对象的虚表指针 是在执行构造函数的初始化列表时 才赋值的

    如果构造函数可以是 虚函数,也就意味着它可以多态调用

    但是 多态调用是需要通过虚表指针 查虚函数表获得函数指针的,而对象的虚表指针是在构造函数执行时才赋值

    所以 如果构造函数是虚函数,多态调用时需要查表,查表又需要先执行构造函数

    会发生大错误

  8. 析构函数可以是 虚函数 吗?

    答:可以

    并且,最好设置为虚函数,方便多态调用

  9. 对象访问 普通成员函数 快还是 虚函数 更快?

    答:普通调用虚函数是,一样快

    多态调用虚函数时,由于多了一个查表的过程,所以 会慢一些

  10. C++菱形继承的问题?虚继承的原理?(已介绍)

  11. 什么是抽象类?抽象类的作用?(已介绍)

这些问题 都是对C++多态的理解的问题,最好可以完全的分析清楚
版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)

作者: 哈米d1ch 发表日期:2022 年 7 月 30 日