C++多继承与虚拟继承的内存布局

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 );

 




  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值