C++ 虚继承内存分配

我们知道,虚继承的基类在类的层次结构中只可能出现一个实例。虚基类在类的层次结构中的位置是不能固定的,因为继承了虚基类的类可能会再次被其他类多继承。

 

比如class A: virtual T{} 这时T的位置如果相对于A是固定值的话,假设偏移是X,当再有个类 class B:virtual T{} ;这时假设在B里面T的偏移是固定的Y,而当再有一个类,class C: B, A {} 的时候,像这种情况,T在C里面的实例就只能有一份,由B和A共享继承,显然这时候的T的位置相对于A或者B的偏移和这两个类对象单独存在的时候的相对偏移是不可能一样的,也就是说当我们把C的指针转换成A或者B的指针的时候,通过A* 来访问T还是使用固定的偏移量X么?用B* 来访问T还是使用固定的偏移量Y么?显然如果这样做的话肯定会有一个是错的。在非虚继承的时候因为T还是由A或B独享的,偏移固定是可以的。所以在虚继承的时候虚基类的位置是变化的,位置变化的意思其实主要是指在继承层次上相对于某些子对象的偏移上,如果当一个类已经定义完毕了的话,单独对于这个类来说那么虚基类的位置就肯定是固定的了。我们要访问到这个虚基类的话还是得知道他的地址,或者说相对偏移。

 

那么怎么才能找到这个虚基类呢?C++标准里面没有规定怎么去实现虚继承等这些东西,可以由实现决定。在VC里面是使用虚基类表的方法来实现类的虚继承的。在类的层次结构中,有虚继承的话,类都会生成一个隐藏的成员变量,虚基类表指针,虚基类表指针指向一个全类共享的虚基类表,虚基类表里面每一项的单位是4个字节,第一项保存的是类本身的地址相对于虚基类表指针的偏移,第二项保存的是第一个虚基类相对于虚基类表指针的偏移,第三个依此类推。

 

我们先从一些简单的情况看下,最后在慢慢地阶段性万剑归宗。

class A{public:  
   int a;  
   A():a(0xaaaaaaaa){}  
};  
class B{public:  
   int b;  
   B():b(0xbbbbbbbb){}  
};  
class C: virtual A, virtual B{public:  
   int c;  
   C():c(0x11111111){}  
};  

我们定义一个C的变量,看下类C的内存布局是怎么样的,虚基类表又是怎么样的。

上面就是变量内存的一个抓包,可以看到在最前面的肯定就是那个自动加入的隐藏成员,虚基类表指针了,我们由指针找到虚基类表,来看下虚基类表里面有什么。

虚基类表指针是0x00ef5858 (注意这是小端字节序)。

虚基类表里面各项就是偏移了

 

1.         前4个字节:00 00 00 00,是派生类本身到虚基类表指针的偏移,可以看到是0,虚基类表指针就是在派生类C的最开始,虚基类表指针不一定都是在派生类的最开始,要不然这第一项就没意义了,后面我们会看到这种情况;

 

2.         第 5 ~ 8 字节:08 00 00 00,是第一个虚基类的偏移,可以看到是8,结合图1可知,类A(从 aa aa aa aa 开始就是类 A 的部分)相对于虚基类表指针是8字节的偏移;

 

3.         第9 ~ 12字节:0c 00 00 00,是B的偏移,是12,结合图1可知,类B(从 bb bb bb bb 开始就是类 B的部分)相对于虚基类表指针是12字节的偏移;

 

这就是虚基类表了,没什么神秘也没什么太多难的东西的。表里存放的东西就是固定的了,而且虚基类表是存储在只读数据区的,由所有的对象共享,也就是说,我们再创建一个C的变量,不管C的变量是临时变量还是静态或者全局变量,它的虚基类表指针还是一样的指向这个虚基类表,因为这些偏移量在类写出来的时候就已经确定了,和具体的对象是没有联系的,有人会说,那既然在类写出来后不管是实继承的类还是虚继承的类位置都是已经确定了的话那为什么还需要一个表来查找呢?这个道理很简单,和我们之前说的那个例子一样,就是在派生类的指针转换成基类的时候,这个时候如果使用这个指针来访问基类的虚继承基类的成员的话,那么这个基类的虚基类的偏移该是多少呢?这时候就会有歧义或者说冲突,总之固定下来是不可能的。而且你可以试试直接去擦写虚基类表里面的数据,看下程序会不会报错:xxx指令引用的xxx内存,该内存不能为written。 理论上是会报错的,除非你的系统是把它存储在数据区里面,这样到还可以强行擦写。

 

虚基类表是简单,难的是虚继承的时候类的布局情况。

 

我将循序渐进地推导以下的类的内存布局,逐渐给出一个我自己总结出来的通用模型,然后用这个模型去推导后几个复杂的类的每一个字节~!

