多重继承和虚继承的内存布局

这篇文章主要讲解虚继承的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


.

原文链接: https://www.cnblogs.com/kex1n/archive/2010/10/21/2286468.html

欢迎关注

微信关注下方公众号,第一时间获取干货硬货;公众号内回复【pdf】免费获取数百本计算机经典书籍

    多重继承和虚继承的内存布局

原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/16365

非原创文章文中已经注明原地址,如有侵权,联系删除

关注公众号【高性能架构探索】,第一时间获取最新文章

转载文章受原作者版权保护。转载请注明原作者出处!

(0)
上一篇 2023年2月7日 下午4:38
下一篇 2023年2月7日 下午4:40

相关推荐