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

这篇文章主要讲解虚继承的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类实际上被继承了两次,(这种机制在Eiffel中被称作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

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

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

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

1 Right* right = bottom;

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

上图有两点值得大家注意。第一点就是类中成员分布顺序是完全不一样的(实际上可以说是正好相反)。第二点,类中增加了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。我们画图总结下:

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

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

Downcasting

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

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

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

那么现在考虑如下代码

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*类型(一级)的指针,所以这个赋值是有效的!

这个就和我们上面给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.

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

时间: 2024-11-05 16:22:52

多重继承和虚继承的内存布局的相关文章

转载:C++ 多继承和虚继承的内存布局

C++ 多继承和虚继承的内存布局[已翻译100%] 英文原文:Memory Layout for Multiple and Virtual Inheritance 标签: <无> run_mei 推荐于 4年前 (共 14 段, 翻译完成于 10-17) 评论 46 分享 收藏 198 参与翻译 (5人) : super0555, polarisxxm, Ley, zaobao, 开源中国吹牛第一仅中文 | 中英文对照 | 仅英文 | 打印此文章 警告. 本文有点技术难度,需要读者了解C++和

C++ 多继承和虚继承的内存布局(Memory Layout for Multiple and Virtual Inheritance)

警告. 本文有点技术难度,需要读者了解C++和一些汇编语言知识. 在本文中,我们解释由gcc编译器实现多继承和虚继承的对象的布局.虽然在理想的C++程序中不需要知道这些编译器内部细节,但不幸的是多重继承(特别是虚拟继承)的实现方式有各种各样的不太明确的结论(尤其是,关于向下转型指针,使用指向指针的指针,还有虚拟基类的构造方法的调用命令). 如果你了解多重继承是如何实现的,你就能预见到这些结论并运用到你的代码中.而且,如果你关心性能,理解虚拟继承的开销也是非常有用的.最后,这很有趣. :-) 多重

C++ 多继承和虚继承的内存布局(转)

转自:http://www.oschina.net/translate/cpp-virtual-inheritance 警告. 本文有点技术难度,需要读者了解C++和一些汇编语言知识. 在本文中,我们解释由gcc编译器实现多继承和虚继承的对象的布局.虽然在理想的C++程序中不需要知道这些编译器内部细节,但不幸的是多重继承(特别是虚拟继承)的实现方式有各种各样的不太明确的结论(尤其是,关于向下转型指针,使用指向指针的指针,还有虚拟基类的构造方法的调用命令). 如果你了解多重继承是如何实现的,你就能

虚继承的内存布局手札1——基于VS2012

1.纸上得来总觉浅,低头debug才深刻. 对于<深度探索C++对象模型>这类型的书,得到的知识要去及时巩固才能实现永久记忆. 2.在实现了了虚拟继承归并分支之后的内容布局跟虚表的可复用存在极大关系. 继承的顺序决定了A和B的布局顺序,然后Common则在高地址,即公虚基类的布局放在D的尾部. 情况1: class A:public virtual Common{...}; class B:public virtual Common{...;virtual void NotInCommon()

[转载]多重继承及虚继承中对象内存的分布

粘过来的效果还不错:) 本位通过不断地完善讲解多重继承及虚继承中对象内存的分布. 读的时候不要着急,第一遍会有些晕,第二遍就会好很多. 能帮助加深对多重继承及虚继承对象内存的分布情况. 多重继承及虚继承中对象内存的分布     这篇文章主要讲解G++编译器中虚继承的对象内存分布问题,从中也引出了dynamic_cast和static_cast本质区别.虚函数表的格式等一些大部分C++程序员都似是而非的概念. 本文是介绍C++的技术文章,假定读者对于C++有比较深入的认识,同时也需要一些汇编知识.

虚继承之单继承的内存布局(VC在编译时会把vfptr放到类的头部,这和Delphi完全一致)

C++2.0以后全面支持虚函数与虚继承,这两个特性的引入为C++增强了不少功能,也引入了不少烦恼.虚函数与虚继承有哪些特性,今天就不记录了,如果能搞了解一下编译器是如何实现虚函数和虚继承,它们在类的内存空间中又是如何布局的,却可以对C++的了解深入不少.这段时间花了一些时间了解这些玩意,搞得偶都,不过总算有些收获,嘿嘿. 先看一段代码class A{      virtual aa(){};}; class B : public virtual  A{      char j[3];      

C++ Primer 学习笔记_95_用于大型程序的工具 --多重继承与虚继承

用于大型程序的工具 --多重继承与虚继承 引言: 大多数应用程序使用单个基类的公用继承,但是,在某些情况下,单继承是不够用的,因为可能无法为问题域建模,或者会对模型带来不必要的复杂性. 在这些情况下,多重继承可以更直接地为应用程序建模.多重继承是从多于一个直接基类派生类的能力,多重继承的派生类继承其所有父类的属性. 一.多重继承 1.定义多个类 为了支持多重继承,扩充派生列表: class Bear : public ZooAnimal { //... }; 以支持由逗号分隔的基类列表: cla

C++ Primer 学习笔记_96_用于大型程序的工具 --多重继承与虚继承[续1]

用于大型程序的工具 --多重继承与虚继承[续1] 四.多重继承下的类作用域 成员函数中使用的名字和查找首先在函数本身进行,如果不能在本地找到名字,就继续在本类中查找,然后依次查找每个基类.在多重继承下,查找同时检察所有的基类继承子树 -- 在我们的例子中,并行查找 Endangered子树和Bear/ZooAnimal子树.如果在多个子树中找到该名字,则那个名字的使用必须显式指定使用哪个基类;否则,该名字的使用是二义性的. [小心地雷] 当一个类有多个基类的时候,通过对所有直接基类同时进行名字查

C++ Primer 学习笔记_97_用于大型程序的工具 --多重继承与虚继承[续2]

用于大型程序的工具 --多重继承与虚继承[续2] 七.特殊的初始化语义 从具有虚基类的类继承的类对初始化进行特殊处理:在虚基类中,由最低层派生类的构造函数初始化虚基类.在ZooAnimal示例中,使用常规规则将导致Bear 类和 Raccoon类都试图初始化Panda对象的ZooAnimal类部分. 虽然由最低层派生类初始化虚基类,但是任何直接或间接继承虚基类的类一般也必须为该基类提供自己的初始化式.只要可以创建虚基类派生类类型的独立对象,该类就必须初始化自己的虚基类,这些初始化只在创建中间类型