#include"stdafx.h"  
#pragma pack(8)  
class F0{ public:char f0; F0() :f0(0xf0){} };  
class F1{ public:int f1; F1() :f1(0xf1f1f1f1){} };  
class F2{ public:int f2; double f22; F2() :f22(0), f2(0xf2f2f2f2){} };  
   
class A : virtual F1 {  
public:  
       int a;  
       A() :a(0xaaaaaaaa){}  
};  
class T : virtual F2, F0 {  
public:  
       int t; short t2;  
       T() : t(0x11111111), t2(0x2222){}  
};  
class B : T{  
public:  
       int b; double b2;  
       B() :b(0xbbbbbbbb), b2(0){}  
};  
class C : B,virtual T, A {  
public:  
       int c;  
       C() :c(0x33333333){}  
};  
   
   
int _tmain(int argc, _TCHAR* argv[])  
{  
       A aa;  
       T tt;  
       B bb;  
       C cc;  
       return 0;  
}  

情况1

当然还是从简单的慢慢看起。F0、F1、F2相信都已经很清楚了,我们先看类A。

class A : virtual F1 {  
public:  
       int a;  
       A() :a(0xaaaaaaaa){}  
};  

一个小结论:当一个类虚继承了一个基类或者多虚继承了几个基类的时候,会创建一个隐藏的成员变量,虚基类表指针,放在类的其他成员的前面。先是虚基类表指针,然后是类的各个成员,最后是各个虚基类按照继承声明的顺序依次排放在后面。

按照这个结论我们可以得出A的布局情况,A的基类都是虚基类,没有实基类,所以A先会产生一个虚基类指针指向A的虚基类表,然后就开始存放A的成员变量a,最后放虚基类F1{}的实例,因为C++要求不能破坏基类的完整性,所以F1是按照一个完整的结构放在最后的,当然这里F1只有一个int成员,所以就是一样的了。下面是A的一个实际布局我在VS2013下抓的包。

A 的虚基类表(注意是小端序)

可以看到,虚基类表的第一项是0(十进制),意味着派生类到虚基类表指针的距离为0,第二项是8(十进制),意为着虚基类F1距离派生类的偏移是8。这两项都是符合事实的。

 

在A的情况下,我们没有考虑结构的字节对齐的问题,当然刚好也不需要考虑,你把对齐设置成多少都是一样的内存布局,这当然是故意安排的。在下一个类T的时候我们就有必要考虑字节对齐的问题了。

情况2

class T : virtual F2, F0 {  
public:  
       int t; short t2;  
       T() : t(0x11111111), t2(0x2222){}  
};  

结论进一步拓展:当一个类的基类中,有虚继承也有(实)继承的情况下,实继承的基类按照声明的顺序依次排放在类的最前面,而不管是否有虚基类声明在他们之前,实基类的存放依然和多继承时候一样要保证他们的完整性,排放完各个实基类之后,如果没有继承任何虚基类表指针的话,就会自动产生一个虚基类表指针,排放在各个实基类之后,然后依次再存放派生类里面的各个成员变量,到此为止的所有实基类和虚基类表指针以及各个成员变量一起组成派生类的实部,整个实部作为一个整体结构,需要进行自身对齐。其中,虚基类表指针这个特殊的成员变量的字节对齐规则,在另一篇,里详细描述,不在此细说。然后在整个实部之后,开始排放各个虚基类,各个虚基类以派生类的起始地址为基准,按照正常字节对齐规则对齐,仍然要保证他们的结构完整性。各个虚基类组成了派生类的虚部,整个虚部不看成一个整体结构,不需要进行自身对齐。由实部和其后的虚部组成的派生类不需要再进行自身对齐,也就是说,实部和虚部不能合起来看做一个整体结构。

我们再来分析 T,T 虚继承了 F2 还实继承了 F0,虽然 F2 声明在 F0 之前,但是 F0 是实继承而 F2 是虚继承的,所以 F0 要排在最前面,然后因为 F0 自身没有带虚基类表指针,所以在存放完 F0 后需要加入一个虚基类表指针,因为虚基类表指针是4字节的有效对齐参数,而F0只有一个char类型的成员,只占据了1个字节,所以在F0后需要填充3个字节,然后才到虚基类表指针,根据虚基类表指针的字节对齐规则,还需要在它之后主动填充一个字节,然后就可以排放派生类T本身的成员变量了,第一个变量是int型的,需要对齐到4字节的边界,所以还得在虚基类表指针主动填充的那个字节之后再填充3个字节,然后才到他自己,接着就是存放short 类型的t2 了,整个实部到此就没有其他成员了,然后整个实部需要看成一个大的结构,进行自身对齐,即其字节数要是自身有效对齐参数的整数倍,这个大实部的自身对齐参数就是4字节,所以在t2之后还得填充2个字节方可满足。然后这就是整个完整的实部了。接着就可以排放各个虚基类了,因为每个虚基类都要做为一个完整的结构进行存放,所以在F2存放之前,F2对于派生类起始地址的偏移要是8的整数倍,然而现在实部的大小是1+3+4+1+3+4+2+2=20字节,不满足要求,所以要填充4字节,这4个字节不属于实部也不属于虚基类 F2。填充之后就可以最后存放 F2 了。因此T类的总大小就是实部的20字节+上填充的4字节+上虚基类F2的大小16字节 = 40字节。

 

