C++: multiple and virtual inheritance under the hood

0. 序

前两天跑llvm的时候遇到了有关RTTI的链接报错,借此重新回来看下C++的多继承相关的东西。希望通过本文能够把多继承和虚继承的机制解释明白。以及以下的C++术语

  • vcall offset
  • vbase offset
  • non-virtual thunk, virtual thunk
  • complete object constructor, base object constructor
  • construction vtable
  • VTT

本文的内容主要基于cpp_vtable,建议大概阅读一遍之后再继续看下面的内容。

1. data layout of vtable

Itanium C++ ABI在规定了vtable需要按顺序包含如下的表项:

  • virtual call(vcall) offset (probably more than one or not present)
  • virtual base(vbase) offset (probably more than one or not present)
  • offset to top (always present)
  • typeinfo pointer (always present)
  • virtual function pointers (probably more than one or not present, vptr contained in object points here)

因此vptr[-1]就指向了typeid运算符的返回值,vptr[-2]则指向了该对象在派生类对象中的偏移。vcall offsetvbase offset用于virtual inheritance,具体含义会在接下来探讨,值得注意的是,这俩在vtable中可能会有多项(这在cpp_vtable并未提到)。

2. non-virtual multiple inheritance

这种情况相对简单,带来的问题只是说其中一个base在派生对象的内存布局的中间,导致需要pointer adjustment。举例一些隐式的指针运算。

struct C: A, B{
/// ....
// layout of C:
//   vptr A in C /C  (A C share one vtable
//   data of A
//   vptr B in C
//   data of B
//   data of C
// 可以理解为C 有两个虚函数表,一个是为 vptr A/C准备的,一个是
// 为vptr B准备的,两个虚函数表都具有第一节中提到的layout
}
C* c = new C();
B* b = c;   // 包含隐式指针调整 b = c + offset(b in c)
B* bb = static_cast<B*>(c); // 类似
c->v();  // 如果v是定义在b中,且c没有重载,则存在隐式指针调整
         // 以保证v的第一个参数是指向B类型的对象
         
b->v(); // 如果v是定义b中的虚函数,且在c中被重载,麻烦的情况!

关于最后一种情况,没有办法在编译时直接调整指针b,因为不清楚b是被包含在哪个对象里的。解决办法是引入non-virtual thunk。下面是用clang++编译出的non-virtual thunk的例子

define linkonce_odr dso_local void @non-virtual thunk to C::v()(ptr noundef %0) unnamed_addr #2 comdat align 2 {
  %2 = alloca ptr, align 8
  store ptr %0, ptr %2, align 8
  %3 = load ptr, ptr %2, align 8
  %4 = getelementptr inbounds i8, ptr %3, i64 -16
  tail call void @C::v()(ptr noundef nonnull align 8 dereferenceable(32) %4)
  ret void
}

做的事情很简单,就是在实际调用C::v前先对指针进行调整一个固定常量(这里的偏移量是16,说明offset(B in C)=16),然后再调用实际的C::v。有了non-virtual thunk后再回到原来的例子,如果B在C对象里,那么B调用v函数,查虚函数表就会调用到non-virtual thunk C::v()。进行了需要的偏移后才会调用真正的C::v。而如果B在另一个D对象里,vptr B实际会指向D的某个虚函数表,b->v()会实际调用non-virtual thunk D::v(),对指针进行offset(B in D)的偏移后再调用真正的D::v

3. calling virtual functions inside constructors.

在开始讨论virtual inheritance之前,先考虑这样一个问题:在构造函数中调用虚函数会怎样。C++规定了在构造函数和析构函数中,任何的RTTI信息和虚函数调用都应该表现为正在构造或者析构的类即为最派生对象(most derived class),因此不能陷入到子类中去。stackoverflow上的例子

最简单的能想到的实现方式是,对构造函数和析构函数中调用的函数都在编译期静态绑定就行了。因为已经假定该类是most derived class,那么任何虚函数的调用在编译期就可以知道应该调用到哪个函数。但这并不够,因为还要保证不能返回子类的RTTI信息。

实际的实现是这样,在没有虚继承的情况下,实际上只需要在实际执行该类的构造函数代码前,用该类的vptr覆盖掉原本的vptr即可。看一个实际的例子

struct A{int a; virtual void f();};
struct B: A{int b};
struct C: B{int c};
// layout of C
// vptr 
// int a
// int b
// int c
C* c = new C();
// 构造函数执行顺序 A->B->C,每个类的构造函数都在实际执行用户的代码前
// 先把自己类对应的虚函数表的地址存到 vptr中即可,那么在执行A构造函数的
// 代码时,vptr指向A的虚函数表。。。最终执行C的构造函数,在构造函数结束后
// vptr指向C的虚函数表。

