C++多继承与虚拟继承的内存布局
(译自http://www.phpcompiler.org/articles/virtualinheritance.html(By Edsko de Vries, January 2006))
在本文中,我们来解释GCC编译器对多继承和虚拟继承实现的对象模型。尽管在现实世界中,C++程序员并非一定要知道这些编译器内部的细节,但不幸的是,在C++的实现中,多继承(特别是虚拟继承)的实现方式,会导致我们写的C++代码产生许多难以理解的结果(特别是当遇到 downcasting pointers,使用 pointers to pointer,以及虚基类的构造器的调用顺序这些问题时)。如果你能够理解多继承是如何实现的,你将能够明白你的代码为什么会有这样的结果,并能够处理它们。同时,理解多继承可以帮助你了解使用虚拟继承所带来的性能损耗,从而帮组你改善你的代码性能。最后,其实去了解一下对象模型是一件非常有趣的事:-)
多继承
首先,我们先来考虑一个相对简单的多继承。我们来思考以下C++类的继承关系:
class
Top
{public :
int a ;
};
class Left : public Top {
public :
int b ;
};
class Right : public Top {
public :
int c ;
};
class Bottom : public Left , public Right {
public :
int d ;
};
使用一个UML图,我们可以把这种继承关系表示为:
我们可以注意到,这里 Top 类被继承了2次(在这Eiffel语言中被称作“重复继承”)。这意味这一个Bottom类型的对象bottom中会包含两个名字叫”a”的属性(bottom.Left::a 和 bottom.Right::a)。
那么,Left、Right以及Bottom在内存中是如何布局的呢?我们先来看一种最简单的情况。Left和Right的结构如下:
注意,第一个属性是继承自Top类,这意味着,当我们进行如下赋值后,left和top指针完全指向相同的地址,我们完全可以把 Left 对象当作一个 Top 对象来用(对Right类也是如此)。
Left * left = new Left ();Top * top = left ;
那么Bottom呢?如果我们upcast一个Bottom指针会发生怎样的状况?见下面代码:
Bottom * bottom = new Bottom ();
Left * left = bottom ;
这面这段代码工作正常!我们可以将一个Bottom对象当作Left对象来使用,因为在内存布局中,left 与 bottom 指针是一致的。那么问题来了,如果我们将Bottom指针upcast到Right类型呢?见代码:
Right * right = bottom ;
要想让这种情形正常工作,我们将必须调整指针的值,从而让指针指向Layout中正确的位置:
将指针调整后,我们才可以通过 right 指针将一个Bottom对象当作Right对象来使用;然而,我们可以看到,此时bottom与right已经指向了不同的内存地址。(译者注:这可能会让那些习惯C语言的程序员很疑惑:“right = bottom(指针赋值),那么难道right!=bottom(指针比较)么?”——这一问题我们会在最后的总结中解答。)
为了完整性,我们再来考虑下面的代码所产生的情况:
Top * top = bottom ;
如果你尝试去编译这句代码,是的,编译不过!因为这句话是有歧义的,编译器(GCC)会报以下错误:
Error: ‘Top’ is an ambiguous base of ‘Bottom’
编译器告诉我们,Top作为Bottom的基类是有二义性的(也就是说1个Bottom对象中存在2个Top子对象,见前面的UML图)。要消除这种二义性,可以使用以下代码:
Top * topL = ( Left *) bottom ;
Top * topR = ( Right *) bottom ;
经过这两句赋值后,topL 和 left 会指向相同的地址,topR 和 right 同理也指向相同的地址。
虚拟继承
在上面的例子中,如果要避免Top类被重复继承,就必须使用“虚拟继承”:
class Top {
public : int a ;
};
class Left : virtual public Top {
public : int b ;
};
class Right : virtual public Top {
public : int c ;
};
class Bottom : public Left , public Right {
public : int d ;
};
在虚拟继承下,编译器为我们生成下面的继承结构:
虽然从一个程序员的角度来看,这样的继承结构比前面的非虚拟继承更简单、更显而易见,但是从编译器的角度上,要实现虚拟继承机制,其复杂程度是非常之大的。我们再来思考此时的Bottom,一种可能的布局(后面我们会知道事实并非如此)如下:
这种布局的好处是它的开头是与 Left 类型一致的,从而我们可以通过一个Left指针来访问一个Bottom。但是当遇到下面的语句怎么办?right指针该指向何处?我们理应能够使用right指针,并把它当作指向一个正常的Right对象。但在上面所述的布局下是不可能的!在上面的布局中Right已经完全被改变了,因此我们无法再像普通的多继承中那样,将一个Bottom对象upcast到Right类型来使用。
Right * right = bottom ;
这一问题的解决方案比较复杂,我们先给出解决方案再来慢慢解释:
在上面这张图中,你应该会注意到2件事:(1)首先Bottom类中基类字段的顺序完全不同了(实际上它似乎被“倒”过来了)。(2)第二,新加入了一些vptr指针。这些字段是编译器在必要的时候(比如使用了虚拟继承或者虚拟函数)自动插入的。编译器也会在构造函数中插入一些代码来初始化这些指针。
vptr(虚表指针)用来指向一张“virtual table”(虚表)。每一个虚基类都会对应一个vptr。要了解虚表vtable是如何使用的,我们先来看下面的C++代码:
Bottom * bottom = new Bottom ();Left * left = bottom ;
int p = left -> a ;
第二句赋值语句让left指针指向bottom的相同地址(也就是指向Bottom对象的起始地址)。我们来考虑最后一句赋值语句的编译结果(略有简化):
movl left, %eax # %eax = left
movl(%eax), %eax # %eax = left.vptr.Left
movl(%eax), %eax # %eax = virtual base offset
addl left, %eax # %eax = left + virtual base offset
movl(%eax), %eax # %eax = left.a
movl %eax, p # p = left.a
简单来说,我们使用left指针先找到虚表,然后从虚表中获得虚基类的offset偏移量(vbase)。用left指针加上虚基类的偏移量,就得到了Bottom对象中Top子对象的地址。从上面的图表中,你可以看到Left虚基类的偏移量是20;如果我们假设Bottom中所有字段都是4字节长度(32位对齐),你会发现left加上 20 会正好指向 Top::a 字段!
以此类推,我们可以用相同的方式访问Right子对象:
Bottom * bottom = new Bottom ();
Right * right = bottom ;
int p = right -> a ;
Right指针会指向Bottom对象中正确的位置:
对right指针的赋值语句的编译与上面对Left的处理完全一样,唯一的不同是我们此时访问的vptr指向了虚表的另一部分:此时我们获得的虚基类offset是12。现在我们可以用下图来总结:
当然,这样实现的目的是为了我们可以将Bottom upcast到Left或Right来使用。类似的,在Left类与Right类中,我们也会导入vptr指针:
现在我们就可以通过 Right 指针来访问Bottom对象了!然而,这样的做法会带来一定的成本:(1)我们需要引入虚表,(2)类中会插入1个或多个虚表指针vptr,(3)原本简单的属性查找现在需要通过虚表进行2次间接的寻址(尽管编译器会通过优化减少一定的性能消耗)。
Downcasting
如同我们上面所看到的,将一个派生类的指针转换为基类的指针(也就是upcasting)会导致派生类指针加上一个offset。有人可能会想,反过来(也就是downcasting)的时候,只需要将相同的offset减去就可以了!事实上,对于非虚拟继承的情况是这样的。但对于虚拟继承(不要惊讶!)会引入另一种编译方式。
现在我们来假设我们引入一个新的类型:
class AnotherBottom : public Left , public Right {public :
int e ;
int f ;
};
它的继承关系如下:
我们来考虑这样一段代码:
Bottom * bottom1 = new Bottom ();AnotherBottom * bottom2 = new AnotherBottom ();
Top * top1 = bottom1 ;
Top * top2 = bottom2 ;
Left * left = static_cast < Left *>( top1 );
下面的表格展示了Bottom和AnotherBottom的布局,以及在赋值语句后top指针的指向:
现在来考虑,假如在我们不知道top1究竟指向一个Bottom对象还是指向AnotherBottom对象的时候,如何实现从top1到top2的static cast(静态类型转换)?答案是无法实现!虚基类的偏移量是基于top1运行时的类型来决定的(如果top1指向Bottom对象,则偏移量为20;如果top1指向AnotherBottom,则偏移量为24)。此时编译器会报错:
Error: cannot convert from base ‘Top’ to derived type ‘Left’ via virtual base ‘Top’
译者注:TOP类型同时被Bottom和AnotherBottom多态继承,你要问一个TOP类型的指针究竟可以被 down cast 成 Bottom,还是被 down cast 成 AnotherBottom,这需要知道该指针在运行时指向的对象类型。而在编译期间,编译器是无法获知TOP指针的运行时类型信息的。注意,这里我们必须强调是“多态”的情况下,对于非多态的情况,编译器并不会阻止指针类型进行强制转换。
既然我们需要运行时的信息,那么这里我们需要使用动态类型转换dynamic cast:
Left * left = dynamic_cast < Left *>( top1 );
然而即便用动态转换,编译器依然会报错:
Error: cannot dynamic_cast ‘top’ (of type ‘class Top*) to type ‘class Left*’ (source type is not polymorphic)
这里的问题在于dynamic cast动态转换(与使用typeid同样)需要运行时top1指针所指向的对象的类型信息。然而,如果你查看上面的图表,你会看到我们从top1指针获得的内容只有一个整型a!编译器并没有生成一个vptr.Top字段,因为它认为这是没有必要的。要强制编译器生成一个vptr.Top,我们可以为Top添加一个虚析构函数:
class Top {public :
virtual ~Top () {}
int a ;
};
这一改变后,编译器将不得不为Top类型生成一个vptr。从而Bottom新的布局如下:
此时编译器会为前面我们的动态类型转换插入一句库函数调用:
left = __dynamic_cast ( top1 , typeinfo_for_Top , typeinfo_for_Left , - 1 );
__dynamic_cast 函数在 libstdc++ 库中定义(对应的头文件是cxxabi.h);有了Top、Left、Right类型的类型信息后,上面的类型转换就可以执行了。(参数-1表示当前Left与Top之间的关系未知)。详细的信息,可以去参考tinfo.cc的代码实现。
译者注:在VC++编译下,dynamic_cast对应的库函数是 ___RTDynamicCast,对应的头文件是 rtti.h,代码实现位于 rtti.cpp, 详情可参阅MSDN。
总结
最后,我们再来总结几个小的知识点。
二级指针的问题
这里会有一点迷惑,但你一旦理解后会觉得理所当然。我们来思考一个例子,还是接着我们前面Downcasting一节中的继承关系,我们已经知道了下面代码的作用:
Bottom * b = new Bottom ();Right * r = b ;
(在将b赋值给r之前,b 的值被加上8个字节的偏移,从而指向Bottom对象中的Right自对象)。因为我们可以合法的将一个Bottom*指针赋值给一个Right*类型的指针。那么对于Bottom**和Right**呢??
Bottom ** bb = & b ;Right ** rr = bb ;
编译器应该接收这样的操作么?我们快速的来试一下就会发现编译器报错了:
Error: invalid conversion from ‘Bottom**’ to ‘Right**’
Why?为什么?好的,我们先来假设编译器可以接受从bb到rr的赋值。我们将结果用下图来表示:
那么,bb和rr都指向b,b和r又分别指向Bottom对象中相应的位置。现在我们来考虑,当我们给*rr赋值时会发生什么?(注意,*rr的类型是Right*,所以下面的赋值语句是合法的):
* rr = b ;
这在本质上和上面给r赋值的操作是等价的。因此,编译器会用相同的方式来实现这一赋值语句,它会将b的值加上8字节的偏移,然后再赋给*rr。但这里我们的*rr指向的是b!如果此时我们再用图来表示一下:
好吧,只要我们依然通过*rr来访问Bottom,那么是没有问题的。但是,一旦我们回过头来用b去访问Bottom,所有的内存引用操作都偏移了8个字节!——很显然,这是我们不希望的结果。
所以,我们总结一下,即便一级指针*a和*b之间具有子类型关系,但它们的二级指针**a和**b之间是没有这层关系的!
虚基类的构造函数
编译器必须确保一个对象中所有的虚指针都必须正确的初始化。说白了,就是必须要保证一个class的所有虚基类的构造函数都被调用到,并且只能调用一次。如果你没有显式的调用你的虚基类的构造函数(不管这个虚基类在你的类型上面多少层。。),编译器会在生成的默认构造函数中自动插入调用指令,来调用所有虚基类的构造函数。
这种行为会导致一些意想不到的结果。我们再来考虑以下我们前面的继承结构,这次我们添加上了构造函数:
class Top {public :
Top () { a = - 1 ; }
Top ( int _a ) { a = _a ; }
int a ;
};
class Left : public Top {
public :
Left () { b = - 2 ; }
Left ( int _a , int _b ) : Top ( _a ) { b = _b ; }
int b ;
};
class Right : public Top {
public :
Right () { c = - 3 ; }
Right ( int _a , int _c ) : Top ( _a ) { c = _c ; }
int c ;
};
class Bottom : public Left , public Right {
public :
Bottom () { d = - 4 ; }
Bottom ( int _a , int _b , int _c , int _d ) : Left ( _a , _b ), Right ( _a , _c )
{
d = _d ;
}
int d ;
};
(我们先来考虑非虚继承的情况。)下面的代码你期望输出什么?
Bottom bottom ( 1 , 2 , 3 , 4 );printf ( "%d %d %d %d %d\n" , bottom . Left :: a , bottom . Right :: a ,
bottom . b , bottom . c , bottom . d );
你可能会希望得到下面的结果:
1 1 2 3 4
没错。然而,现在我们来考虑虚拟继承的情形(虚拟继承Top)。如果我们将上面代码改为虚拟继承后,我们得到的结果是:
-1 -1 2 3 4
Why?发生了什么?如果你去跟踪构造函数的执行,你会发现它的顺序是这样的:
Top :: Top ();Left :: Left ( 1 , 2 );
Right :: Right ( 1 , 3 );
Bottom :: Bottom ( 1 , 2 , 3 , 4 );
如同我们前面所解释的,编译器在Bottom的默认构造函数中插入了一个调用,这个调用发生在其他构造函数执行之前。然后当Left想要调用它的基类构造函数Top::Top(int _a)时,我们会发现Top实际上已经被初始化过了,那么它的构造函数就不会再被调用!
要避免这样的情形,你应该显式的调用你的虚基类的构造函数:
Bottom ( int _a , int _b , int _c , int _d ) : Top ( _a ), Left ( _a , _b ), Right ( _a , _c ){d = _d ;
}
指针相等?
我们再再再一次考虑前面的虚拟继承的案例,你会期望下面这段代码打印出“Equal”么?
Bottom * b = new Bottom ();Right * r = b ;
if ( r == b )
printf ( "Equal!\n" );
哦,你心里已经在想这两个指针指向的地址是不相等的(r增加了8字节的偏移)。然而,这种偏移对于使用者来说完全是透明的;所以编译器实际上在比较r和b之前,会将8字节的偏移减去;因此,这两个指针被认为是相等的。
(译者注:当将指针r和b强制转换成整型再进行比较时,它们是不相等的。在C语言中,我们常常会将指针转成整型进行比较,在C++中这是行不通的。)
类型转换为void*
最后,我们再来考虑一个问题:如果我们把一个对象指针转换成void*指针,会发生什么?编译器必须保证当指针被转换成void*指针后,它必须指向对象的“top”(顶端)。利用虚表,这一点很容易被实现。你可能在想,顶端top的偏移量offset是多少?这个偏移量就是从vptr到对象顶端的偏移量。所以,将对象指针转换为void*指针可以通过在vtable中进行单次查找来实现。但要确保使用动态转换dynamic cast:
dynamic_cast < void *>( b );