zoukankan      html  css  js  c++  java
  • 多重继承和虚继承的内存布局

    这篇文章主要讲解虚继承的C++对象内存分布问题,从中也引出了dynamic_cast和static_cast本质区别、虚函数表的格式等一些大部分C++程序员都似是而非的概念。原文见这里 (By Edsko de Vries, January 2006)

          敬告 本文是介绍 C++ 的技术文章,假定读者对于 C++ 有比较深入的认识,同时也需要一些汇编知识。

        本文我们将阐释GCC 编译器针对多重继承和虚拟继承下的对象内存布局。尽管在理想的使用环境中,一个 C++ 程序员并不需要了解这些编译器内部实现细节,实际上,编译器针对多重继承 ( 特别是虚拟继承 ) 的各种实现细节对于我们编写 C++ 代码都或多或少产生一些影响 ( 比如 downcasting pointer pointers to pointers  以及虚基类构造函数的调用顺序 ) 。如果你能明白多重继承是如何实现的,那么你自己就能够预见到这些影响,进而能够在你的代码中很好地应对它们。再者,如果你十分在意的代码的运行效率,正确地理解虚继承也是很有帮助的。最后嘛,这个 hack 的过程是很有趣的哦 :)

       

    多重继承

       首先我们先来考虑一个很简单(non-virtual) 的多重继承。看看下面这个 C++ 类层次结构。

     1 class  Top
     2 {
     3 public :
     4    int  a;
     5 };
     6
     7 class  Left : public  Top
     8 {
     9 public :
    10    int  b;
    11 };
    12
    13 class  Right : public  Top
    14 {
    15 public :
    16    int  c;
    17 };
    18
    19 class  Bottom : public  Left, public  Right
    20 {
    21 public :
    22    int  d;
    23 };
    24

        用UML 表述如下:

        注意到Top 类实际上被继承了两次, ( 这种机制在 Eif fel中被称作 repeated inheritance ) ,这就意味着在一个bottom 对象中实际上有两个 a 属性( attributes ,可以通过bottom.Left::a 和  bottom.Right::a 访问 )  。

        那么Left Right Bottom 在内存中如何分布的呢?我们先来看看简单的 Left Right 内存分布:

           [Right 类的布局和Left是一样的,因此我这里就没再画图了。刺猬]

           注意到上面类各自的第一个属性都是继承自Top 类,这就意味着下面两个赋值语句:

    1 Left* left = new  Left();
    2 Top* top = left;

           left top 实际上是指向两个相同的地址,我们可以把 Left 对象当作一个 Top 对象 ( 同样也可以把 Right 对象当 Top 对象来使用 ) 。但是 Botom 对象呢 ?GCC 是这样处理的:

         但是现在如果我们upcast  一个 Bottom 指针将会有什么结果  

    1 Bottom* bottom = new  Bottom();
    2 Left* left = bottom;
     

           这段代码运行正确。这是因为GCC 选择的这种内存布局使得我们可以把 Bottom 对象当作 Left 对象,它们两者 (Left 部分 ) 正好相同。但是,如果我们把 Bottom 对象指针 upcast Right 对象呢 ?

    1 Right* right = bottom;

          如果我们要使这段代码正常工作的话,我们需要调整指针指向Bottom 中相应的部分。

         通过调整,我们可以用right 指针访问 Bottom 对象,这时 Bottom 对象表现得就如 Right 对象。但是 bottom right 指针指向了不同的内存地址。最后,我们考虑下 :

    1 Top* top = bottom;

         恩,什么结果也没有,这条语句实际上是有歧义(ambiguous) 的,编译器会报错: error: `Top' is an ambiguous base of `Bottom'。其实这两种带有歧义的可能性可以用如下语句加以区分:

    1 Top* topL = (Left*) bottom;
    2 Top* topR = (Right*) bottom;
     

      这两个赋值语句执行之后,topL left 指针将指向同一个地址,同样 topR right 也将指向同一个地址

    虚拟继承

       为了避免上述Top 类的多次继承,我们必须虚拟继承类 Top

     1 class Top
     2 {
     3     public :
     4         int  a;
     5 };
     6
     7 class Left : virtual public Top
     8 {
     9     public :
    10         int  b;
    11 };
    12
    13 class Right : virtual public Top
    14 {
    15     public :
    16         int  c;
    17 };
    18
    19 class Bottom : public Left, public Right
    20 {
    21     public :
    22         int  d;
    23 };
    24 

       上述代码将产生如下的类层次图( 其实这可能正好是你最开始想要的继承方式 )

    virtualinheritance

         对于程序员来说,这种类层次图显得更加简单和清晰,不过对于一个编译器来说,这就复杂得多了。我们再用Bottom 的内存布局作为例子考虑,它可能是这样的 :

          

          这种内存布局的优势在于它的开头部分(Left 部分 ) Left 的布局正好相同,我们可以很轻易地通过一个 Left 指针访问一个 Bottom 对象。不过,我们再来考虑考虑 Right:

    1 Right* right = bottom;

      这里我们应该把什么地址赋值给right 指针呢?理论上说,通过这个赋值语句,我们可以把这个 right 指针当作真正指向一个 Right 对象的指针 ( 现在指向的是 Bottom) 来使用。但实际上这是不现实的!一个真正的 Right 对象内存布局和 Bottom 对象 Right 部分是完全不同的,所以其实我们不可能再把这个 upcasted bottom 对象当作一个真正的 right 对象来使用了。而且,我们这种布局的设计不可能还有改进的余地了。这里我们先看看实际上内存是怎么分布的,然后再解释下为什么这么设计。

    vtable

          上图有两点值得大家注意。第一点就是类中成员分布顺序是完全不一样的( 实际上可以说是正好相反 ) 。第二点,类中增加了 vptr 指针,这些是被编译器在编译过程中插入到类中的 ( 在设计类时如果使用了虚继承,虚函数都会产生相关 vptr) 。同时,在类的构造函数中会对相关指针做初始化,这些也是编译器完成的工作。Vptr指针指向了一个“ virtual table ”。在类中每个虚基类都会存在与之对应的一个 vptr 指针。为了给大家展示 virtual table 作用,考虑下如下代码。

    1 Bottom* bottom = new Bottom();
    2 Left* left = bottom;
    3 int  p = left->a;

        第二条 的赋值语句让left 指针指向和 bottom 同样的起始地址 ( 即它指向 Bottom 对象的“顶部” ) 。我们来考虑下第三条的赋值语句。

    1 movl   left , %eax         # %eax  = left
    2 movl   (%eax ), %eax       # %eax  = left .vptr.Left
    3 movl   (%eax ), %eax       # %eax  = virtual  base  offset  
    4 addl   left , %eax         # %eax  = left  + virtual  base  offset
    5 movl   (%eax ), %eax       # %eax  = left .a
    6 movl   %eax , p            # p  = left .a

           总结下,我们用left 指针去索引 ( 找到 )virtual table ,然后在 virtual table 中获取 虚基类的偏移( virtual base offset , vbase),然后在 left 指针上加上这个偏移量,这样我们就获取到了 Bottom 类中 Top 类的开始地址。 从上图中,我们可以看到对于 Left 指针,它的 virtual base offset 20 ,如果我们假设 Bottom 中每个成员都是 4 字节大小,那么 Left 指针加上 20 字节正好是成员 a 的地址。

        我们同样可以用相同的方式访问Bottom Right 部分。

    1 Bottom* bottom = new Bottom();
    2 Right* right = bottom;
    3 int  p = right->a;

       right指针就会指向在 Bottom 对象中相应的位置。

     

          这里对于p 的赋值语句最终会被编译成和上述 left 相同的方式访问 a 。唯一的不同是就是 vptr ,我们访问的 vptr 现在指向了 virtual table 另一个地址,我们得到的 virtual base offset 也变为 12 。我们画图总结下:

    virtualinheritance

       当然,关键点在于我们希望能够让访问一个真正单独的Right 对象也如同访问一个经过 upcasted (到 Right 对象)的 Bottom 对象一样。这里我们也在 Right 对象中引入 vptrs

    vtable2

        OK,现在这样的设计终于让我们可以通过一个 Right 指针访问 Bottom 对象了。不过,需要提醒的是以上设计需要承担一个相当大的代价:我们需要引入虚函数表,对象底层也必须扩展以支持一个或多个虚函数指针,原来一个简单的成员访问现在需要通过虚函数表两次间接寻址 ( 编译器优化可以在一定程度上减轻性能损失 )

     

    Downcasting

       如我们猜想,将一个指针从一个派生类到一个基类的转换(casting) 会涉及到在指针上添加偏移量。可能有朋友猜想, downcasting 一个指针仅仅减去一些偏移量就行了吧。实际上,非虚继承情况下确实是这样,但是,对于虚继承来说,又不得不引入其它的复杂问题。这里我们在上面的例子中添加一些继承关系:

    1 class AnotherBottom : public Left, public Right
    2 {
    3     public :
    4         int  e;
    5         int  f;
    6 };

       这个继承关系如下图所示:

    virtual2

       那么现在考虑如下代码

    1 Bottom* bottom1 = new  Bottom();
    2 AnotherBottom* bottom2 = new  AnotherBottom();
    3 Top* top1 = bottom1;
    4 Top* top2 = bottom2;
    5 Left* left = static_cast <Left*>(top1);

       下面这图展示了Bottom AnotherBottom 的内存布局,同时也展示了各自 top 指针所指向的位置。

          现在我们来考虑考虑从top1 left static_cast ,注意这里我们并不清楚对于 top1 指针指向的对象是 Bottom 还是 AnotherBottom 。这里是根本不能编译通过的!因为根本不能确认 top1 运行时需要调整的偏移量 ( 对于 Bottom 20 ,对于 AnotherBottom 24) 。所以编译器将会提出错误: error: cannot convert from base `Top' to derived type `Left' via virtual base `Top'。这里我们需要知道运行时信息,所以我们需要使用dynamic_cast:

    1 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 指针所指的仅仅是一个整数成员 a 。编译器没有在 Bottom 类中包含针对 top vptr ,它认为这完全没有必要。为了强制编译器在 Bottom 中包含 top vptr ,我们可以在 top 类里面添加一个虚析构函数。

    1 class  Top
    2 {
    3     public :
    4         virtual  ~Top() {}
    5         int  a;
    6 };

        这就迫使编译器为Top 类添加了一个 vptr 。下面来看看 Bottom 新的内存布局:

       是的,其它派生类(Left Right) 都会添加一个 vptr.top ,编译器为 dynamic_cast 生成了一个库函数调用。

    1 left = __dynamic_cast(top1, typeinfo_for_Top, typeinfo_for_Left, -1 );

       __dynamic_cast定义在 libstdc++( 对应的头文件是 cxxabi.h) ,有了 Top Left Bottom 的类型信息,转换得以执行。其中,参数 -1 代表的是类 Left 和类 Top 之间的关系未明。如果想详细了解,请参看 tinfo.cc 的实现。

     

    总结

        最后,我们再聊聊一些相关内容。

       

    二级指针

       这里的问题初看摸不着头脑,但是细细想来有些问题还是显而易见的。这里我们考虑一个问题,还是以上节的Downcasting 中的类继承结构图作为例子。

    1 Bottom* b = new  Bottom();
    2 Right* r = b;

      (在把 b 指针的值赋值给指针 r 时, b 指针将加上 8 字节,这样 r 指针才指向 Bottom 对象中 Right 部分 ) 。因此我们可以把 Bottom* 类型的值赋值给 Right* 对象。但是 Bottom** Right** 两种类型的指针之间赋值呢?

    1 Bottom** bb = &b;
    2 Right** rr = bb;

       编译器能通过这两条语句吗?实际上编译器会报错: error: invalid conversion from `Bottom**' to `Right**'
      为什么 不妨反过来想想,如果能够将 bb 赋值给 rr ,如下图所示。所以这里 bb rr 两个指针都指向了 b b r 都指向了 Bottom 对象的相应部分。那么现在考虑考虑如果给 *rr 赋值将会发生什么。

    1 *rr = b;  

      注意 *rr Right* 类型 ( 一级 ) 的指针,所以这个赋值是有效的!

    doublepointers

        这个就和我们上面给r 指针赋值一样 (*rr 是一级的 Right* 类型指针,而 r 同样是一级 Right* 指针 ) 。所以,编译器将采用相同的方式实现对 *rr 的赋值操作。实际上,我们又要调整 b 的值,加上 8 字节,然后赋值给 *rr ,但是现在 **rr 其实是指向 b ! 如下图

        呃,如果我们通过rr 访问 Bottom 对象,那么按照上图结构我们能够完成对 Bottom 对象的访问,但是如果是用 b 来访问 Bottom 对象呢,所有的对象引用实际上都偏移了 8 字节——明显是错误的!

       总而言之,尽管*a *b 之间能依靠类继承关系相互转化,而 **a **b 不能有这种推论。

    虚基类的构造函数

       编译器必须要保证所有的虚函数指针要被正确的初始化。特别是要保证类中所有虚基类的构造函数都要被调用,而且还只能调用一次。 如果你写代码时自己不显示调用构造函数,编译器会自动插入一段构造函数调用代码。这将会导致一些奇怪的结果,同样考虑下上面的类继承结构图,不过要加入构造函数。

     1 class  Top
     2 {
     3 public :
     4    Top() { a = -1 ; }
     5    Top(int  _a) { a = _a; }
     6    int  a;
     7 };
     8
     9 class  Left : public  Top
    10 {
    11 public :
    12    Left() { b = -2 ; }
    13    Left(int  _a, int  _b) : Top(_a) { b = _b; }
    14    int  b;
    15 };
    16
    17 class  Right : public  Top
    18 {
    19 public :
    20    Right() { c = -3 ; }
    21    Right(int  _a, int  _c) : Top(_a) { c = _c; }
    22    int  c;
    23 };
    24
    25 class  Bottom : public  Left, public  Right
    26 {
    27 public :
    28    Bottom() { d = -4 ; }
    29    Bottom(int  _a, int  _b, int  _c, int  _d) : Left(_a, _b), Right(_a, _c)
    30     {
    31       d = _d;
    32     }
    33    int  d;
    34 };
    35 

       先来考虑下不包含虚函数的情况,下面这段代码输出什么?

    1 Bottom bottom(1 ,2 ,3 ,4 );
    2 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
       如本节开头所讲,编译器在Bottom 中插入了一个 Top 的默认构造函数,而且这个默认构造函数安排在其他的构造函数之前,当 Left 开始调用它的基类构造函数时,我们发现 Top 已经构造初始化好了,所以相应的构造函数不会被调用。如果跟踪构造函数,我们将会看到

    Top::Top()
    Left::Left(1,2)
    Right::Right(1,3)
    Bottom::Bottom(1,2,3,4)
       为了避免这种情况,我们应该显示地调用虚基类的构造函数

    1 Bottom(int  _a, int  _b, int  _c, int  _d): Top(_a), Left(_a,_b), Right(_a,_c)
    2 {
    3    d = _d;
    4 }

     

    void*  的转换

     1 dynamic_cast <void *>(b);

        最后我们来考虑下把一个指针转换到void * 。编译器会把指针调整到对象的开始地址。通过查 vtable ,这个应该是很容易实现。看看上面的 vtable 结构图,其中 offset to top 就是 vptr 到对象开始地址。另外因为要查阅 vtable ,所以需要使用 dynamic_cast

    指针的比较

       再以上面Bottom类继承关系为例讨论,下面这段代码会打印Equal吗?

    1 Bottom* b = new  Bottom();
    2 Right* r = b;
    3       
    4 if (r == b)
    5    printf("Equal! /n " );

       先明确下这两个指针实际上是指向不同地址的,r指针实际上在b指针所指地址上偏移8字节 ,但是,这些C++内部细节不能告诉C++程序员,所以C++编译器在比较r和b时,会把r减去8字节,然后再来比较,所以打印出的值是"Equal".

     

    参考文献   

    [1] CodeSourcery , in particular the C++ ABI Summary , the Itanium C++ ABI (despite the name, this document is referenced in a platform-independent context; in particular, the structure of the vtables is detailed here). The libstdc++ implementation of dynamic casts, as well RTTI and name unmangling/demangling, is defined in tinfo.cc .

    [2] The libstdc++ website, in particular the section on the C++ Standard Library API .

    [3] C++: Under the Hood by Jan Gray.

    [4] Chapter 9, “Multiple Inheritance” of Thinking in C++ (volume 2) by Bruce Eckel . The author has made this book available for download .

  • 相关阅读:
    Benelux Algorithm Programming Contest 2016 Preliminary K. Translators’ Dinner(思路)
    Benelux Algorithm Programming Contest 2016 Preliminary Target Practice
    Benelux Algorithm Programming Contest 2016 Preliminary I. Rock Band
    Benelux Algorithm Programming Contest 2016 Preliminary A. Block Game
    ICPC Northeastern European Regional Contest 2019 Apprentice Learning Trajectory
    ICPC Northeastern European Regional Contest 2019 Key Storage
    2018 ACM ICPC Asia Regional
    2018 ACM ICPC Asia Regional
    Mybatis入库出现异常后,如何捕捉异常
    优雅停止 SpringBoot 服务,拒绝 kill -9 暴力停止
  • 原文地址:https://www.cnblogs.com/kex1n/p/2286468.html
Copyright © 2011-2022 走看看