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