从sizeof习题来看C++虚继承内存布局和内存对齐
例题及结果
// test.cc
#include <iostream>
using namespace std;
struct B {
int dataB;
};
struct D1 : virtual B {
int dataD1;
};
struct D2 : virtual B {
int dataD2;
};
struct MI : D1, D2 {
int dataMI;
};
int main() {
cout << sizeof(B) << endl;
cout << sizeof(D1) << endl;
cout << sizeof(D2) << endl;
cout << sizeof(MI) << endl;
return 0;
}
分别在win10和Ubuntu 16.04下编译64位的程序,因为64位程序下指针所占字节数为8。Ubuntu下是64位程序,而VS下默认是32位(x86)的,因此需要进行如下配置。
或者打开64位命令行工具手动编译
运行结果如下
$ uname -snm
Linux ubuntu x86_64
$ g++ test.cc
$ ./a.out
4
16
16
40
windows运行结果
可以看到两者得到的结果不一样,原因之后分析
虚继承简述
C++中普通的继承可以简单地理解为派生类拥有基类成员的一份拷贝,而多继承则是按照继承顺序把基类的成员依次拷贝过来。如果不同基类拥有同名成员则需要明确访问哪个基类的成员。
struct Base1 { int a = 1; };
struct Base2 { int a = 2; };
struct Derived : Base1, Base2 {};
void foo(Derived& d) { // 访问不同基类的同名成员
printf("%d %d\n", d.Base1::a, d.Base2::a);
}
而在处理菱形继承(B->D1->MI
B->D2->MI
)时,则会有一些问题。假设B有一个成员变量int a;
,那么D1
中会有一份a
的拷贝,D2
中也会有一份a
的拷贝,MI
继承自D1
和D2
,因此M2
中实际上有2个变量a
。《C++ Primer》给出的示例是iostream
继承自istream
和ostream
,而这两者都继承自ios_base
,其中ios_base
包含了一个缓冲区,如果不使用虚继承,那么iostream实际上包含了2个缓冲区。
如果让D1
和D2
虚继承自B
,那么MI
中则只有一份B
成员的拷贝。那么虚继承是怎么实现的?
虚继承的内存布局
参考【C++拾遗】 从内存布局看C++虚继承的实现原理的做法,截取D1
和D2
对象的分布情况。
可以看到D1
类的内存布局如下,一共占20个字节,至于sizeof(D1)
是24而非20的原因最后再说。
- [0, 8)
vbptr
,即虚表指针(virtual base table pointer),64位下占8个字节 - [8, 12) 成员变量
dataD1
,int类型占4个字节 - [12, 16):内存对齐,占用4个字节
- [16, 20):基类
B
的成员变量dataB
,int类型占4个字节。
排列顺序为vbptr->derived class member->base class member
,如果改成普通继承,排列顺序为base class member->derived class member
。
现在重点是虚表指针,注意下面这部分
D1::$vbtable@:
0 | 0
1 | 16 (D1d(D1+0)B)
vbi: class offset o.vbptr o.vbte fVtorDisp
B 16 0 4 0
虚表指针指向的即上面这样的虚表,虚表维护了一些信息,比如第一项就是基类名称B
,第二项是偏移量。注意,虚继承看似把基类和派生类的排列顺序改变了,实际上并没有变。基类信息还是放在前面,只不过是以一个指针取而代之,能够从指针指向的对象中获取基类成员的偏移量,另一方面能够获取基类的类型信息,从而在派生类D1
的派生类MI
中决定是否是否只保留1份基类的拷贝。
dynamic_cast和reinterpret_cast的区别
C++特有的转型方式static_cast
/reinterpret_cast
/const_cast
都可以用C风格的类型转换来代替,只是明确了类型转换的具体含义。这三种转型方式共同点就是变量的值实际没变。
C++特有的转型方式reinterpret_cast
是对对象的重新解释,因此从 Base*
转换成Derived*
后,指针的值(地址)是不会改变的。
int32_t i = 0x11223344;
auto pch = reinterpret_cast<int8_t*>(&i);
std::for_each(pch, pch + 4, [](char ch) { printf("%02x ", ch); });
// 小端系统下输出结果为44 33 22 11
转型转换的只是类型,而值是不变的,所以pch
和&i
指向的均是变量i
的地址。
但是dynamic_cast就不同了
D1 d1;
cout << &d1 << endl;
cout << (B*)&d1 << endl;
cout << static_cast<B*>(&d1) << endl;
cout << dynamic_cast<B*>(&d1) << endl;
cout << reinterpret_cast<B*>(&d1) << endl;
$ ./a.out
0x7ffd92187390
0x7ffd9218739c
0x7ffd9218739c
0x7ffd9218739c
0x7ffd92187390
可以发现reinterpret_cast
的地址仍然是D1
对象的地址。
从派生类转型为基类时,值发生了改变,这是通过虚表指针实现的,虚表记录了实际基类对象地址相对派生类对象首部的偏移量,比如这里偏移量是12(为什么不是16?),所以转型后的地址值增加了12。虚表记录了类型信息,从而实现了多态,所以如果将指向基类对象的指针转换成指向派生类对象的指针时会返回nullptr
,对引用的转型而言则会抛出bad_cast
异常。这点是static_cast
和C风格转型不具备的。
注意:如果类中不包含虚函数,则该类不被视为多态类型,在进行dynamic_cast向下转型(从基类指针/引用转型成派生类指针/引用)时,编译期间就会报错(source type is not polymorphic)
,而不是在运行期间抛出异常。
内存对齐
在明白了虚继承的内存布局后,就可以回顾最开始的代码,看看为什么sizeof的结果是4,16,16,40(Ubuntu)和4,24,24,48(Win10)。
内存对齐的规则
- 对于类的每个成员,偏移量必须是min(#pragma pack()指定的数,成员大小) 的倍数。
- 类的size必须是min(#pragma pack()指定的数,类中最大成员大小) 的倍数。
注意“成员”不包含“成员函数”,因为成员函数在内存中和类的对象并不是在一起的。没有显示定义宏#pragma pack()的参数时,按照系统默认的数来进行。
在我的Ubuntu下,默认是#pragma pack(4)
,所以结果和Win10的结果有所差异。这里按照#pragma pack的值为8计算。
对 D1
类,虚表指针占用8个字节;dataD1占用4个字节;
至此完成了D1
类的部分(12字节),D1
类最大成员是虚表指针,8个字节,因此必须补齐为8的倍数,8*1<8+4<8*2
,因此补齐4个字节,变成16字节。
后面接着B
类的部分(4字节),一共20字节,补齐为8的倍数,即24字节。
D2
和D1
一样,因此也占24个字节。
MI
继承自D2
和D1
,内存布局如下
-
D1
的虚表指针,8字节; -
dataD1
,4字节,补齐4字节; -
D2
的虚表指针,8字节; -
dataD2
,4字节,补齐4字节; -
dataMI
,4字节,补齐4字节; -
dataB
,4字节;
一共44字节,补齐至8的倍数,48字节。
其他测试
为了熟悉内存对齐规则,分别对开头的代码做出下列修改重新运行(#pragma pack(8)
)
- 去掉
dataB
成员,结果为1,16,16,40;注意一个空类单独占据1个字节,但是作为基类时占据派生类的0个字节。 - 在
B
类中增加成员char s[3];
,结果为8,24,24,48;只有B
类的大小改变了,相当于派生类的对齐部分有3个字节被它填充了。 - 在'D1'类或'D2'类中增加成员
char s[3];
,结果均为4,24,24,48。