C++类内存布局与虚继承

发布时间 2023-10-15 18:18:43作者: DogWealth~

类的内存布局

本文参考浅析C++类的内存布局,做了一些修改和补充

1. 无继承的情况

为了得到类的内存布局,先设置一下

1697183896007

1697183918859

输入 /d1 reportAllClassLayout,结果会在输出窗口打印出。最后会打印很多类,基本上最后就是自己的类的布局,也可以指定类。如果写上 /d1 reportSingleClassLayoutXXX(XXX为类名),则只会打出指定类XXX的内存布局。

1.1 无虚函数

class Base
{
private:
	char c_a;
	static char c_b;
	int i_a;
	static int i_b;
	float f_a;
	static float f_b;
	double d_a;
public:
	void fun_1() {}

};

输出的类布局如图

1697184308194

其中 <alignment member>表示为了内存对齐填充了字节

可以得到以下结论:

  • 普通的变量 :是要占用内存的,但是要注意对齐原则
  • static修饰的静态变量 :不占用内容,原因是编译器将其放在全局变量区
  • 成员函数不占用具体类对象内存空间,成员函数存在代码区
  • 数据成员的访问级别并不影响其在内存的排布和大小,均是按照声明的顺序在内存中有序排布,并适当对齐

1.2 有虚函数

class Base
{
private:
	char c_a;
	static char c_b;
	int i_a;
	static int i_b;
	float f_a;
	static float f_b;
	double d_a;
public:
	void fun_1() {}
	virtual void vfun_1() {}

};

1697184386294

现在Base类的布局改变了,最起始储存的是vfptr虚函数指针。这个指针占用了8个字节(64位操作系统),如果是32位操作系统应该是4个字节,我看网上大部分都是4个字节的,我测试多次是8个字节。

下面有一个vftable虚表,里面只有虚函数 vfun_1。如果我们再加一个虚函数 vfun_2会怎样呢?

1697184711334

可以发现,类的布局没有改变,依旧只有一个指向虚表的虚函数指针。也就是说无论有多少个虚函数,只会有一个虚函数指针存入内存,而这个虚函数指针指向的虚表里面多了一个虚函数 vfun_2,它指向了这个虚函数的地址。

2. 单一继承的情况

2.1 无虚函数

class Base
{
private:
	char c_a;
	static char c_b;
	int i_a;
	static int i_b;
	float f_a;
	static float f_b;
	double d_a;
public:
	void fun_1() {}
};

//Derived1类
class Derived1 : public Base
{
	char c_b;

public:
	void fun_d1() {}
};

//Derived2类
class Derived2 : public Derived1
{
	double d_b;

public:
	void fun_d2() {}
};

1697185794406

可以发现:

  • 每个派生类中起始位置都是Base class subobjectj基类子对象
  • 内存空间会按照类的继承顺序(父类到子类)和字段的声明顺序布局

2.2 有虚函数

  • Base类布局

    1697186155898

  • Derived1布局
    首先不变的是内存空间起始是虚函数指针,之后会按照类的继承顺序(父类到子类)和字段的声明顺序布局。在这里Derived1重写了虚函数 vfun_1。因此虚表中函数地址是 &Derived1::vfun_1,而没有重写的虚函数依旧是基类的虚函数。
    1697186192455

  • Derived2布局
    Derived2的布局是差不多的

    1697186239408

2.3 虚析构函数

当一个派生类对象经由一个base class指针被删除,而该base class带着一个non-virtual析构函数,其结果未有定义——实际执行时通常发生的是对象的derived成分没被销毁。所以上述的类设计其实有错误,带多态性质的基类应该声明一个virtual析构函数。如果class带有任何virtual函数,它就应该拥有一个virtual析构函数。

3. 多重继承

class A
{
private:
	char c_a;
	static char c_b;
	int i_a;
	static int i_b;
	float f_a;
	static float f_b;
	double d_a;
public:
	void fun_A() {}
	virtual void vfun_A() {}
	virtual ~A() {}
};

