一、内存对齐
C++的对象都会进行内存对齐,所谓内存对齐,指的是对象的地址和大小都会对齐到n的倍数上。比如按照4对齐,那么对象的地址会是4的倍数,对象的大小也是4的倍数。究其原因是,机器在内存对齐的地址上访问数据更快,可以一起取出数据;如果数据存在在不对齐的地址上,需要换成2次对齐地址上的取数据,再组合出原始数据;而且,部分机器根本没有取非对齐的数据。
1.1 默认对齐
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| class OrdinaryClassWithMemoryPack { public: int intA;
short shortB;
float floatC; };
std::cout << "sizeof(int):" << sizeof(int) << std::endl; std::cout << "sizeof(short):" << sizeof(short) << std::endl; std::cout << "sizeof(float):" << sizeof(float) << std::endl; std::cout << "sizeof(OrdinaryClassWithMemoryPack):" << sizeof(OrdinaryClassWithMemoryPack) << std::endl; std::cout << "address of omp:" << &omp << std::endl << std::endl;
|
vs2019 x86的结果
vs2019 x64的结果
可以看到,默认都是按照4字节对齐,int和float都是4个字节,short是2个字节,不过强制按照4字节对齐了。对象的地址都是4的倍数,不过64位程序的地址是64位了。
1.2 Pack(n)
假如我们用pack指令强制按照2字节对齐,那么输出结果如何了?
1 2 3 4 5 6 7 8 9 10 11 12
| #pragma pack(push) #pragma pack(2) class OrdinaryClassWithMemoryPack { public: int intA;
float floatB;
short shortC; }; #pragma pack(pop)
|
vs2019 x86的结果
vs2019 x64的结果
从输出结果可以看出,对象还是位于4对齐的地址上,只是对象本身的大小变成10了。short只占2个字节,那么接下来的float并没有强制在4字节的地址对齐,而是根据pack指令对齐在2字节的地址上了。
1.3 实验环境
未避免文章过于啰嗦,接下来的例子只说明vs2019 x86的输出结果。
二、普通类的对象
2.1 基类的对象
接下来的讨论为避免内存对齐的干扰,忽略内存对齐。因此,类的成员变量只有一个int。定义基类如下,
1 2 3 4 5
| class OrdinaryClassA { public: int intA; };
|
2.2 单继承子类的对象
定义子类如下,
1 2 3 4 5
| class OrdinaryClassAFirstSon : public OrdinaryClassA { public: int intAFirstSon; };
|
2.3 多继承子类的对象
定义多继承的子类如下,
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| class OrdinaryClassASecondSon : public OrdinaryClassA { public: int intASecondSon; };
class OrdinaryMultipleInheritClassE : public OrdinaryClassAFirstSon, public OrdinaryClassASecondSon { public: int intE; }; std::cout << "sizeof(OrdinaryClassA):" << sizeof(OrdinaryClassA) << std::endl; std::cout << "sizeof(OrdinaryClassAFirstSon):" << sizeof(OrdinaryClassAFirstSon) << std::endl; std::cout << "sizeof(OrdinaryMultipleInheritClassE):" << sizeof(OrdinaryMultipleInheritClassE) << std::endl;
|
输出结果:
根据输出结果,可以看出:基类是4个字节;子类拥有基类的对象,加上自己的成员,一起是8个字节;多重继承的子类,拥有2个基类对象,加上自己的成员,总共是8+8+4=20个字节。
OrdinaryMultipleInheritClassE的两个基类都继承同一个类OrdinaryClassA,因此E的对象中会有2份A的实例。一般的编程范式中,都要求避免多继承,改用多接口继承。C++在针对这种情况,也有一种虚拟继承的方式来避免数据冗余。
三、带虚函数的类对象
3.1 带虚函数的基类的对象
1 2 3 4 5 6 7 8 9 10 11 12
| class VirtualFunClassA { public: int intA;
public: virtual int VirtualFunA() { return 0; } };
|
3.1 带虚函数的单继承子类的对象
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| class VirtualFunClassAFirstSon : public VirtualFunClassA { public: int intAFirstSon;
public: virtual int VirtualFunA() override { return 0; }
virtual int VirtualFunAFirstSon() { return 0; } };
VirtualFunClassA va; VirtualFunClassAFirstSon vason;
std::cout << "sizeof(VirtualFunClassA):" << sizeof(VirtualFunClassA) << std::endl; std::cout << "sizeof(VirtualFunClassAFirstSon):" << sizeof(VirtualFunClassAFirstSon) << std::endl << std::endl;
|
用vs2019调试,自动窗口中显示的va和vason的内存布局如下:
输出结果:
可以看到,类对象内多了一个vfptr(虚函数指针),其中子类的虚函数指针是放在父对象内的。
3.2 带虚函数的多继承子类的对象
现在来考虑多继承的情况,假如多个基类都有虚函数,那么内存布局如何了?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| class VirtualFunClassB { public: int intB;
public: virtual int VirtualFunB() { return 0; } };
class VirtualFunMultipleInheritClassC : public VirtualFunClassA, public VirtualFunClassB { public: int intC;
public: virtual int VirtualFunA() override { return 0; }
virtual int VirtualFunB() override { return 0; }
virtual int VirtualFunC() { return 0; } };
VirtualFunMultipleInheritClassC vmc;
std::cout << "sizeof(VirtualFunClassA):" << sizeof(VirtualFunClassA) << std::endl; std::cout << "sizeof(VirtualFunClassB):" << sizeof(VirtualFunClassB) << std::endl; std::cout << "sizeof(VirtualFunMultipleInheritClassC):" << sizeof(VirtualFunMultipleInheritClassC) << std::endl << std::endl;
|
用vs2019调试,自动窗口中显示的vmc的内存布局如下:
输出结果:
可以得出结论:vmc中有2个基类的对象,大小分别是8,自身有一个大小为4的int,因此总共是20的大小;多继承的对象内会有多个虚函数指针,一个指针对应一个带虚函数的基类;子类如果也带非继承而来的虚函数,那么这个虚函数也会放在某个基类的虚函数表内。
因此,多重继承的子类对象,会有多个虚函数指针,对应多个虚函数表,自身虚函数会被合并到某个基类的虚函数表中,不会再多一个虚函数指针和虚函数表。对于多重继承子类的多个虚函数表,可能是分开存储,也可能是连续存储为一个表,只是虚函数指针有一定的偏移。
四、虚拟继承的类对象
下面来讨厌最变态的部分,虚拟继承的对象。
4.1 虚多继承子类的对象
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| class OrdinaryClassAVirtualFirstSon : virtual public OrdinaryClassA { public: int intAFirstSon; };
class OrdinaryClassAVirtualSecondSon : virtual public OrdinaryClassA { public: int intASecondSon; };
class OrdinayVirtualMultipleInheritClassF : public OrdinaryClassAVirtualFirstSon, public OrdinaryClassAVirtualSecondSon { public: int intF; };
OrdinaryClassAVirtualFirstSon oavson; OrdinayVirtualMultipleInheritClassF ovmf;
std::cout << "sizeof(OrdinaryClassAVirtualFirstSon):" << sizeof(OrdinaryClassAVirtualFirstSon) << std::endl; std::cout << "sizeof(OrdinaryClassAVirtualSecondSon):" << sizeof(OrdinaryClassAVirtualSecondSon) << std::endl; std::cout << "sizeof(OrdinayVirtualMultipleInheritClassF):" << sizeof(OrdinayVirtualMultipleInheritClassF) << std::endl << std::endl;
|
用vs2019调试,自动窗口中显示的ovmf的内存布局如下:
输出结果:
可以看到2个基类的大小都是12,子类的大小是24。如果是普通继承的话,基类的大小是8,子类的大小是20,这个可以参考2.3。那么,虚继承的对象内肯定多了什么?具体是什么了。
启用类内存布局分析
由于自动窗口无法显示虚拟继承的内存布局了,那么我们只能用其它方式来查看。
如下图,我们通过Project的属性窗口,找到C++ ->命令行,添加新的选项 /d1 reportAllClassLayout。
虚继承的基类内存布局
然后清理工程重新生成,在输出窗口会输出所有类的局部情况,然后搜索OrdinaryClassAVirtualFirstSon,如下图所示,
可以看到,对象内有三个成员,按照顺序分别是vbptr(虚表指针)、数据成员intAFirstSon、基类的数据成员intA。相比普通的继承,多了虚表指针。大小总和是4+4+4=12。
虚继承的多重继承子类内存布局
可以看到,对象的成员按照顺序分别是基类1对象、基类2对象、数据成员intF、虚继承的基类数据成员intA。
大小总和是8+8+4+4=24。基类1和基类2里面都是带1个虚表指针和1个数据成员。
相比普通的继承,多了2个虚表指针,但是减少了重复基类数据,总的大小变化是20+8-4=24。如果,重复的基类OrdinaryClassA有更多的数据成员,那么虚拟继承这种机制就更划算了。
4.2 带虚函数的虚多继承子类的对象
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
| class VirtualFunClassASecondSon : virtual public VirtualFunClassA { public: int intASecondSon;
public: virtual int VirtualFunA() override { return 0; }
virtual int VirtualFunASecondSon() { return 0; } };
class VirtualFunClassAThirdSon : virtual public VirtualFunClassA { public: int AThirdSon;
public: virtual int VirtualFunA() override { return 0; }
virtual int VirtualFunAThirdSon() { return 0; } };
class VirtualFunVirtualInheritClassG : public VirtualFunClassASecondSon, public VirtualFunClassAThirdSon { public: int intG;
public: virtual int VirtualFunA() override { return 0; }
virtual int VirtualFunASecondSon() override { return 0; }
virtual int VirtualFunAThirdSon() override { return 0; }
virtual int VirtualFunE() { return 0; } };
std::cout << "sizeof(VirtualFunClassASecondSon):" << sizeof(VirtualFunClassASecondSon) << std::endl; std::cout << "sizeof(VirtualFunClassAThirdSon):" << sizeof(VirtualFunClassAThirdSon) << std::endl; std::cout << "sizeof(VirtualFunVirtualInheritClassG):" << sizeof(VirtualFunVirtualInheritClassG) << std::endl << std::endl;
|
输出结果:
发现基类的大小变成了20,多了8个字节。子类的从24变成了36,多了12个字节。猜测是多了虚函数指针。
带虚函数的虚继承的基类内存布局
可以看到,内存布局是虚函数指针、虚表指针、数据成员、基类对象(基类的虚函数指针、基类数据成员)。相比不带虚函数的虚拟继承,是多了2个虚函数指针。相比,普通的继承,是多了1个虚表指针和1个虚函数指针。所以,最奇怪的地方是没有像普通继承那样将2个虚函数指针合并成一个。
如果注释掉当前类的虚函数VirtualFunASecondSon,得到的内存布局如下:
区别是少了当前类的虚函数指针,基类对象内的虚函数指针保留。
带虚函数的虚继承的多重继承子类内存布局
这应该是已知的最复杂的类对象布局情况了。按照顺序是基类1、基类2、数据成员、虚拟基类。基类1和基类2内部都是虚函数指针、虚表指针、数据成员,大小都是12,那么总共是24。数据成员大小是4。虚拟基类的内部是虚函数指针、数据成员,大小是8。因此,总共的大小是12+12+4+8=36。
相比不带虚函数的虚拟继承,多了3个虚函数指针,总计12个字节。相比普通的继承,多了2个虚表指针和1个虚函数指针,但是减少了虚拟基类数据的重复,那么总大小是28+12-4=36。
虚拟继承的最终结论
1、虚拟继承的对象内会多一个虚表指针。
2、带虚函数的虚继承,子类和基类的虚函数表不会合并,因此会多一个虚函数指针。
3、多重继承的基类,如果虚继承了共同的基类,那么其共同基类对象只会存在一份,包括数据成员和虚函数指针。
疑问:带虚函数的虚继承为何不合并子类和基类的虚函数指针?
猜测可能跟vs2019对应的vc++编译器实现有关。
4.3 虚表指针的用途
我们知道,虚函数指针指向的是虚函数表,虚函数表内存储的是虚函数的地址。对于采用指针或者引用来动态调用虚函数的情况,会在运行时才能确定真正的虚函数地址,这个就叫做延迟绑定。为了灵活性,失去了部分性能。
那么,虚表指针是用来做什么的?可以肯定的是用于找到共同的基类对象的。猜测虚表指针指向一张table,该table内部存储共同的基类数据在类对象内的偏移。
4.4 虚拟继承实现的编译器差异
根据深入探索C++对象模型的说明,虚拟继承在不同的编译器下有不同的实现,而且C++标准并未规定如何实现。因此,g++的内存布局跟vc++的内存布局可能会有显著差别。