我们接下来讨论虚继承时,会发现为何这种实现在虚继承中会遇到麻烦。

4. virtual inheritance

虚继承希望解决的是基类在子类的被复制多份的问题。在这节将讨论第0节中展示的名词在解决这个问题中各扮演了什么样的作用。

4.1 data layout of object with virtual inheritance

虚继承一言以蔽之,就是允许虚基类和该子类内存距离是任意的。考虑经典的diamond problem

class Base{
	public:
	int base;
		Base(){printf("Base ctor\n");}
virtual void v(){
	printf("i am base\n");
}
};

class A :public virtual Base{
public:
  int a;
	A(){printf("A ctor\n"); a = 4321;}
  virtual void v(){
		printf("i am A\n");
	}
};

class B: public virtual Base {
public:
  int b;
	B(){printf("B ctor\n"); b = 1234;}
  virtual void v(){
		printf("i am B\n");
	}
};

class C : public A, public B {
public:
  int c;
	C(){printf("C ctor\n");c=7890;}
	virtual void v(){
		printf("c=%p\n", this);
	}
};

如果使用普通的继承,导致C中有两份Base的副本,为什么?因为普通的继承要求Base一定在A的开头,也要求Base一定在B的开头。无论B后面怎么被继承,Base,B的数据的相对内存布局都是固定的。这导致了普通继承没办法解决两份副本的问题。

而在虚继承的情况下,某个类自身的数据和它的虚子类的数据的相对位置可以因most derived class不同而不同。我们来查看clang编译出的内存布局

%class.A = type <{ ptr, i32, [4 x i8], %class.Base.base, [4 x i8] }>
%class.Base.base = type <{ ptr, i32 }>
%class.B = type <{ ptr, i32, [4 x i8], %class.Base.base, [4 x i8] }>
%class.C = type { %class.A.base, [4 x i8], %class.B.base, i32, %class.Base.base, [4 x i8] }
%class.A.base = type <{ ptr, i32 }>
%class.B.base = type <{ ptr, i32 }>
// ptr对应了vptr,[4 x i8]是对齐padding

可以看到,class.A中A的数据与Base的数据的间隔,与class.C中A的数据和Base的数据的间隔是不同的。

一般地类S的data layout大致如下:

  • 首先按照继承时的书写顺序,排布direct non-virtual base的数据
  • 然后排布类S的数据
  • 随后按照继承时的书写顺序,深度优先排布virtual base的数据。

注意第二条中是没有direct的,修改一下,将上面的继承关系中的C改为

class C: public virtual A, public virtual B{
...
}

编译后得到的layout为

%class.A = type <{ ptr, i32, [4 x i8], %class.Base.base, [4 x i8] }>
%class.Base.base = type <{ ptr, i32 }>
%class.B = type <{ ptr, i32, [4 x i8], %class.Base.base, [4 x i8] }>
%class.C = type <{ ptr, i32, [4 x i8], %class.A.base, [4 x i8], %class.Base.base, [4 x i8], %class.B.base, [4 x i8] }>
%class.A.base = type <{ ptr, i32 }>
%class.B.base = type <{ ptr, i32 }>

由于C没有direct non-virtual base,所以首先出现的是类C的数据。接下来查看类C的virtual base base, A, B, 顺着继承关系图深度优先写,由于继承时A写在前面,因此排布A的数据,然后深度优先搜索到base,因此接下来排布base的数据,最后排布B的数据。

4.2 vbase offset

如果理解了4.1节,那么可以相信,通过虚继承的方法,每个虚继承的基类只会在派生类中出现一次,而且相应的布局也清楚了。现在的问题是,如何索引到虚基类的数据成员呢?因为在普通继承中,基类在派生类中的偏移是固定的,可以在编译时期确定。但虚继承中就没办法在编译期确定了。

这便是vtable中的第二项,vbase offset的用武之地了。只要一个类存在虚基类(不要求是直接基类), 那么这个类的vtable中就需要包含vbase offset,指示该虚基类偏移该类多远的位置。例如4.1节中的class.A, 它指向的vtable中就需要包含base类的偏移量,而class.A嵌在不同的类中,指向的vtable的包含的偏移量也可能不同。

而对应4.1第二个例子中的class.C来讲,它有三个虚基类,那么class.C指向的vtable中就需要包含三个vbase offset。因此每次访问虚基类的成员时,会额外多两次load(一次load vtable,一次load相应的偏移量)。

4.3 complete/base object constructor

现在知道了一个类如何访问自己的虚基类成员,接下来的问题是如何保证虚基类只被初始化一次。考虑4.1中的第一个例子,class.C会分别调用class.B, class.A的构造函数,它们又会调用class.Base的构造函数,这是否会导致class.Base被构造两次呢?

C++标准要求如果一个类有虚基类,那么应该由该类的构造函数来调用虚基类的构造函数。因此在4.1的第一个例子中,class.Base的构造函数应该由class.C来调用(使用初始化列表的方式),而class.A, class.B则不会再调用class.Base的构造函数。这如何做到呢?如果我们希望实例化一个class.A对象,此时当然要求class.A的构造函数调用class.Base的构造函数了。

解决方法是,如果一个类有虚基类,那么编译器会生成两个构造函数,complete/base object constructor(下面简称complete/base constructor), complete constructor即通常的构造函数,会先调用所有虚基类的base constructor,然后调用所有直接普通基类(direct non-virtual base)的base constructor,然后执行用户的代码。而base constructor相比之下,少了第一步,即不会调用虚基类的构造函数。

具体来说,实例化一个有虚基类的类S时,会调用它的complete construtor,这个construtor首先调用虚基类的base constructor,调用顺序则是4.1节谈到的深度优先的逆序。然后按照正常顺序调用直接普通基类的base constructor,最后执行用户代码。这样保证了所有的虚基类都只被构造一次,并且执行某个类的用户指定的构造函数代码时,它的基类都已经初始化完成了。

destructor的过程则和constructor相反。

4.4 construction vtable and VTT(vtable tables)

之前有说到,第3节中讲述的方法(构造函数中调用虚函数的问题)在虚继承中会出现问题,现在能够解释了。比如类A有虚基类Base,在调用类A的时base constructor,类A并不知道Base到底在哪。那谁才知道Base类在哪呢?只有最终的实例化对象,即被调用complete constructor的类才知道Base在哪。

如果按照第3节的方法,在A的base constructor中直接填入类A的虚函数表地址的话,类A不能正确地找到Base类的位置(最终的实例化对象不同,类A和类Base的相对位置也可能不同)。因此如果一个对象,它的一个子类包含虚基类。那么它需要为这个子类提供constrution vtable,相比于该子类原本的vtable,提供了vbase offsetvcall offset的修正(vcall offset的作用后面会讲到)。

那具体construction vtable(下面简称ctor vtable)要怎么传给base constructor呢,这势必是一个递归的过程,因为有些需要ctor vtable的构造函数并不直接由complete constructor调用。这就是VTT的用武之地了。所有需要给子类提供ctor vtable的类都有一个VTT,VTT是一个指针数组,包含了两类地址:所有需要用到的ctor vtable的地址,还有该类的所有vtable的地址。具体递归构建VTT的方法在Itanium C++ ABI 2.6.2节解释了。类似于一棵展平的树,先填入自己的primary vtable地址(即这个对象开头的vptr指向的地址), 然后在数组中依次填入direct non-virtual base的VTT(递归),然后再填入其余vtable的地址(这里的描述并不准确)。最后填入所有virtual base的VTT(递归)。

然后每个base constructor的接收参数额外增加一个指针,指向VTT这个指针数组的地址。complete constructor在调用相应的base constructor时,传入为这个类构建的VTT地址(即VTT数组地址加一个合适的偏移)。这个base constructor又会把它收到的VTT递归地往下传,传的时候又会添加适当的偏移。最终使得整个构建过程正常进行。

destructor也是一样的,它和constructor可以使用同一个VTT。

4.5 vcall offset and virtual thunk

non-virtual thunk中,对指针进行的偏移量是固定的,而在virtual thunk中,对指针的偏移量依赖于vcall offset。用clang++编译发现,如果需要进行指针偏移时,指针调整前指向的对象和指针调整后指向的对象的相对位置不是固定的(这说明继承路径上的某条边是虚继承),就会使用virtual thunk,比如4.1的第一个例子,考虑下面的调用

Base* base = new A();
base->v();

而在class.A的虚函数表中,存放v函数地址的表项存放的是virtual thunk to A::v(),对应的LLVM IR为