class B
{
	char c_b;

public:
	void fun_B() {}
	virtual void vfun_B() {}
	virtual ~B() {}
};

class C : public A, public B
{
	double d_b;

public:
	void fun_C() {}
	virtual void vfun_C() {}
	virtual ~C() {}
};

这里A和B是独立的类,没有继承关系,而C继承A和B。A和B的内存布局不必多赘述了,这里直接看C

1697187220093

每个包含虚函数的父类都会有自己的虚函数表,并且按照继承顺序布局(虚表指针+字段);如果子类重写父类虚函数,都会在每一个相应的虚函数表中更新相应地址;如果子类有自己的新定义的虚函数或者非虚成员函数,也会加到第一个虚函数表的后面。

4. 菱形继承

1697189015017

class A
{
private:
	char c_a;
	int i_a;
	float f_a;
	double d_a;
public:
	virtual ~A() {}
};

class B : public A
{
	int i_b;

public:
	virtual ~B() {}
};

class C : public A
{
	int i_c;

public:
	virtual ~C() {}
};

class D : public B, public C
{
	int i_d;

public:
	virtual ~D() {}
};

直接看D的布局

1697189946772

D中依次存放基类B subobject和基类C subobject。其中B和C中均存放一份class A subobject

4.1 虚拟继承

从菱形继承的D的内存布局可以看出,subobject A有两份,所以A的数据成员也存了两份,但 实际上对于D而言,只需要有一份subobject A即够了。菱形继承不仅浪费存储空间,而且造成了数据访问的二义性 。虚拟继承可以很好地解决这个问题。

我们给B和C对A的继承都加上了关键字virtual。

class A
{
private:
	char c_a;
	int i_a;
	float f_a;
	double d_a;
public:
	virtual ~A() {}
};

class B : virtual public A
{
	int i_b;

public:
	virtual ~B() {}
};

class C : virtual public A
{
	int i_c;

public:
	virtual ~C() {}
};

class D : public B, public C
{
	int i_d;

public:
	virtual ~D() {}
};

B和C类内存布局类似,如下

1697190022019

可以看到,class B中有两个虚指针: 第一个指向B自己的虚表(注意这里是vbptr而不是vfptr,是虚基类指针),第二个指向虚基类A的虚表 。而且, 从布局上看,class B的部分要放在前面,虚基类A的部分放在后面 。在class B中虚基类A的成分相对内存起始处的偏移offset等于class B的大小(16字节)。C的内存布局和B类似。

Class如果内含一个或多个virtual base subobjects,将被分割成两部分:一个不变区域和一个共享区域。不变区域中的数据,不管后继如何衍化,总有固定的offset(从object的开头算起),所以这一部分可以直接存取。而共享区域所表现的就是virtual base class subobject。这部分数据的位置会因为每次的派生操作而发生变化,所以它们只可以被间接存取。

D的内存布局如下所示。

1697189990701

菱形/钻石继承,并采用了虚继承,则内存空间排列顺序为:各个父类(包含虚基类指针)、子类、公共基类(最上方的父类,包含虚函数指针),并且各个父类不再拷贝公共基类中的数据成员。

虚继承的实现原理是,编译器在派生类的对象中添加一个指针vbptr。vbptr指的是虚基类表指针(virtual base table pointer),该指针指向了一个虚基类表(virtual table),虚基表中记录了虚基类与本类的偏移地址;通过偏移地址,这样就找到了虚基类成员,而虚继承也不用像普通多继承那样维持着公共基类(虚基类)的两份同样的拷贝,节省了存储空间。

仔细观察可以发现,D::$vbtable@B@中的偏移量是40,观察D的内存布局可以看到B类起始偏移量是0,而A类的偏移量是40,A相对D的偏移量是40;

同理观察 D::$vbtable@C@中的偏移量是24,可以看到D内存布局中C类偏移量是16,A相对C的便宜就是26。

