本文转载自 C++类大小详尽讲解

C++ 类的大小

与类大小有关的因素:普通成员变量,虚函数,继承(单一继承,多重继承,重复继承,虚拟继承); 与类大小无关的因素:静态成员变量,静态成员函数及普通成员函数。

内存对齐

由于在分析类的大小时会经常涉及到内存对齐的概念,因此这里先总结一下相关的知识点。

  • 内存对齐的原因: 关键在于CPU存取数据的效率问题。为了提高效率,计算机从内存中取数据是按照一个固定长度的。比如在32位机上,CPU每次都是取32bit数据的,也就是4字节;若不进行对齐,要取出两块地址中的数据,进行掩码和移位等操作,写入目标寄存器内存,效率很低。内存对齐一方面可以节省内存,一方面可以提升数据读取的速度;

  • 内容: 内存对齐指的是C++结构体中的数据成员,其内存地址是否为其对齐字节大小的倍数。

  • 对齐原则:

    1. 结构体变量的首地址能够被其最宽基本类型成员的对齐值所整除;
    2. 结构体内每一个成员的相对于起始地址的偏移量能够被该变量的大小整除;
    3. 结构体总体大小能够被最宽成员大小整除;如果不满足这些条件,编译器就会进行一个填充(padding)。

#pragma pack 宏

#pragma pack(1)的用法大多是用在结构体中

结构体的字节对齐方式在不同的编译器中不同,会存在数据冗余,以下举个例子

1
2
3
4
5
 struct example
{
char header_start;
double data_type;
};

现有的结构体,就会按照结构体成员中最大的数据类型对齐,例子当中就是double型,按照8个字节进行对齐。那么此时sizeof(example)就是16,存在7个空字节,因为其中char只占一个字节

如果加上 #pragma pack(1),那么example按1个字节对齐方式对齐,此时sizeof(example)就是等于9

这种方法的使用一定要是成对使用,如下面例子

1
2
3
4
5
6
7
#pragma pack(1)
struct example
{
char header_start;
double data_type;
};
#pragma pack()

我们一定要在结构体末尾加上#pragma pack()进行取消自定义字节对齐的命令,如果不取消,可能会导致整个程序存在问题。因为会影响到其他的结构体对齐方式

一些用法:

1
2
3
4
5
6
7
8
9
10
11
#pragma pack(show) //显示当前内存对齐的字节数,编辑器默认8字节对齐

#pragma pack(n) //设置编辑器按照n个字节对齐,n可以取值1,2,4,8,16

#pragma pack(push) //将当前的对齐字节数压入栈顶,不改变对齐字节数

#pragma pack(push,n) //将当前的对齐字节数压入栈顶,并按照n字节对齐

#pragma pack(pop) //弹出栈顶对齐字节数,不改变对齐字节数

#pragma pack(pop,n) //弹出栈顶并直接丢弃,按照n字节对齐

空类

空类即什么都没有的类,按上面的说法,照理说大小应该是0,但是,空类的大小为1,因为空类可以实例化,实例化必然在内存中占有一个位置,因此,编译器为其优化为一个字节大小。

某类继承自空类:

1
2
3
4
5
6
7
8
9
class base
{
};

class derived:public base
{
private:
int a;
};

此时,derived类的大小为4,derived类的大小是自身int成员变量的大小,至于为什么没有加上父类base的大小1是因为空白基优化的问题,在空基类被继承后,子类会优化掉基类的1字节的大小,节省了空间大小,提高了运行效率

一般类的大小(注意内存对齐)

首先上两个类的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class base1
{
private:
char a;
int b;
double c;
};

class base2
{
private:
char a;
double b;
int c;
};

虽然上述两个类成员变量都是一个char,一个int,一个double,但是不同的声明顺序,会导致不同的内存构造模型,对于base1,base2,其成员排列是酱紫的:

base1:

1.png

base2:

2.png

base 1类对象的大小为16字节,而base 2类对象的大小为24字节,因为不同的声明顺序,居然造成了8字节的空间差距,因此,我们将来在自己声明类时,一定要注意到内存对齐问题,优化类的对象空间分布。

含虚函数的单一继承

首先呈上示意类:(64位,指针大小8字节)

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 Base
{
private:
char a;
public:
virtual void f();
virtual void g();
};

class Derived:public Base
{
private:
int b;
public:
void f();
};

class Derived1:public Base
{
private:
double b;
public:
void g();
virtual void h();
};

基类Base中含有一个char型成员变量,以及两个虚函数,此时Base类的内存布局如下:

3.png

内存布局的最一开始是vfptr(virtual function ptr)即虚函数表指针(只要含虚函数,一定有虚函数表指针,而且该指针一定位于类内存模型最前端),接下来是Base类的成员变量,按照在类里的声明顺序排列,当然啦,还是要像上面一样注意内存对齐原则!

继承类Derived继承了基类,重写了Base中的虚函数f(),还添加了自己的成员变量,即int型的b,这时,Derived的类内存模型如下:

4.png

此种情况下,最一开始的还是虚函数表指针,只不过,在Derived类中被重写的虚函数f()在对应的虚函数表项的Base::f()已经被替换为Derived::f(),接下来是基类的成员变量char a,紧接着是继承类的成员变量int b,按照其基类变量声明顺序与继承类变量声明顺序进行排列,并注意内存对齐问题。

继承类Derived1继承了基类,重写了Base中的虚函数g(),还添加了自己的成员变量(即double型的b)与自己的虚函数(virtual h() ),这时,Derived1的类内存模型如下:

5.png

此种情况下,Derived1类一开始仍然是虚函数表指针,只是在Derived1类中被重写的虚函数g()在对应的虚函数表项的Base::g()已经被替换为Derived1::g(),新添加的虚函数virtual h()位于虚函数表项的后面,紧跟着基类中最后声明的虚函数表项后,接下来仍然是基类的成员变量,紧接着是继承类的成员变量。

含虚函数的多重继承

首先上示意类:

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
class Base1
{
private:
char a;
public:
virtual void f();
virtual void g1();
};

class Base2
{
private:
int b;
public:
virtual void f();
virtual void g2();
};

class Base3
{
private:
double c;
public:
virtual void f();
virtual void g3();
};

class Derived:public Base1, public Base2, public Base3
{
private:
double d;
public:
void f();
virtual void derived_func();
}

首先继承类多重继承了三个基类,此外继承类重写了三个基类中都有的虚函数virtual f(),还添加了自己特有的虚函数derived_func(),那么,新的继承类内存布局究竟是什么样子的呢?请看下图!先来看3个基类的内存布局:

3个基类的内存布局:

6.png

紧接着是继承类Derived的内存布局:

7.png

首先,Derived类自己的虚函数表指针与其声明继承顺序的第一个基类Base1的虚函数表指针合并,此外,若Derived类重写了基类中同名的虚函数,则在三个虚函数表的对应项都应该予以修改,Derived中新添加的虚函数位于第一个虚函数表项后面,Derived中新添加的成员变量位于类的最后面,按其声明顺序与内存对齐原则进行排列。

菱形继承的问题及解决方案:虚拟继承

首先在讲这一节之前,先贴出几个重要的信息(干货):

  1. 不同环境下虚拟继承对类大小的影响
  • 在vs环境下,采用虚拟继承的继承类会有自己的虚函数表指针(假如基类有虚函数,并且继承类添加了自己新的虚函数)。

  • 在gcc环境下及mac下使用clion,采用虚拟继承的继承类没有自己的虚函数表指针(假如基类有虚函数,无论添加自己新的虚函数与否),而是共用父类的虚函数表指针。

  1. 虚拟继承会给继承类添加一个虚基类指针(virtual base ptr 简称vbptr),其位于类虚函数指针后面,成员变量前面,若基类没有虚函数,则vbptr其位于继承类的最前端。

关于虚拟继承,首先我们看看为什么需要虚拟继承及虚极继承解决的问题。

虚极继承主要是为了解决菱形继承下公共基类的多份拷贝问题:

8.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Base
{
public:
int a;
};

class Base1:virtual public Base
{
};

class Base2:virtual public Base
{
};

class Derived:public Base1,public Base2
{
private:
double b;
public:
};

Base1与Base2本身没有任何自身添加的数据成员与虚函数,因此,Base1与Base2都只含有从Base继承来的int a与一个普通的方法,然后Derived又从Base1与Base2继承,这时会导致二义性问题及重复继承下空间浪费的问题:

  • 二义性问题:
1
2
Derived de;
de.a=10; //这里是错误的,因为不知道操作的是哪个a
  • 重复继承下空间浪费:

Derived重复继承了两次Base中的int a,造成了无端的空间浪费。

虚拟继承是怎么解决上述问题的?

虚基继承可以使得上述菱形继承情况下最终的Derived类只含有一个Base类,Base类在虚拟继承后,位于继承类内存布局最后面的位置,继承类通过vbptr寻找基类中的成员及vfptr

