警告:下面的大部分内容当然是依赖于实现和平台的并进行了简化。我将按照我在您的示例中看到的实现方式进行操作 - 可能是 GCC,64 位。
首先,虚拟类实例的契约是什么?例如。如果你有一个变量IFace1* obj
:
- 有一个指向虚拟表的指针obj+0.
- 任何成员数据字段将继续obj+8 (
sizeof(void*)
).
- 虚拟表包含一条记录,该记录指向
void fcn(int)
at vtbl+0.
- 表中还有一个指针
typeinfo
班级的vtbl-8(由dynamic_cast
等)和“到基数的偏移量" at vtbl-16.
任何看到类型变量的函数IFace1*
可以相信这是真的。同样对于IFace2*
.
- 如果他们想调用虚函数
void fcn(int)
,他们看着obj+0获取虚函数表,然后在vtbl+0并拨打在那里找到的地址。this
被设定为obj.
- 如果他们想要访问成员字段(通过他们自己,例如,如果该字段具有公共访问权限,或者如果有内联访问器),他们只需在其地址处读/写该成员obj+xxx.
- 如果他们想查看他们真正拥有的类型,他们会减去vtbl-16从地址到他们的对象,然后看看
typeinfo
基础对象引用的虚函数表的指针。
现在,编译器如何满足具有多重继承的类的这些要求?
1)首先它需要为自己生成结构。虚拟表指针必须位于obj+0,就是这样。桌子会是什么样子?嗯,到基数的偏移量为0,显然,typeinfo
数据和指向它的指针很容易生成,然后是第一个虚拟函数和第二个,没有什么特别的。任何知道定义的人RealClass
可以进行相同的计算,因此他们知道在哪里可以找到虚函数表等中的函数。
2)然后它就可以让RealClass
被传递为IFace1
。所以它需要有一个指向虚拟表的指针IFace1
格式化对象中的某处,那么虚拟表必须有该记录void fcn(int)
.
编译器很聪明,发现它可以重用它生成的第一个虚拟表,因为它符合这些要求。如果有任何成员字段,它们将存储在指向虚拟表的第一个指针之后,因此即使是它们也可以像派生类是基类一样简单地访问。到目前为止,一切都很好。
3)最后,如何处理该对象以便其他人能够将其用作IFace2
?已经创建的一个 vtable 不能再使用了,因为IFace2
需要它的void fcn1(int)
处于vtbl+0.
因此,创建了另一个虚拟表,即您在转储中紧随第一个虚拟表之后看到的虚拟表,并且指向它的指针存储在RealClass
在下一个可用的地方。第二个表需要有到基数的偏移量设置为 -8,因为真实对象从偏移量 -8 开始。它只包含指向该指针的指针IFace2
虚函数,void fcn1(int)
.
对象中的虚拟指针(位于偏移量处)obj+8) 之后将跟随以下的任何成员数据字段IFace2
,这样当给定指向该接口的指针时,任何继承或内联函数都可以再次工作。
好的,现在别人怎么能打电话给fcn1()
from IFace2
?那是什么non-virtual thunk to RealClass::fcn1(int)
?
如果你通过了你的RealClass*
指向一个陌生函数的指针,该函数需要IFace2*
,编译器将发出代码将指针增加 8(或者无论多大)sizeof(void*) + sizeof(IFace1)
是),这样函数就得到了以虚表指针开头的指针IFace2
,然后是它的成员字段——正如我之前概述的合同中商定的那样。
当该函数想要调用时void IFace2::fcn1(int)
,它会查看虚拟表,转到该特定函数的记录(第一个也是唯一一个)并调用它,this
设置为作为指针传递的地址IFace2
.
这里出现了一个问题:如果有人调用这个实现的方法RealClass
on a RealClass
指针,this
指向基数RealClass
。与IFace1
。但是如果它是由拥有指向的指针的人调用的IFace2
界面,this
相反,将 8 个(或任意多个)字节指向对象!
因此,编译器需要多次生成该函数来适应这一点,否则它无法正确访问成员字段和其他方法,因为它根据调用该方法的人而有所不同。
编译器通过创建隐藏的隐式小代码来优化它,而不是让代码真正重复两次。thunk函数代替,这只是
- 减少了
this
指针移动适当的量,
- 调用真正的方法,现在无论谁调用它都可以正常工作。