综上验证了虚基表中记录了虚基类与本类的偏移地址

在加一个例子

class A
{
private:
	char c_a;
	int i_a;
	float f_a;
	double d_a;

public:
	virtual ~A() {}
};

class A2
{
private:
	char c_a2;

public:
	virtual ~A2() {}
};

class B : virtual public A, virtual public A2
{
	int i_b;

public:
	virtual ~B() {}
};

class C : virtual public A, virtual public A2
{
	int i_c;

public:
	virtual ~C() {}
};

class D : public B, public C
{
	int i_d;

public:
	virtual ~D() {}
};

1697364515545

5. 总结

  • 如果是有虚函数的话,虚函数表的指针始终存放在内存空间的头部;
  • 除了虚函数之外,内存空间会按照类的继承顺序(父类到子类)和字段的声明顺序布局;
  • 如果有多继承,每个包含虚函数的父类都会有自己的虚函数表,并且按照继承顺序布局(虚表指针+字段);如果子类重写父类虚函数,都会在每一个相应的虚函数表中更新相应地址;如果子类有自己的新定义的虚函数或者非虚成员函数,也会加到第一个虚函数表的后面;
  • 如果有菱形/钻石继承,并采用了虚继承,则内存空间排列顺序为:各个父类(包含虚表)、子类、公共基类(最上方的父类,包含虚表),并且各个父类不再拷贝公共基类中的数据成员。
  • 虚继承的实现原理是,编译器在派生类的对象中添加一个指针vbptr。vbptr指的是虚基类表指针(virtual base table pointer),该指针指向了一个虚基类表(virtual table),虚基表中记录了虚基类与本类的偏移地址;通过偏移地址,这样就找到了虚基类成员,而虚继承也不用像普通多继承那样维持着公共基类(虚基类)的两份同样的拷贝,节省了存储空间。
  • 空的类是会占用内存空间的,而且大小是1,原因是C++要求每个实例在内存中都有独一无二的地址。
  • 类内部的成员变量:
    普通的变量 :是要占用内存的,但是要注意 对齐原则 (这点和struct类型很相似)。
    static修饰的静态变量 :不占用内容,原因是编译器将其放在全局变量区。
  • 类内部的成员函数:
    普通函数:不占用内存。
    虚函数:要占用4个字节(32位系统)或8个字节(64位系统),用来指定虚函数的虚拟函数表的入口地址。所以一个类的虚函数所占用的地址是不变的,和虚函数的个数是没有关系的。
  • C++编译系统中,数据和函数是分开存放的(函数放在代码区;数据主要放在栈区或堆区,静态/全局区以及文字常量区也有),实例化不同对象时,只给数据分配空间,各个对象调用函数时都都跳转到(内联函数例外)找到函数在代码区的入口执行,可以节省拷贝多份代码的空间
    数据主要放在栈区或堆区,有可能是堆,也有可能是栈。这取决于实例化对象的方式:
    A a1 = new A(); //堆
    A a2; //栈
  • 类的静态成员变量编译时被分配到静态/全局区,因此静态成员变量是属于类的,所有对象共用一份,不计入类的内存空间。
  • 内联函数(声明和定义都要加inline)也是存放在代码区,在编译阶段,编译器会用内联函数的代码替换掉函数,避免了函数跳转和保护现场的开销。不要将成员函数的这种存储方式和inline(内联)函数的概念混淆。不要误以为用inline声明(或默认为inline)的成员函数,其代码段占用对象的存储空间,而不用inline声明的成员函数,其代码段不占用对象的存储空间。不论是否用inline声明(或默认为inline),成员函数的代码段都不占用对象的存储空间。用inline声明的作用是在编译时期,将函数的代码段复制插人到函数调用点,而若不用inline声明,在调用该函数时,流程转去函数代码段的入口地址,在执行完该函数代码段后,流程返回函数调用点。inline与成员函数是否占用对象的存储空间无关

Reference