在Slack上,我遇到了
C ++首字母缩略词词汇表的新首
字母缩略词 :“ VTT”。
Godbolt :
test.o: In function `MyClass': test.cc:3: undefined reference to `VTT for MyClass'
在本文中,“ VTT”是指“虚拟表表”。 这是创建一些本身从虚拟基类继承的基类时使用的辅助数据结构(在Itanium C ++ ABI中)。 VTT遵循与虚拟表(vtable)和类型信息(typeinfo)相同的布局规则,因此,如果您遇到上述错误,则可以在精神上用“ vtable”代替“ VTT”并开始调试。 (很可能您未定义该类的
键功能 )。 为了了解为什么需要VTT或类似的结构,让我们从基础开始。
非虚拟继承的设计顺序
当我们有一个继承层次结构时,基类是
从最基本的开始构造的。 要构造Charlie,我们必须首先构造其父类MrsBucket和MrBucket,以递归方式来构造MrBucket,首先必须构造其父类GrandmaJosephine和GrandpaJoe。
像这样:
struct A {}; struct B : A {}; struct C {}; struct D : C {}; struct E : B, D {};
虚拟基类的设计顺序
但是虚拟继承会混淆所有信息! 使用虚拟继承,我们可以拥有菱形的层次结构,其中两个不同的父类可以共享一个共同的祖先。
struct G {}; struct M : virtual G {}; struct F : virtual G {}; struct E : M, F {};
在最后一节中,每个构造函数负责调用其基类的构造函数。 但是现在我们有了虚拟继承,并且构造函数M和F必须以某种方式知道不必构造G,因为它很常见。 如果在这种情况下M和F负责构造基础对象,则公共基础对象将被构造两次,这不是很好。
为了使用虚拟继承子对象,Itanium C ++ ABI将每个构造函数分为两部分:基础对象构造函数和完整对象构造函数。 基础对象的构造函数负责构造所有非虚拟继承子对象(及其子对象,并将其vptr安装在其vtable上,并在C ++代码中以大括号运行代码)。 每次创建完整的C ++对象时都会调用该完整对象的构造函数,该构造函数负责构造派生对象的虚拟继承的所有子对象,然后进行其余工作。
考虑上一节中的ABCDE示例与以下示例之间的区别:
struct A {}; struct B : virtual A {}; struct C {}; struct D : virtual C {}; struct E : B, D {};
完整对象E的构造函数首先调用虚拟子对象A和C的基础对象的构造函数; 然后调用基本非虚拟继承对象B和D的构造函数,B和D不再分别负责构造A和C。
设计vtable表
假设我们有一个带有一些虚拟方法的类,例如(
Godbolt ):
struct Cat { Cat() { poke(); } virtual void poke() { puts("meow"); } }; struct Lion : Cat { std::string roar = "roar"; Lion() { poke(); } void poke() override { roar += '!'; puts(roar.c_str()); } };
构造Lion时,首先要构造基本的Cat子对象。 Cat的构造函数调用poke()。 此时,我们只有一个Cat对象-我们尚未初始化制作Lion对象所需的成员数据。 如果Cat构造函数调用Lion :: poke(),则可以尝试更改std :: string roar的未初始化成员,从而得到UB。 因此,C ++标准要求我们在Cat构造函数中执行此操作,对poke()虚拟方法的调用应调用Cat :: poke(),而不是Lion :: poke()!
没问题 编译器只是通过将对象的vptr设置为Cat对象的vtable来使Cat :: Cat()(基础对象的版本和完整对象的版本)开始。 Lion :: Lion()将调用Cat :: Cat(),然后将vptr重置为Lion中Cat对象的vtable表的指针,然后再运行括号中的代码。 没问题!
虚拟继承偏移
让猫实际上继承自动物。 然后,用于Cat的vtable不仅存储Cat虚拟成员函数的函数指针,而且还存储Cat内部的Animal虚拟子对象的偏移量。 (
天哪 。)
struct Animal { const char *data = "hi"; }; struct Cat : virtual Animal { Cat() { puts(data); } }; struct Nermal : Cat {}; struct Garfield : Cat { int padding; };
Cat构造函数查询成员Animal ::数据。 如果此Cat对象是Nermal对象的基础子对象,则其成员数据位于vptr后面的偏移量8处。 但是,如果Cat对象是Garfield对象的基础子对象,则成员数据位于vptr和Garfield :: padding之后的偏移量16。 为了解决这个问题,Itanium ABI将虚拟基础对象的偏移量存储在Cat对象的vtable中。 Cat-in-Nermal的vtable保留以下事实:动物(基础Cat子对象)存储在偏移量8处; Cat-in-Garfield的vtable保留以下事实:动物(基础Cat子对象)存储在偏移量16处。
现在,将其与上一节结合。 编译器必须确保Cat :: Cat()(基础对象版本和完整对象版本)都可以通过在类型为Cat-in-Nermal的vtable上或在Cat-in-Garfield的vtable上安装vptr来启动,具体取决于类型最衍生的设施! 但是它是如何工作的呢?
派生程度最高的对象的完整对象的构造函数必须预先计算在构造对象时他希望基础子对象的vptr引用哪个表vtable,然后派生度最大的对象的完整对象的构造函数必须将此信息传递给基础子对象的基础对象的构造函数作为隐藏参数! 让我们看一下Cat :: Cat()(
Godbolt )的生成代码:
_ZN3CatC1Ev: # Cat movq $_ZTV3Cat+24, (%rdi) # this->vptr = &vtable-for-Cat; retq _ZN3CatC2Ev: # Cat movq (%rsi), %rax # fetch a value from rsi movq %rax, (%rdi) # this->vptr = *rsi; retq
基础对象的构造函数不仅接受%rdi中的此隐藏参数,还接受%rsi中的隐藏VTT参数! 基础对象构造函数从(%rsi)加载地址,并将该地址存储在Cat对象的vtable中。
调用基本Cat对象的构造函数的人都有责任预测应在vptr中写入哪个Cat :: Cat()地址,并将(%rsi)中的指针设置为该地址。
为什么我们需要另一种身份?
考虑完整的Nermal对象的构造函数。
_ZN3CatC2Ev: # Cat movq (%rsi), %rax # rsi movq %rax, (%rdi) # this->vptr = *rsi; retq _ZN6NermalC1Ev: # Nermal pushq %rbx movq %rdi, %rbx movl $_ZTT6Nermal+8, %esi # %rsi = &VTT-for-Nermal callq _ZN3CatC2Ev # Cat movq $_ZTV6Nermal+24, (%rbx) # this->vptr = &vtable-for-Nermal popq %rbx retq _ZTT6Nermal: .quad _ZTV6Nermal+24 # vtable-for-Nermal .quad _ZTC6Nermal0_3Cat+24 # construction-vtable-for-Cat-in-Nermal
为什么_ZTC6Nermal0_3Cat + 24位于数据部分中,并且其地址传递给%rsi,而不是直接传递_ZTC6Nermal0_3Cat + 24?
# ? _ZN3CatC2Ev: # Cat movq %rsi, (%rdi) # this->vptr = rsi; retq _ZN6NermalC1Ev: # Nermal pushq %rbx movq %rdi, %rbx movl $_ZTC6Nermal0_3Cat+24, %esi # %rsi = &construction-vtable-for-Cat-in-Nermal callq _ZN3CatC2Ev # Cat movq $_ZTV6Nermal+24, (%rbx) # this->vptr = &vtable-for-Nermal popq %rbx retq
这是因为我们可以具有多个继承级别! 在每个继承级别,基础对象的构造函数都应设置vptr,然后可能将控制权进一步沿链向下传递到下一个基础构造函数,后者可以将vptrs设置为其他值。 这意味着指向vtable的列表或指针表。
这是一个具体示例(
Godbolt ):
struct VB { int member_of_vb = 42; }; struct Grandparent : virtual VB { Grandparent() {} }; struct Parent : Grandparent { Parent() {} }; struct Gretel : Parent { Gretel() : VB{1000} {} }; struct Hansel : Parent { int padding; Hansel() : VB{2000} {} };
Grandparent基本构造函数对象必须将其vptr设置为Grandparent-其他,这是派生程度最高的类。 Parent基础对象的构造函数必须首先使用适当的%rsi调用Grandparent :: Grandparent(),然后将vptr设置为Parent,这是派生程度最高的类。 为Gretel实施此方法:
Gretel::Gretel() [ ]: pushq %rbx movq %rdi, %rbx movl $1000, 8(%rdi) # imm = 0x3E8 movl $VTT for Gretel+8, %esi callq Parent::Parent() [ ] movq $vtable for Gretel+24, (%rbx) popq %rbx retq VTT for Gretel: .quad vtable for Gretel+24 .quad construction vtable for Parent-in-Gretel+24 .quad construction vtable for Grandparent-in-Gretel+24
您可以在Godbolt中看到,Parent类的基础对象的构造函数首先调用%rsi + 8的Grandparent :: Grandparent(),然后将其自己的vptr设置为(%rsi)。 因此,在这里我们利用的事实是,可以说,Gretel精心铺设了一条面包屑路径,在构建过程中,她的所有基层都遵循了这一路径。
析构函数(
Godbolt )中使用了相同的VTT。 据我所知,VTT表的空行从未使用过。 Gretel构造函数将Gretel + 24的vtable加载到vptr中,但他知道此地址是静态的,因此不需要从VTT加载。 我认为保留表的零行仅出于历史原因。 (当然,编译器不能只是将其丢弃,因为这将违反Itanium ABI,并且不可能链接到遵循Itanium-ABI的旧代码)。
就是这样,我们查看了一个虚拟表或VTT表。
进一步资料
您可以在以下位置找到VTT信息:
StackOverflow:“
什么是课程的VTT? ”
“
GCC C ++编译器v4.0.1中关于多重继承的VTable注意 ”(Morgan Deters,2005年)
Itanium C ++ ABI的 “ VTT订单”部分
最后,我必须重申VTT是Itanium C ++ ABI的功能,并且在Linux,OSX等上使用。 Windows上使用的MSVC ABI没有VTT,并且对虚拟继承使用完全不同的机制。 我(到目前为止)对MSVC ABI几乎一无所知,但也许有一天我会发现所有内容并撰写有关它的文章!