《深度探索C++对象模型》第三章重点梳理。主要内容包括:
- C++ 字节对齐和类大小计算
- 数据成员的布局、虚函数表和虚基类表的布局
1 C++ 字节对齐
关于 C++ 字节对齐这篇文章讲得非常细致:C++ 字节对齐的总结(原因和作用)
这里只做简要总结:
字节对齐的原因:各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。比如有些架构的CPU在访问 一个没有进行对齐的变量的时候会发生错误,那么在这种架构下编程必须保证字节对齐。其他平台可能没有这种情况,但是最常见的是,如果不按照适合其平台要求对数据存放进行对齐,会在存取效率上带来损失。比如有些平台每次读都是从偶地址开始,如果一个 int 型数据存放在偶地址开始的地方,那么一个读周期就可以读出这 32 位数据,而如果存放在奇地址开始的地方,就需要 2 个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该 32 位数据,显然在读取效率上下降很多
可以使用
pragma pack(n)
指定以 n 字节方式对齐,不指定则按默认方式对齐字节对齐是要看变量所在偏移地址是否为变量字节数(或指定对齐字节数 n,取二者较小值)的整数倍
对于结构成员,要按照结构成员中对齐长度最大的一个对齐
对齐后还要看结构或类的总大小是否为其中最长变量(或指定对齐字节数 n,取二者较小值)的整数倍
按照以上规则可以保证对齐后整个结构或类对象所占空间最小,并保证读取效率。
2 C++ 类大小计算
关于 C++ 类的大小计算这篇文章讲的非常全面:C++类的大小计算汇总
这里只做简要总结:
C++ 中的类由于涉及虚函数、静态成员、虚拟继承、多继承、空类等情况,类对象的大小较为复杂,但总结起来遵循以下几个原则:
- 类的大小是指类对象实体的大小,类大小的计算遵循字节对齐原则
- 类的大小与普通数据成员有关,与成员函数和静态成员无关。即普通成员函数、静态成员函数、静态数据成员、静态常量数据成员,均对类的大小无影响;静态数据成员之所以不计算在类的对象大小内,是因为类的静态数据成员被该类所有的对象所共享,并不属于具体哪个对象,静态数据成员定义在内存的全局区
- 虚函数对类的大小有影响,是因为虚函数表指针带来的影响
- 虚继承对类的大小有影响,是因为虚基表指针带来的影响
- 空类的大小为 1,因为 C++ 要为某一个实体分配内存就要求这个实体必须有大小,不能为大小为 0 的实体分配内存,所以 C++ 会为空类指定 1 个字节作为其大小,防止 sizeof 为 0,含有虚函数,虚继承,多继承是特殊情况
2.1 关于空类
C++ 空类大小为 1,这之中有两种情况需要注意:
- 如果一个派生类继承自空类,且派生类有自己的数据成员,则基类子对象的一个字节不会加入到派生类中,因为派生类有自己的数据成员意味着有大小,可以分配内存,所以不需要空类的 1 个字节
- 一个类包含一个空类对象成员,这时空类的一个字节是要被计算进去的,因为空类对象必须有自己大小
2.2 关于虚函数
虚函数是通过一张虚函数表来实现的。编译器必需要保证虚函数表的指针存在于对象实例中最前面的位置(这是为了保证正确取到虚函数的偏移量)。
每当创建一个包含有虚函数的类或从包含有虚函数的类派生一个类时,编译器就会为这个类创建一个虚函数表保存该类所有虚函数的地址,其实这个虚函数表的作用就是保存自己类中所有虚函数的地址,可以把虚函数表形象地看成一个函数指针数组,这个数组的每个元素存放的就是虚函数的地址。在每个带有虚函数的类中,编译器秘密地置入一指针,称为 vptr,指向这个对象的虚函数表。 当构造该派生类对象时,其成员 vptr 被初始化指向该派生类的虚函数表。所以可以认为虚函数表是该类的所有对象共有的,在定义该类时被初始化;而 vptr 则是每个类对象都有独立一份的,且在该类对象被构造时被初始化。
含有虚函数的时候,类的大小计算要注意以下几点:
- 由于指针大小为 8 字节,因此含有虚函数的任何类的大小都是原本大小加上 8 字节
- 当继承的基类含有虚函数时,在派生类中不对基类的虚函数进行覆盖,同时派生类中还拥有自己的虚函数。此时派生类的虚函数表中首先存放基类虚函数地址,再存放派生类虚函数地址,所有虚函数地址顺序与声明顺序一致。因此无论是基类还是派生类,其大小也还是原本大小加 8 字节
- 如果派生类中对基类的虚函数进行了覆盖,同时派生类中还拥有自己的虚函数。此时虚函数表中原本存放基类虚函数的位置会被覆盖为派生类重写的虚函数地址,其他顺序不变。因此无论是基类还是派生类,其大小也还是原本大小加 8 字节
- 当一个派生类继承自多个含有虚函数的基类,并对基类虚函数无重载,此时派生类虚函数被放在第一个基类虚函数表后面,派生类的大小就是原本大小加上基类数量 * 8 字节
- 当一个派生类继承自多个含有虚函数的基类,并对基类虚函数有重载,情况和上述一致,这时任意一个基类指针指向派生类对象都可以调用派生类重载的基类虚函数
上面文章中的一个例子,在 Visual C++ 中的运行结果和文章中不同:
1 |
|
这里关于类 E 的大小,有必要再详细分析一下:类 E 包含一个类 B 子对象 16字节,一个类 C 子对象 16 字节,以及自身的 int 4 字节,现在一共是 36字节,但因为要跟成员中最大变量虚函数表指针 8 字节对齐,最终补齐到 40 字节。
可以在 VS 中输出类的布局,打开 vs项目属性->配置属性-> c/c++ ->命令行,在其它选项中添加如下命令:
/d1 reportAllClassLayout
是查看所有类的布局/d1 reportSingleClassLayoutXX
是查看名为XX的类的布局配置之后重新编译项目,会在编译输出中打印类布局。
上面例子中,类 E 的布局打印出来如下:
和我们的分析一致,同时还可以看到类 E 的两个虚函数表的布局:
其中 E::$vftable@B@
是基类 B 的虚函数表,存放的是被 E 重载后的函数 func0 的指针, E::$vftable@C@
是基类 C 的虚函数表,存放的是 C 的函数 func 和被 E 重载的函数 func1 的指针。
2.3 关于虚继承
Visual C++ 中虚继承的情况比较简单,只是多了一个指向虚基类表的指针 vbptr,而真正的指向虚基类的指针存放在虚基类表中。
3 数据成员的布局
最后通过一个例子总结 C++ 数据成员布局,通过下面这个例子可以清晰的认识 C++ 类数据成员布局、虚函数表和虚基类表的布局:
1 |
|
上面的例子中包含了虚继承、多重继承、虚函数的情况,运行结果如下:
1 | size of class X is 4 |
我们打印类 A 的布局:
可以看到类 A 的布局:
- 首先是基类 Y 的子对象,Y 包含一个虚函数,同时有一个虚基类,所以 Y 的内存中就是两个指针,一个是虚函数表指针 vfptr,一个是虚基类表指针 vbptr,共 16 字节;
- 然后是基类 Z 的子对象,Z 同样包含 vfptr 和 vbptr,以及一个 int 成员,字节对齐后一共是 24 字节,此时类 A 总共偏移 40 字节
- 然后是类 A 自己的数据成员,char 类型一个字节,类 A 总共偏移 41 字节,最终要和类中最长成员 8 字节对齐,因此对齐到 48 字节
- 类 A 的全部成员都存放完后,最后存放虚基类成员,int 占 4 字节,此时类 A 总共 52 字节,但输出是 56 字节,实际又做了一次 8 字节对齐,因为打印出的内存布局把类 A 和虚基类子对象作为两个独立的部分,所以打印 48 + 4 = 52 字节,但实际还要做一次 8 字节对齐,所以 sizeof 输出 56 字节
然后是类 A 的虚函数表:
虚函数表的第一项表示派生类对象指针相对于虚函数表指针的偏移,这里派生类 A 相对于基类 Y 的虚函数表指针的偏移是 0 字节,相对于基类 Z 的虚函数表指针的偏移是 -16 字节。
最后是类 A 的虚基类表:
虚基类表原理与虚函数表类似,表的第一项表示派生类对象指针相对于虚基类表指针的偏移,这里是 -8 字节,因为虚基类表指针前面还有一个虚函数表指针占用了 8 个字节,所以派生类 Y 对象的指针相对于其虚基类表指针的偏移量是 -8,派生类 Z 对象的指针相对于其虚基类表指针的偏移也是 -8,注意这里的派生类不再是指 A 了,而是指虚基类的派生类,所以就是 Y 和 Z;虚基类表从第二项开始,表示各个虚基类地址相对于虚基类表指针的偏移。这里虚基类 X 相对于 Y 的虚基类表指针偏移了 40 个字节,因此虚基类 X 相对于派生类 Y 的地址总共偏移了 48 字节,对于派生类 Z 也同样如此,总共偏移了 24 + 8 = 32 个字节,从上面的内存布局可以清晰地看出这一点。
可以看到,Y 和 Z 的虚基类表都指向同一个虚基类 X 的子对象,正如之前所说的,虚基类无论在继承链中被继承多少次,都只产生一个实体,即虚基类子对象,但是“不同的派生类中虚基类子对象的位置不同”这句话,一方面是指虚基类子对象相对于派生类的起始地址偏移量不同,并不是存在多个虚基类子对象;另一方面,随着类的派生,虚基类子对象的位置确实可能变化,但所有派生类的虚基类表中都指向那同一个虚基类子对象。
最后总结一下,C++ 中数据成员布局与编译器有关,但总体原理一致,Visual C++ 中数据成员布局顺序为:
- 基类子对象
- 虚函数表指针 vfptr
- 虚基类表指针 vbptr
- 基类成员
- 数据成员
- 虚基类成员
注:有的编译器不存在虚基类表指针 vbptr,而是把虚基类的偏移放在虚函数表中,这样只需要一个虚函数表指针
最最后附上一个简单的问题:为什么不把派生类的成员填到基类子对象由于字节对齐而填充的空白内存中,而是要保留基类子对象的空白内存?
直接借用书中的图解释: