C++类内存布局与虚继承
类的内存布局
本文参考浅析C++类的内存布局,做了一些修改和补充
1. 无继承的情况
为了得到类的内存布局,先设置一下
输入 /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() {}
};
输出的类布局如图
其中 <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() {}
};
现在Base类的布局改变了,最起始储存的是vfptr虚函数指针。这个指针占用了8个字节(64位操作系统),如果是32位操作系统应该是4个字节,我看网上大部分都是4个字节的,我测试多次是8个字节。
下面有一个vftable虚表,里面只有虚函数 vfun_1
。如果我们再加一个虚函数 vfun_2
会怎样呢?
可以发现,类的布局没有改变,依旧只有一个指向虚表的虚函数指针。也就是说无论有多少个虚函数,只会有一个虚函数指针存入内存,而这个虚函数指针指向的虚表里面多了一个虚函数 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() {}
};
可以发现:
- 每个派生类中起始位置都是Base class subobjectj基类子对象
- 内存空间会按照类的继承顺序(父类到子类)和字段的声明顺序布局
2.2 有虚函数
-
Base类布局
-
Derived1布局
首先不变的是内存空间起始是虚函数指针,之后会按照类的继承顺序(父类到子类)和字段的声明顺序布局。在这里Derived1重写了虚函数vfun_1
。因此虚表中函数地址是&Derived1::vfun_1
,而没有重写的虚函数依旧是基类的虚函数。
-
Derived2布局
Derived2的布局是差不多的
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
每个包含虚函数的父类都会有自己的虚函数表,并且按照继承顺序布局(虚表指针+字段);如果子类重写父类虚函数,都会在每一个相应的虚函数表中更新相应地址;如果子类有自己的新定义的虚函数或者非虚成员函数,也会加到第一个虚函数表的后面。
4. 菱形继承
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的布局
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类内存布局类似,如下
可以看到,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的内存布局如下所示。
菱形/钻石继承,并采用了虚继承,则内存空间排列顺序为:各个父类(包含虚基类指针)、子类、公共基类(最上方的父类,包含虚函数指针),并且各个父类不再拷贝公共基类中的数据成员。
虚继承的实现原理是,编译器在派生类的对象中添加一个指针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() {}
};
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与成员函数是否占用对象的存储空间无关