我们知道,虚继承的基类在类的层次结构中只可能出现一个实例。虚基类在类的层次结构中的位置是不能固定的,因为继承了虚基类的类可能会再次被其他类多继承。
比如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的内存布局是怎么样的,虚基类表又是怎么样的。
图1
上面就是变量内存的一个抓包,可以看到在最前面的肯定就是那个自动加入的隐藏成员,虚基类表指针了,我们由指针找到虚基类表,来看下虚基类表里面有什么。
虚基类表指针是0x00ef5858 (注意这是小端字节序)。
图2
虚基类表里面各项就是偏移了
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
我们讨论了实继承的本质,但是我们还没遇到过虚基类也复杂的情况,当虚继承一个类的时候实际上是怎么继承的,也就是虚继承的本质是什么。
继续拓展结论:一个类可以分为实部和虚部,虚继承的意义是,虚继承整个类的实部和虚部,整个虚基类将会放在派生类的虚部内,排放的顺序是,先排放虚基类的虚部里面的各个虚基类自己的虚基类,然后再排放虚基类的实部,整个实部当然也是作为一个整体结构排放。
有了以上的拓展推理,我们就可以来看更加复杂的C类了。
class C : B,virtual T, A { public: int c; C() :c(0x33333333){} };
由于B、T、A的结构我们已经知道了,其实这个时候推导出C的布局其实已经很简单了。我先做出C的实部。C的实部由B的实部和A的实部组成,然后我们看是否已经继承有虚基类表指针,然后排放C的成员变量即可。C的实部大致的模型如下:
struct C_实部{ B_实部{}; A_实部{}; [virtual baseclass pointer] // 可有可无的选项 C_成员 1~2~3~…… };
B 的实部和A的实部我们都知道了,因为在B中已经有了虚基类表指针了,所以在派生类C里面就不需要虚基类表指针了。所以C的实部我们可以轻易得到了,注意各个结构的字节对齐和整体实部的字节对齐就可以了。
关键是我们来看C的虚部。C的虚部由B的虚部和整个T还有A的虚部构成,C的虚部模型可以表示如下:
C_虚部{ B的虚部….. T{}; A的虚部….. }
而,整个类的T作为虚基类的时候在派生类的虚部里面的排放顺序是先排放虚部的各个他自己的虚基类,然后才到T的实部。所以C的虚部又可以进一步拓展开成这样:
C_虚部{ B的虚部….. T的虚部….. T的实部{}; A的虚部….. }
我们再进一步展开各个基类的虚部,虚部不是一个整体结构,是一个个虚基类分开的,只有T的实部这一波是一个整体结构。进一步展开后,B的虚部有虚基类F2,T的虚部有虚基类F2,因为已经存在了所以被忽略掉,然后是T的实部作为一个整体放在F2之后,然后是A的虚部,是F1。所以,最后C的虚部只有3个结构,F2、T实部、F1。
C_虚部{ F2{}; T实部{}; F1{}; }
这样我们就可以得到C的整体布局了。先是B实部,32字节,然后A的实部8字节,然后是成员c,4字节。实部再进行自身对齐,需要填充4字节。然后再到虚部,F2,16字节,T的实部20字节,接着是A的虚部F1,4字节。
所以C的总大小是,32+8+4+4+16+20+4 = 88字节 ,即0x58 。而且我们看到,B的实部是包含了T的实部的,所以T的实部可以在派生类的实部和虚部同时存在,这个是可以的,只要不在虚部里面有两份实例就可以了。
我们来一步步分析这88个字节(cc 是为了字节对齐而填充):
1. C类的实部:
1.1 、f0 cc cc cc:F0类的成员变量 f0,char f0 = 0xf0f0f0f0;(距派生类 C 的虚基类表指针 -4 个字节)
1.2 、7c 68 d8 00:C的虚基类表指针,和B共用,B又和它基类的那个T共用,派生类和按照实继承的声明顺序中第一个有虚基类表的实基类共用一个虚基类表;(距派生类 C 的虚基类表指针 0 个字节)
1.3 、cc cc cc cc:为了字节对齐而填充的 4 个字节;(距派生类 C 的虚基类表指针 +4)
1.4 、11 11 11 11:T类的成员变量,int t = 0x11111111;(距派生类 C 的虚基类表指针 +8 个字节)
1.5 、22 22 cc cc:T类的成员变量,short t2 = 0x2222,cc cc 是为了字节对齐;(距派生类 C 的虚基类表指针 +12个字节)
1.6 、bb bb bb bb:B类的成员变量,int b = 0xbbbbbbbb;(距派生类 C 的虚基类表指针 +16 个字节)
1.7 、00 00 00 00 00 00 00 00:B类的成员变量,double b = 0;(距派生类 C 的虚基类表指针 +24个字节)
1.8 、90 68 d8 00:A 类的虚基类表指针;(距派生类 C 的虚基类表指针 +28个字节)
1.9 、aa aa aa aa:A类的成员变量,int a = 0xaaaaaaaa;(距派生类 C 的虚基类表指针 +32个字节)
1.10 、33 33 33 33:C类(派生类)的成员变量,int c = 0x33333333;(距派生类 C 的虚基类表指针 +36个字节)
以下是派生类 C 的实部示意图,各个成员的摆放顺序与派生类 C 的声明顺序有关
class C : B,virtual T, A {…….}
从左向右,B、T、A
2. cc cc cc cc:实部和虚部的结合处,前面 10 项已经用去 44 个字节,而C 类的虚部中有 double 类型的成员,故虚部的有效字节对齐是 8 ,44 并不能被 8 整除,我们还要填充 4 个字节才行。(距派生类 C 的虚基类表指针 +40 个字节)
3. C类的虚部:
3.1 、f2 f2 f2 f2:F2 类的成员变量,int f2 = 0xf2f2f2f2f2;(距派生类 C 的虚基类表指针 +44个字节)
3.2 、cc cc cc cc:虚部的有效字节对齐是 8,所以要填充4个字节;(距派生类 C 的虚基类表指针 +48个字节)
3.3 、00 00 00 00 00 00 00 00:F2 类的成员变量,double f22 = 0;(距派生类 C 的虚基类表指针 +56个字节)
3.4 、f0 cc cc cc:F0 类的成员变量,char f0 = 0xf0,三个 cc 是为了字节对齐;(距派生类 C 的虚基类表指针 +60 个字节)
3.5 、9c 68 d8 00:虚基类 T 的虚基类表指针;(距派生类 C 的虚基类表指针 +64)
3.6 、cc cc cc cc:为了字节对齐;(距派生类 C 的虚基类表指针 +68 个字节)
3.7 、11 11 11 11:虚基类 T 的成员变量 int t = 0x11111111;(距派生类 C 的虚基类表指针 +72 个字节)
3.8 、22 22 cc cc:虚基类 T 的成员变量 short t2 = 0x2222,两个 cc是为了字节对齐;(距派生类 C 的虚基类表指针 +76个字节)
3.9 、f1 f1 f1 f1:虚基类 F1的成员变量:int f1 = 0xf1f1f1f1,(距派生类 C 的虚基类表指针 +80个字节)
以下是派生类 C 的虚部示意图,各个成员的摆放顺序与派生类 C 的声明顺序有关
class C : B,virtual T, A {…….}
从左向右,B、T、A
上面已经把实部和虚部说清楚了,稍稍有点绕,有兴趣的童鞋可以耐心的多读几次。
然后我们来看看三个虚基类表:
1. C类的虚基类表:
C的虚基类表里面的项,是它自己和他的虚部的各个虚基类的偏移量。可以一个个地计算下位置,都是对得上号的。
2. 实基类 A 的虚基类表:
A基类表里面的项是它自己和他的虚基类F1相对于这个指针的偏移,80 – 28 = 52,这也是对的上号的。
3. 虚基类T的虚基类表:
虚基类T的虚基类表,表指针到F0的距离是 60– 64 = -4,表指针到F2的距离是44– 64 = -20
到此为止,规律基本上都出来了。可以做一个没有虚函数时候的小总结了。
小总结:一个类可以分为实部和虚部,实继承的意义就是,实继承基类的实部,基类的虚部依然按照其顺序排放在派生类的整个实部之后。虚继承的意义是,虚继承整个类的实部和虚部,整个虚基类将会放在派生类的虚部内,排放的顺序是,先排放虚基类的虚部,然后再排放虚基类的实部。基类的排放顺序是,按照声明顺序,先实继承然后到成员再到虚继承类。
类的实部字节对齐细则:
类分为实部和虚部,而实部作为一个整体的结构自然也需要字节对齐。又因为是类的一个部分所以字节对齐也有点特殊。总的来说可以描述如下:
1. 有虚部的类不需要整体进行自身字节对齐,但是对于外部来讲有虚部的类的有效对齐是他的各个基类,包括实基类和虚基类,和成员变量中有效对齐参数最大的那个。
2. 实部的有效对齐参数是实继承的各个基类和各个成员变量(也包括隐藏成员变量)中有效对齐参数最大的那个,实部按照这个有效对齐参数进行自身对齐。
3. 实部的起始地址代表了类的起始地址,所以在该类作为其他派生类的子基类的时候,实部的起始地址相对于总的类的起始地址的偏移要是类的有效对齐的整数倍。
大概就可以总结为这3条。下面例子进行说明。
#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: char f2; F2() :f2(0xf2){} }; class F3{ public: int f3; double f33; F3() :f33(0), f3(0xf3f3f3f3){} }; class A : virtual F3, virtual F0{ public: int a, a2; A() :a(0xaaaaaaaa), a2(0xa2a2a2a2){} }; class T : F1, virtual F2, A { public: int t, t2; T() : t(0x11111111), t2(0x22222222){} }; class B : virtual A{ public: int b2, b; B() :b(0xbbbbbbbb), b2(0xb2b2b2b2){} }; class C : T{ public: int c; C() :c(0x33333333){} }; class D : virtual T{ public: int d; D() :d(0xdddddddd){} }; int _tmain(int argc, _TCHAR* argv[]) { A aa; T tt; B bb; C cc; D dd; return 0; }
下面是各个抓包我们逐个分析。
类A这个很普通,通过之前的讲述理解A的布局应该不难。虚函数表指针,因为没有影响后面的成员的对齐,所以刚好不需要主动填充字节。然后是A的两个成员变量,这3个元素组成了类A的实部,按照实部的对齐规则,实部的自身有效对齐是其实继承的基类和各个成员之中最大的那个,因为A是虚继承了F3和F0,没有实继承的基类,所以F3和F0在这里不适用于类A的实部的对齐规则,而各个成员的有效对齐(刚好也是其自身对齐参数)都是4,所以类A的实部的有效对齐参数就是4字节,实部的整体大小要是有效对齐参数的整数倍,所以这里刚好也不需要在实部里面填充字节。a2后面跟着的cc是因为虚部的基类F3而引入的字节填充,既不属于实部也不属于虚部。实部后面就是虚部的各个虚基类了。因为类A不需要进行自身对齐,所以可以看到在最后的虚基类F0后面就没有填充任何字节了,而类A的大小就是0x21个字节,是个单数。类A虽然不需要进行自身对齐,但是它的有效对齐是他的各个基类,包括实基类和虚基类,和成员变量中有效对齐参数最大的那个。成员变量的有效对齐都是4,虚基类F3是8,F0
是1,所以类A的有效对齐参数就是8字节。
然后看到T。
T实继承了类F1和类A,虚继承了类F2。由之前的规则可以知道,内存中的排放顺序是,类F1的实部、类A的实部、[虚函数表指针,这里因为实继承了A的虚函数表指针所以就省略了]、成员t、成员t2、然后就是各个虚部了。
可以看到在F1的实部(因为F1没有虚部所以实部就是整个F1了)之后填充了4个字节,然后才开始存放类A的实部,这是因为虽然类A的实部本身的有效对齐是4字节,但是它的起始地址是代表整个类A的,所以在类A作为其他派生类的子基类的时候,他的起始地址相对于整个类的起始地址的偏移要是他的有效对齐的整数倍,刚才我们知道了类A的有效对齐是8字节,所以在这里需要填充4个字节才能开始存放类A的实部。存放完类A的实部之后就开始存放成员变量t和t2。F1的实部+类A的实部+成员t和t2 这4个元素组成了派生类T的实部,实部的自身有效对齐是他的各个实基类和成员中有效对齐参数最大的那个,实基类F1的有效对齐是4,F2是虚基类所以不管,实基类A的有效对齐我们已经知道是8字节,而成员t和t2的有效对齐都是4字节,所以类T的实部的自身有效对齐参数就是8字节,整个实部要进行自身对齐使得大小是其有效对齐的整数倍,所以还得在t2后面填充4个字节,这4个填充的字节是属于类T的实部的,而不是因为后面的虚基类才产生的,从虚基类F2的位置我们也可以看到这个事实。类T的实部之后就是虚部的各个虚基类了,先是F2,然后是A的虚部的各个虚基类,F3和F0。在F2和F3之间填充的7个字节就是因为F3需要8字节对齐而填充的,也我因为类T不需要整体自身对齐,所以在最后的F0后面没有填充任何字节,而类T的大小是0x39字节,也是单数。类T的有效对齐是他的各个基类(包括实基类和虚基类)和成员中,最大的那个,F1是4字节,F2是1字节,A是8
字节,所以T的有效对齐也是8字节了。
再看B。
B虚继承了A,按照布局规则,先是虚基类表指针,然后是B的成员变量,然后就是A的虚部,然后再到A的实部。从B的布局也可以看到,A的实部确实也是只包含虚基类表指针、a、a2这3个元素,而且A的实部的起始地址相对于总的类的起始地址的偏移要是类A的有效对齐8字节的整数倍,而不管A的实部是在派生类的实部还是虚部里面,对齐规则都应该满足这个。
接着看C。
C实继承了T,按照规则,先是T的实部,然后是C的成员,然后是T的虚部。可以看到在T的实部里面的t2成员,也就是22222222,后面填充的那4个字节确实也是属于T的实部的,要不然的话在C的成员变量小c(也就是333那里)之前是没必要填充这4个字节的,T的整个实部在实继承的时候一起继承过来了,然后再到C的成员c,而且C的实部现在也变成了8字节对齐了,因为T的有效对齐就是8字节,所以在成员变量c,也就是33333333,后面还需要填充4字节,这4个字节当然也是属于类C的实部的。然后才到T的虚部的各个虚基类,F2、F3、F0。
我们用D再次证明我们的结论。
类D通过虚继承T,再一次证明我们的结论。按照布局规则,先是虚基类表指针,然后是D的成员变量,然后是T的虚部,再到T的实部。可以看到T的实部的地址是要填充到偏移为8的整数倍的地址上的,而D的大小是0x48的字节,也包括了22222222后面的4个字节,说明这4个字节就是T的实部的实实在在的数据,而不是因为其他的填充。