T的虚基类表:0x00fc6864(小端序)

可以看到,T的虚基类表里面的第一项已经不是0了,因为此时虚基类表指针前面还有一个实基类,所以第一项,也就是派生类本身的地址相对于虚基类表指针的偏移就是负数了,0xFFFFFFFC(小端序)就是-4的十六进制表示,结合上上图,虚基类表指针到派生类头部的距离为-4个字节。第二项就很明显为14(十进制20),结合上上图,虚基类表指针到派生类虚部的距离为20个字节。

 

情况3

然后我们再把事情变得复杂一些。考虑实继承的基类中又有虚基类的情况。也就是类B的情况。

class B : T{  
public:  
       int b; double b2;  
       B() :b(0xbbbbbbbb), b2(0){}  
};  

在上一个结论拓展中我们已经把类分为实部和虚部,也介绍了其概念,接下来就会简单一些了。

 

再拓展结论:一个类可以分为实部和虚部,实继承一个基类的意义就是,实继承基类的实部,基类的虚部依然按照其顺序排放在派生类的整个实部之后。当类有多继承时,排放的顺序依然是,先按照声明次序排放各个实基类的实部,然后,如果已经从实基类那里继承有了虚基类表指针的话,派生类就可以和继承声明顺序中第一个实继承的实基类共同使用一个虚基类表,不必要再产生一个新的虚基类表指针,如果没有继承任何虚基类表指针则产生一个。然后就是存放派生类本身的各个成员变量,存放完之后,整个实基类的实部和派生类本身的成员变量一起又构成了一个派生类的实部,需要进行自身对齐。派生类实部之后开始存放各个实基类的虚部,排放顺序是按照各个基类的声明顺序进行排放,如果排放的虚基类已经在派生类的虚部里面存在着实例,则不再安排,这样就保证了虚基类的唯一性。

然后我们按照这个结论再分析类B的情况。B实继承了一个T,所以先是安排T的实部,T的内存布局我们是知道了的,因为T的实部里面已经有了那个隐藏成员,虚基类表指针,所以派生类B就不再需要产生一个虚基类表指针了,它可以和T共用一个虚基类表,因为T是B的基类中有虚基类的成员,T的各个虚基类也是排放在派生类B的虚部里面的,所以他们可以共用一个虚基类表,共用一个虚基类表的规则是这样:派生类和按照实继承的声明顺序中第一个有虚基类表的实基类共用一个虚基类表,而虚基类表里面的偏移顺序是,先安排那个实基类的虚部的虚基类的各个偏移,然后再到按照声明顺序安排其他的虚基类,其中已经在虚基类表里面了的虚基类就不安排了。在这里还没有什么例子可以说明,先列出规则,后面讲虚函数到时候会有例子。没有产生新的隐藏成员然后就直接到了B的成员变量了,刚才我们知道,T的实部的size是20个字节,按照字节对齐规则B的成员b刚好满足,即不需要填充字节了,然后就是b2,b2也刚好满足字节对齐规则也刚好不需要填充字节了,到此为止,派生类的实部就完成了,其大小是20字节的T的实部+4字节的b+8字节的b2= 32字节,也是刚好满足自身对齐了的。排完实部就可以到虚部了,这里只有T的虚部的各个虚基类,其实就是一个F2而已,而F2作为一个整体它的字节对齐规则也刚好满足要求,所以这次它和实部之间就不需要填充字节了。所以整个派生类B的大小就是 实部的32字节+虚部的16字节= 48 字节。

 

B的虚基类表:0x00fc6870(小端序)

 

可以看到B的虚基类表第一项没变,第二项为1f(十进制28),结合上上图虚基类表指针到派生类虚部的距离为28个字节。

情况4

我们讨论了实继承的本质,但是我们还没遇到过虚基类也复杂的情况,当虚继承一个类的时候实际上是怎么继承的,也就是虚继承的本质是什么。

 

继续拓展结论:一个类可以分为实部和虚部,虚继承的意义是,虚继承整个类的实部和虚部,整个虚基类将会放在派生类的虚部内,排放的顺序是,先排放虚基类的虚部里面的各个虚基类自己的虚基类,然后再排放虚基类的实部,整个实部当然也是作为一个整体结构排放。

原文地址:https://www.cnblogs.com/13224ACMer/p/6291542.html