C++类因为有继承的存在要比C时代的struct复杂得一些,特别是加上有虚函数的时候,以及多继承等这些特性更是令其内存布局变得面目全非。说实在的我也把握不了,我只是在一个实际的平台上进行了一些探索而已,并用此篇笔记将我的探索成果记录下来。
虽然说有些东西在C++标准里面没有规定如何做,不同的实现可能会有不同的作法,但是了解一个实际的系统是如何做的也会有益于我们更加深入的了解C++或者举一反三地理解其他的实现,而且如果我们了解了自己所用的系统上的具体实现的话,就可以对其为所欲为。
没有虚函数单继承内存布局
在只有单继承的情况下,类对象的内存布局也还算不复杂。在class B 继承了class A 的情况下,我们大致也可以猜测到B的对象的内存分布,应该是先储存类A里面的成员,然后再依次存放类B本身的成员。因为这样的布局在转换父类指针的时候不需要做任何处理即可兼容C,而且这种对象模型的实现也比较简单合理。但是具体是怎么放的呢?可以想到的大概有4种情况。像class B:A{…}这种单继承关系,默认的继承是private继承,访问修饰不影响内存布局,内存布局的4种可能是:
1. 先将A作为一块整块的结构存放,然后再将B作为一块整块的结构存放,即有点像这样class temp{A x; B y;}这种等价布局;
2. 先将A作为一块整块的结构存放,然后再在其后依次排放类B本身的各个成员变量,像class temp{A x; …. B的各个成员变量…}这种等价布局;
3. 先将A的各个成员变量依次排放,然后将B作为一个整体结构存放,就像这样class temp{ A的各个成员….; B y}这种等价布局;
4. 先排放A的各个成员变量再跟着排放B的各个成员变量,即像是这样class temp{A的各个成员…..; B的各个成员…..;};
这4种布局有什么不同么?当然有~因为有字节对齐的存在~!这几种布局的意义显然是不同的,当然,如果把字节对齐设为1字节对齐的话这几种布局模型表面上看起来就是一样的了。
如果熟悉结构的字节对齐的话,就可以很容易找到一些合适的类来一个个地推翻他们,如果不是那样的话,估计找一些合适的类的过程会比较困惑。当然也不要刻意去找一些很巧妙的类,能够证明对错其实就可以了。
#pragma pack(8) class A1{public: double b1; char c1; A1():b1(0),c1(0xFF){} }; classA2:A1{public: char a2; int a3; A2():a2(0xee),a3(0x22222222){} };
像上面的两个类,按照字节对齐的规则可以推翻那4个可能的分布中至少两个内存布局情况。按照字节对齐规则,在上面的两个类中,布局类型1和2是等价的布局,而第3种可能和第4种可能都有其不同的布局情况。在有IDE的情况下我们可以很方便的写代码然后就直接抓数据,我在这里用的是VS2013。一同测试的代码还有下面的一些,和上面是一起的:
#include"stdafx.h" #pragma pack(8) class A1{ public: double b1; char c1; A1() :b1(0), c1(0xFF){} }; class A2 :A1{ public: char a2; int a3; A2() :a2(0xee), a3(0x22222222){} }; class A2_{ public: // A2不带A1的继承的情况的等价类 char a2; int a3; A2_() :a2(0xee), a3(0x22222222){} }; class temp1{ public: // 与情况1等价布局的类 A1 a1; A2_ a2; }; class temp2{ public: // 与情况2等价布局的类 A1 a1; char a2; int a3; temp2() :a2(0xee), a3(0x22222222){} }; class temp3{ public: // 与情况3等价布局的类 double b1; char c1; A2_ a2; temp3() :b1(0), c1(0xFF){} }; class temp4{ public: // 与情况4等价布局的类 double b1; char c1; char a2; int a3; temp4() :b1(0), c1(0xFF), a2(0xee),a3(0x22222222){} }; int res; // 在main函数里打断点抓内存数据 int _tmain(int argc, _TCHAR* argv[]) { A2 x; res = sizeof(x); temp1 y; res = sizeof(y); temp2 z; res = sizeof(z); temp3 m; res = sizeof(m); temp4 n; res = sizeof(n); return 0; }
以上代码在VS2013里面的布局抓包如下:
A2 继承自 A1,以下是A2 类在内存中的布局,我们看看
图1:A2在内存中的布局
接下来我们以4 种方式模拟 A2 的内存布局,看看哪种才是真相。
1. 情况1:先将A1(A2的基类)作为一块整块的结构存放,然后再将A2_ (A2 自己的成员变量)作为一块整块的结构存放,即有点像这样class temp1{A1 x; A2_ y;}这种等价布局,我们以这种方式模拟 A2 的内存布局,发现其内存布局确定和图 1 (A2的内存布局)一样
图2
2. 情况2:先将A1 (A2的基类)作为一块整块的结构存放,然后再在其后依次排放类A2 自己的各个成员变量成员,像class temp2{A1 x; …. A2的各个成员变量…}这种等价布局,我们以这种方式模拟 A2 的内存布局,发现其内存布局确定和图 1 (A2的内存布局)一样
图3
3. 情况3:先将A1 (A2的基类)的各个成员变量依次排放,然后将A2_(A2 自己的成员变量)作为一个整体结构存放,就像这样class temp3{ A的各个成员….; B y}这种等价布局,我们以这种方式模拟 A2 的内存布局,发现和图 1 (A2的内存布局)不一样,说明情况3是错误的。
图4
4. 情况4:先排放A1 (A2的基类)的各个成员变量再跟着排放A2 自己的成员变量的各个成员变量,即像是这样class temp4{A1的各个成员…..; A2的各个成员…..;},我们以这种方式模拟 A2 的内存布局,发现和图 1 (A2的内存布局)不一样,说明情况4是错误的。
图5
很明显,第3种和第4种情况的内存分布方式已经被推翻了,还有第1和第2种情况中的一种是正确的,要想进一步推翻其中一个得改一下我们的类里面的成员类型了。我修改的思路是这样的:让基类 class A1 的最宽的成员小于8字节,而且还得让sizeof(A1)不能为8的整数倍,然后让class A2的最宽成员变成8字节的double。
#include"stdafx.h" #pragma pack(8) class A1{ public: short b1; char c1; A1() :b1(0xaaaa), c1(0xFF){} }; class A2 :A1{ public: char a2; double a3; A2() :a2(0xee), a3(0){} }; class A2_{ public: // A2不带A1的继承 的情况的等价类 char a2; double a3; A2_() :a2(0xee), a3(0){} }; class temp1{ public: // 与情况1等价布局的类 A1 a1; A2_ a2; }; class temp2{ public: // 与情况2等价布局的类 A1 a1; char a2; double a3; temp2() :a2(0xee), a3(0){} }; int res; // 在main函数里打断点抓内存数据 int _tmain(int argc, _TCHAR* argv[]) { A2 x; res = sizeof(x); temp1 y; res = sizeof(y); temp2 z; res = sizeof(z); return 0; }
测试结果如下:
A2 继承自 A1,以下是A2 类在内存中的布局,我们看看
图6:A2 的内存布局
接下来我们以2 种方式模拟 A2 的内存布局,看看哪种才是真相。
1. 情况1:先将A1(A2的基类)作为一块整块的结构存放,然后再将A2_ (A2 自己的成员变量)作为一块整块的结构存放,即有点像这样class temp1{A1 x; A2_ y;}这种等价布局,我们以这种方式模拟 A2 的内存布局,发现temp1的大小为 24 个字节,这与 A2 的大小(见图6,16个字节)不符,所有情况1失败。
图7
2. 情况2:先将A1 (A2的基类) 作为一块整块的结构存放,然后再在其后依次排放类A2 自己的各个成员变量成员,像class temp2{A1 x; …. A2的各个成员变量…}这种等价布局,我们以这种方式模拟 A2 的内存布局,发现其内存布局确定和图6 (A2的内存布局)一样
现在我们可以得出结论了,单继承的类的内存布局是和第二种情况等价的:先将base作为一块整块的结构存放,然后再在其后依次排放类derived本身的各个成员变量,像class temp{base x; …. derived的各个成员变量…}这种等价布局。
其实这也是最合理的一种情况,因为C++标准规定需要保证基类子对象的完整性,所以基类就必须作为一个完整的结构进行存储。
知道了这些对象的内存布局的情况我们就可以对任意的单继承类进行把握其在内存里面的样子了,一直递归地推理下去就可以得到最终的内存布局。单继承还是比较简单的,还有一种比较特殊的情况就是继承有空类的时候,空类这个东西各个系统应该会有不同的实现,在Windows 上的实现我测试了一下,除了一些基本的东西比较好估计以外,其他的很多种情况貌似找不到很好的理由去解释它,就是比较难总结,所以,索性我就不说空类这种情况了,没什么意义。
多继承的情况
既然单继承的情况已经明了了,多继承的情况也自然可以推理出来了,自然是按照继承的声明顺序,逐个类依次排放,最后到派生类本身的成员。比如类B继承了A1、A2、A3,即class B:A1,A2,A3{…}; 其内存布局就是等价于class temp{A1 a1; A2 a2; A3 a3; … }; 论证过程和单继承的时候差不多,也不难就是比较多东西而已,在这里我就不摆出来了,随便摆个例子吧。
#include"stdafx.h" #pragma pack(8) class A1{ public: int a1; A1() :a1(0xa1a1a1a1){} }; class A2{ public: int a2; A2() :a2(0xa2a2a2a2){} }; class A3{ public: int a3; A3() :a3(0xa3a3a3a3){} }; class B : A1,A2, A3 { public: int b; B() :b(0xbbbbbbbb){} }; int _tmain(int argc, _TCHAR* argv[]) { B bb; return 0; }