define linkonce_odr dso_local void @virtual thunk to A::v()(ptr noundef %0) unnamed_addr #2 comdat align 2 {
  %2 = alloca ptr, align 8
  store ptr %0, ptr %2, align 8
  %3 = load ptr, ptr %2, align 8
  %4 = load ptr, ptr %3, align 8  // load vtable
  %5 = getelementptr inbounds i8, ptr %4, i64 -24
  %6 = load i64, ptr %5, align 8  // load vcall offset
  %7 = getelementptr inbounds i8, ptr %3, i64 %6  // pointer adjustment
  tail call void @A::v()(ptr noundef nonnull align 8 dereferenceable(12) %7)
  ret void
}

可以看到,相比于non-virtual thunk,多了一次load vcall offset的操作,不过也有一个定值-24内嵌在函数体中,这个定值用来确定vcall offset的地址与vtable的地址的相对偏移。实际调整指针的值由load上来的vcall offset决定。

那么virtual thunk是否必要呢,考虑下面的继承关系:其中B虚继承A,且B重载了A的虚函数v,C, D未重载。

                  v
D <--- C <--- B <--- A

void f(A* a){
	a->v();  // 如果a指向的是B或者C或者D,需要把指针调整到指向B对象
}

上面的a指向B,C,D时,需要调整的偏移量是不相同的,如果我们不使用virtual thunk,由于non-virtual thunk中的偏移量是固定的,那么需要产生三个non-virtual thunk,而每新增一个类继承,就又需要添加新的non-virtual thunk函数(根本原因在于虚继承,如果是普通的继承,需要调整的偏移量是一样的,因此一个non-virtual thunk就足够了)。

另一种选择是使用virtual thunk,为A::v选定一个固定的offset(即存储 vcall offset的地址和vptr指向的地址的偏移,例如上面IR中的-24)。在vptr - offset的位置存储实际的偏移。这样只需要一个virtual thunk, 然后在vtable A in B(即在class.B中 A的vptr指向的 vtable), vtable A in C , vtable A in D的-offset处设置相应的恰当偏移值就好了。这样只需要一个virtual thunk,这时候如果增加新的类E,继承自D,而且没有重载v函数,那么在vtable A in E的-offset处设置新的偏移值,并不需要增加virtual thunk

stackoverflow上关于这个的讨论: why are virtual thunks necessary

另外值得注意的是,vtable中的一项vcall offset对应一个虚函数,如果有多个虚函数,那么在vtable中就需要多个vcall offset(因为调用同一个类的不同的虚函数,最终执行的是不同的类的虚函数,需要的vcall offset也不相同)。

OK,到此为止,虚继承相关的东西就差不多讨论完了。包括如何索引到虚基类,constructor如何运作,如何利用thunk进行指针偏移调整等等。总的来说,虚继承的上层接口是这样:

  • 对不希望被重复继承的对象Base,我们在任意一个对象S继承Base时,使用virtual关键字
  • 在书写任意对象S的构造函数时,如果S有虚基类Base(不一定是直接的虚基类),我们需要用初始化列表显式指定Base的初始化方式(否则调用Base的默认构造函数)。析构函数不需要这一点,因为析构函数没有参数。

在用户保证了这两点后,虚继承在使用上与普通继承没有区别,且保证被虚继承的对象在派生类中只有一份副本。

嗯,虽然接口很简洁,但实际的底层机制还是蛮复杂的。

5. misc and reference

在第1节中谈到vtable中还有一个offset to top,stackoverflow上也有相关提问,Why is there a top_offset in VTT implemented by gcc?
正如回答中所谈到的那样,最直接的作用是dynamic_cast<void*>转换,这个转换要求把指针转换到most derived class,使用offset to top便能一下实现,不太清楚是否该项还有其它作用。

一些参考:

4.1中例子的完整版:(可以使用clang -S -emit-llvm -o /dev/stdout a.cpp | c++filt > a.ll来重现文中的IR)

#include <cstdio>
#include <typeinfo>

using namespace std;

class Base{
	public:
	int base;
		Base(){printf("Base ctor\n");}
virtual void v(){
	printf("i am base\n");
}
};

class A :public virtual Base{
public:
  int a;
	A(){printf("A ctor\n"); a = 4321;}
  virtual void v(){
		printf("i am A\n");
	}
};

class B: public virtual Base {
public:
  int b;
	B(){printf("B ctor\n"); b = 1234;}
  virtual void v(){
		printf("i am B\n");
	}
};

class C : public A, public B {
public:
  int c;
	C(){printf("C ctor\n");c=7890;}
	virtual void v(){
		printf("c=%p\n", this);
	}
};

C* ccc(){
	new A();
	new B();
	return new C();
}


int main()
{
	C* b = ccc();
	printf("c=%p\n", b);
	b->v();
	return 0;
}

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值