0. 什么是“内存布局”
相较于基本内置类型,C++中的自定义类型的尺寸大小,与程序员如何定义一个类或者结构体息息相关(参考【深度C++】之“类型与变量”)。
当一个类或结构体被实例化时,编译器需要在堆栈内存中开辟空间,容纳该类的实例。
本质上来说,一个类或结构体就是其他数据类型及操作的包裹,所以不难得出一个类或结构体的大小与类这些成员相关。
类或结构体的成员包括5部分:
- 类型成员
- 数据成员
- 函数成员
- 静态成员:静态成员变量、静态成员函数
- 模板成员(函数)
类型成员仅仅是一种形式的声明,编译之后都会被替换。
对于函数成员和模板成员来说,他们被统一的放在代码区,通过隐式this指针区分不同实例。因此在内存中:
一个类的大小并不包含成员函数。
对于静态成员来说,静态成员变量存储在静态全局区,静态成员函数与成员函数只有一个this指针的区别,也存放在代码区。因此在内存中:
一个类的大小并不包含静态成员。
综上,一个类的大小,仅计算其数据成员即可,我们通常也叫成员变量。
但是考虑到
- 内存对齐、
- 虚函数、
- 继承
相关内容,计算一个类的大小并非成员变量的简单累加。因此,类的实例在内存中是存在一个排布优良的结构,因此称为“类内存布局”。
验证内存布局的情况,可以使用sizeof运算符。内存的布局,多半和编译器的实现有关,尤其是有关虚表的内容,C++标准没有规范,但使用虚表已经成了业界认可,面试官也会经常提问相关的问题。
不同编译器在实现下述内容可能存在差异,为了论述方便,以下所有结论和代码实例均在如下平台上得出:
- 操作系统:MacOS 10.15.5, 64bit
- 编译器:Apple clang version 11.0.3 (clang-1103.0.32.29)
- IDE:CLion 2020, built on April 9, 2020
1. 内存对齐
现代计算机在处理数据时,按照某个“单位”来处理。32位机器,每次处理32位、4字节的二进制数据,64位同理。
内存对齐指的是计算机系统对基本数据类型合法地址做出了一些限制,要求某种类型对象的地址必须是某个值的倍数。
读者只需知道sizeof
作用于结构体或类的时候,得出的结果并不是简单的将所有非静态数据成员的字节数累加在一起即可,详细内容,请参考【深度C++】之“内存对齐”。
该篇文章只讨论了最简单的一个类的情况,没有考虑虚函数和继承。但是讨论这两个内容,需要先明白在这两种情况下,类是如何布局的。
为了关注问题本身,第2节和第3节的内容,将使用简单的成员变量抵消内存对齐带来的干扰。因此有关虚函数和继承的内存对齐方式,我们将在本文末尾讨论。
2. 虚函数
在C++中,通过关键字virtual
,在基类中将某些函数声明为虚函数,表示基类希望继承自该基类的子类定义一个适合自己的版本,覆盖掉基类的函数。
class Base {
double d0;
public:
Base() = default;
virtual void print();
};
当一个类声明了一个虚函数之后,编译器隐含的在类的起始地址处添加了一个指针(示意图见下节),指向虚函数表,简称虚表指针(vtbl_ptr)。
虚函数表是实现多态性质的重要数据结构。对于指针来说,32bit机器指针大小4byte,64bit指针大小8byte,因此使用sizeof(Base)
得到数值16
,8(vtbl_ptr) + 8(double) = 16。
虚表属于编译器的实现,C++标准并未规定如何实现多态,但是虚表的高效和简约几乎是实现多态的常见做法。
3. 继承
C++中,继承的本质是将父类包裹进子类。但是C++中允许单继承、多继承、菱形继承、虚继承等复杂形式,接下来分情况讨论。
说明:
- 接下来的结果图,通过clang的编译参数
-Xclang -fdump-record-layouts
得到类布局,通过-Xclang -fdump-vtable-layouts
得到虚表布局。 - 接下来使用关键词布局,均考虑了内存对齐的结果;
- 所有继承均为公有继承。
3.1 单继承,无虚函数
示例
class Base0 {
double b0;
public:
Base0() = default;
void print() { cout << "Base 0" << endl; }
};
class Derived0 : public Base0 {
double d0;
public:
Derived0() = default;
void print() { cout << "Derived 0" << endl; }
};
Derived0
公有继承自Base0
,Derived0
中的函数print
覆盖了Base0
中的print
,因为print
不是虚函数,因此使用Base0
的引用或指针指向Derived0
实例时,不会出现多态。
结果
下面是Derived0
的内存示意图:
解析
单继承、父类无虚函数的情况下,在父类成员变量后面衔接子类成员变量,所有变量统一布局,仿佛在一个类里一样。
这里强调统一布局的重点是在计算数据对齐的对齐参数k时需要考虑子类中的成员变量。
3.2 单继承,有虚函数
示例
class Base0 {
double b0;
public:
Base0() = default;
virtual void print() { cout << "Base 0" << endl; }
};
class Derived0 : public Base0 {
double d0;
public:
Derived0() = default;
virtual void print() { cout << "Derived 0" << endl; }
};
Derived0
公有继承自Base0
,因为print
是虚函数,因此使用Base0
的引用或指针指向Derived0
实例时,会出现多态。
结果
下面是Derived0
的内存示意图:
解析
虚函数致使基类Base
添加了一个虚表指针(vtbl_ptr),子类Derived0
继承Base
时,没有新增一个本类的vtbl_ptr(即使子类有虚函数),而是使用父类的vtbl_ptr,当然二者指向的地址已经不同。
子类重写的虚函数直接改变虚表中相关函数的地址,子类新增的虚函数直接添加在虚表末尾。
继承的Base0
也改为了primary base
,主要基类。
单继承、父类有虚函数的情况下,只有一个虚表指针,在父类成员变量后面衔接子类成员变量,所有变量统一布局。
3.3 多继承,无虚函数
示例
class Base0 {
double b0;
public:
Base0() = default;
void print0() { cout << "Base 0" << endl; }
};
class Base1 {
double b1;
public:
Base1() = default;
void print1() { cout << "Base 1" << endl; }
};
class Derived0 : public Base0, public Base1 {
double d0;
public:
Derived0() = default;
void print() { cout << "Derived 0" << endl; }
};
Derived0
公有继承自Base0
和Base1
,三个类中没有虚函数,不会出现多态。之所以将Base0
和Base1
中的print
函数区分开,是防止子类调用print
时,出现二义性问题。
结果
下面是Derived0
内存示意图:
解析
子类Derived0
中同时包裹了两个父类。
多继承,无虚函数情况下,按照类派生列表声明的顺序,从父类到子类衔接各个成员变量,所有变量统一布局。
3.4 多继承,有虚函数
示例
class Base0 {
double b0;
public:
Base0() = default;
virtual void print0() { cout << "Base 0" << endl; }
};
class Base1 {
double b1;
public:
Base1() = default;
virtual void print1() { cout << "Base 1" << endl; }
};
class Derived0 : public Base0, public Base1 {
double d0;
public:
Derived0() = default;
virtual void print() { cout << "Derived 0" << endl; }
};
Derived0
公有继承自Base0
和Base1
,三个类中均出现了虚函数。
结果
下面是Derived0
的内存示意图:
解析
类派生列表中第一个类成为主要基类,子类的虚表指针使用主要基类的虚表指针,并在其指向的子类的虚表后面添加子类的虚函数。
多继承,有虚函数情况下,类派生列表中第一个类成为主要基类,按照类派生列表声明的顺序,从父类到子类衔接各个成员变量,所有变量统一布局。
3.5 菱形继承,有虚函数
示例
class Base0 {
double b0;
public:
Base0() = default;
virtual void print0() { cout << "Base 0" << endl; }
};
class Derived0 : public Base0 {
double d0;
public:
Derived0() = default;
virtual void printd0() { cout << "Derived 0" << endl; }
};
class Derived1 : public Base0 {
double d1;
public:
Derived1() = default;
virtual void printd1() { cout << "Derived 1" << endl; }
};
class Derived2 : public Derived0, public Derived1 {
double d2;
public:
Derived2() = default;
virtual void printd2() { cout << "Derived 2" << endl; }
};
继承关系如图所示:
结果
下面是Derived2
的内存示意图:
解析
可以看到,Derived2
出现了两次Base0
,这也是我们所说的二义性问题,当我们想访问Base0
中的成员时,程序不知道应该访问Derived0
中的Base0
还是Derived1
中的Base0
。
菱形继承,有虚函数情况下,类派生列表中第一个类成为主要基类,按照类派生列表声明的顺序,从父类到子类衔接各个成员变量,所有变量统一布局,公共父类会出现两次。
3.6 菱形继承,虚继承
示例
class Base0 {
double b0;
public:
Base0() = default;
virtual void print0() { cout << "Base 0" << endl; }
};
class Derived0 : public virtual Base0 {
double d0;
public:
Derived0() = default;
virtual void printd0() { cout << "Derived 0" << endl; }
};
class Derived1 : public virtual Base0 {
double d1;
public:
Derived1() = default;
virtual void printd1() { cout << "Derived 1" << endl; }
};
class Derived2 : public Derived0, public Derived1 {
double d2;
public:
Derived2() = default;
virtual void printd2() { cout << "Derived 2" << endl; }
};
继承关系和3.5一样。
结果
(虚表结构过于冗长,此处不再赘述)
下面是Derived2
的内存示意图:
解析
虚继承情况下,子类并没有沿用父类的虚表指针,而是自己新建立了一个。虽然减少了Derived2
中Base0
的数量,取消了二义性问题,但是增加很多虚表指针。
菱形继承下,Derived2
将沿用主要基类的虚表指针,即Derived0 vtable pointer
。
菱形继承,虚继承情况下,首先建立子类自己的虚表指针,排布子类的内容,最后排布父类的内容;多继承情况下,类派生列表中第一个类成为主要基类,按照类派生列表声明的顺序,从直接父类到子类衔接各个成员变量,子类使用主要基类的虚表指针,最后排布公共父类的内容,所有变量统一布局,公共父类只出现一次。
4. 复杂情况下的内存对齐
4.1 虚表指针
虚表指针只出现在有虚函数的类的起始地址处,虚继承和多继承情况下有多个。在计算对齐参数k时,要考虑虚表指针的大小。
4.2 继承
普通继承情况下,考虑父类和子类的所有成员变量及虚表指针得到对齐参数k,按照类派生列表中声明的顺序,依次衔接各个成员变量,子类的虚表指针使用主要基类的虚表指针,不另添加。菱形继承情况下,公共父类有多个。
虚继承情况下,考虑父类和子类的所有成员变量及虚表指针得到对齐参数k,子类使用自己的虚表指针并放在起始地址,先排版子类成员变量,再排版父类的成员变量。出现菱形继承情况下,公共父类只有一个。
5. 总结
C++中的类内存布局,是编译器排版类内成员变量的一种算法,要考虑数据对齐、虚函数和各种继承方案情况下不同的排版方式。