虚继承的目的是让某个类做出声明,承诺愿意共享它的基类。其中,这个被共享的基类就称为虚基类(Virtual Base Class)。在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含一份虚基类的成员。

虚拟继承对继承类的内存布局影响可以先看以下示例代码,理解以后,我们在最后列出上述菱形虚拟继承情况下Base1,Base2与Derived代码及内存布局,看到虚拟继承起的作用。

1
2
3
4
5
6
7
8
9
10
11
12
13
class base
{
public:
int a
virtual void f();
};

class derived:virtual public base
{
public:
double d;
void f();
};

Derived类内存布局如下图,由于虚拟继承,Derived只会有一个最初基类的拷贝,该拷贝位于类对象模型的最下面,而想要访问到基类的元素,需要vbptr指明基类的位置(vbptr作用),假如Base中含有虚函数,而继承类中没有增添自己的新的虚函数,那么Derived类统一的布局如下:

9.png

如果添加了自己的新的虚函数(代码如下):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class base
{
public:
int a
virtual void f();
};

class derived:virtual public base
{
public:
double d;
void f();
virtual void g(); //这是Derived类自己新添加的虚函数
}

那么Derived在VC下继承类会有自己的虚函数指针,而在Gcc下是共用基类的虚函数指针,其分布如下:

10.png

现在有了上述代码的理解我们可以写出菱形虚拟继承代码及每个类的内存布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Base
{
public:
int a;
};

class Base1:public virtual Base
{
};

class Base2:public virtual Base
{
};

class Derived:public Base1,public Base2
{
private:
double b;
public:
};

11.png

带实线的框是类确确实实有的,带虚线是针对Base,及Base1,Base2做了扩展后的情况:

Base有虚函数,Base1还添加了自己新的虚函数,Base1也有自己成员变量,Base2添加了自己新的虚函数,Base2也有自己成员变量,则上图全部虚线中的部分都将存在于对象内存布局中。

🌰

g++ -fdump-lang-class vptr.cpp 可输出对象的内存布局文件

在理解了上述的内容后,以GCC编译器为例来分析一下下面的这些类的大小。

在分析之前还是需要注意:

GCC共享虚函数表指针(无论虚拟继承的子类是否添加了自己的虚函数),也就是说父类如果已经有虚函数表指针,那么子类中共享父类的虚函数表指针空间,不再占用额外的空间,VC在虚继承情况下若没有添加自己的新虚函数,则共享父类虚函数表指针; 若添加自己的新虚函数,则不共享父类虚函数表指针,自己拥有自己新的虚函数表指针。

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
class A
{
char a[2];
public:
virtual void aa() {};
};

class B :public virtual A
{
char b[2];
char a[2];
public:
virtual void aa() {};
virtual void bb() {};
};

class C :public virtual B
{
char c[2];
char b[2];
char a[2];
public:
virtual void cc() {};
virtual void aa() {};
virtual void bb() {};
};

int main()
{
cout << sizeof(A) <<" "<< sizeof(B) <<" "<< sizeof(C) << endl;
}

首先,对于 class A 而言,如果仅有 char a[2] 这个变量,那么 A 的大小将为 2,但 A 中定义了一个虚函数 aa(),因此会在类的前端维护一个虚函数表指针,占 8 个字节,而后 a[2] 被对齐为 8 个字节,因此 sizeof(A) == 16;

1
2
3
4
5
6
class A
{
char a[2];
public:
virtual void aa() {};
};

对于 Class B,由于使用虚继承继承了 A,因此在类的前方会维护一个虚基类表指针,占 8 个字节,紧接着是自己的两个 char[2] 成员变量,对齐后一共占 8 个字节;在 GCC 中共享虚函数表指针,因此只会维护一个虚函数表指针,占 8 个字节,最后是父类 A 的成员变量 char[2],被对齐为 8 个字节,因此 sizeof(B) = 32

1
2
3
4
5
6
7
8
class B :public virtual A
{
char b[2];
char a[2];
public:
virtual void aa() {};
virtual void bb() {};
};

对于 Class C,依然先是要维护一个虚基类表指针,8 个字节,之后是自己的 3 个成员变量,被对齐为 8 个字节,之后的内容占据的空间大小与 Class B 相同,依次为虚函数表指针(公用,8 字节), Class A 的成员变量(8 字节),Class B 的虚基类表指针(8 字节),Class B 的成员变量(8 字节),因此 sizeof(C) = 48

1
2
3
4
5
6
7
8
9
10
class C :public virtual B
{
char c[2];
char b[2];
char a[2];
public:
virtual void cc() {};
virtual void aa() {};
virtual void bb() {};
};