数据语意

本文测试代码的环境为:x86-64,ubuntu22.04,gcc7.5.0

一、缘起

先使用一段代码引入本文所要探讨的主题。

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 X {};
class Y : public virtual X {};
class Z : public virtual X {};
class A : public Y, public Z {};

int main() {
std::cout << "sizeof X: " << sizeof(X) << std::endl;
std::cout << "sizeof Y: " << sizeof(Y) << std::endl;
std::cout << "sizeof Z: " << sizeof(Z) << std::endl;
std::cout << "sizeof A: " << sizeof(A) << std::endl;

X x1, x2;
std::cout << &x1 << " " << &x2 << std::endl;

Y y1, y2;
std::cout << &y1 << " " << &y2 << std::endl;

Z z1, z2;
std::cout << &z1 << " " << &z2 << std::endl;

A a1, a2;
std::cout << &a1 << " " << &a2 << std::endl;
return 0;
}

# g++ main.cpp -g -o main
# ./main
sizeof X: 1
sizeof Y: 8
sizeof Z: 8
sizeof A: 16
0x7fffb23046fe 0x7fffb23046ff
0x7fffb2304700 0x7fffb2304708
0x7fffb2304710 0x7fffb2304718
0x7fffb2304720 0x7fffb2304730

如上的代码也是虚继承解决菱形继承中命名冲突和冗数据的问题。虚继承的目的是让某个类做出声明,承诺愿意共享他的基类。这个被共享的基类就称为虚基类。

在这种机制下,无论虚基类在继承体系中出现了多少次,在派生类中都只包含一份虚基类的成员。

虚派生只影响从指定了虚基类的派生类中进一步派生出来的类,他不会影响派生类本身。

1. 空类占用的空间

首先我们发现一个空的类 X。他占用的空间为 1 字节。

为什么呢?对于空类,编译器会安插一个 char。让这个类对象能有一个地址,并且可以让这个类的两个对象在内存中有不同的地址

2. 继承类占用的空间

我们看到类 Y 和 类 Z 占用的空间大小都为 8 字节。我们来分析为什么是 8 字节。

首先,这个大小是与机器、编译器有关。我们是 64 位机器,并且编译器是 gcc7.5.0 的。也就是指针默认是 8 字节。除此之外,可能还会受到如下三种因素的影响。注意,我说的是可能。

  • C++ 语言支持 虚拟继承 时,子类中会有一个指向虚基类实例的指针,64 位机器,指针占用 8 字节
  • 编译器会对空类安插一个字节,如上所示。
  • 内存对齐的限制,C++ 的类也是一个结构体,需要内存对齐。64 位机器上对齐数默认是 8 字节。

我列了如上三种可能,然后如下图是类 X、Y、Z 的对象布局。

我们可以看到在类 Y 和 类 Z 中,已经有了一个指向虚基类实例的指针了,编译器就不再需要为了区别空类而安插一个字节了。并且指针是 8 字节,内存也是对齐的,不需要额外的内存对齐了。

我们可以通过 gdb 简单看到这些信息

1
2
3
4
5
6
7
8
(gdb) p y1
$1 = {<X> = {<No data fields>}, _vptr.Y = 0x555555557cd8 <VTT for Y>}
(gdb) p y2
$2 = {<X> = {<No data fields>}, _vptr.Y = 0x555555557cd8 <VTT for Y>}
(gdb) p z1
$3 = {<X> = {<No data fields>}, _vptr.Z = 0x555555557cb8 <VTT for Z>}
(gdb) p z2
$4 = {<X> = {<No data fields>}, _vptr.Z = 0x555555557cb8 <VTT for Z>}

可以看到,类 Y 和 类 Z 的对象中有对应的指针。所以类 Y 和类 Z 对象占用的空间大小为 8 字节。

3. 多继承的情况

首先无论虚基类在继承体系中出现了多少次,在派生类中都只包含一份虚基类的成员。

再来看看 类 A 的情况,类 A 对象的大小由下列几点决定:

  • 被大家共享的唯一一个 类 X 的实例,大小为 1 字节
  • 类 Y 和 类 Z 的大小
  • 内存对齐所需要的填充大小

同理,因为类 A 继承了类 Y 和类 Z,所以类 A 对象不再需要为了区别空类而安插一个字节了。同时,也不用管被大家共享的唯一一个类 X 实例的大小了。

主要需要考虑的就是继承过来的类 Y 和 类 Z 的指向虚基类实例的指针。通过 gdb 查看如下

1
2
3
4
(gdb) p a1
$5 = {<Y> = {<X> = {<No data fields>}, _vptr.Y = 0x555555557c38 <vtable for A+24>}, <Z> = {_vptr.Z = 0x555555557c50 <VTT for A>}, <No data fields>}
(gdb) p a2
$6 = {<Y> = {<X> = {<No data fields>}, _vptr.Y = 0x555555557c38 <vtable for A+24>}, <Z> = {_vptr.Z = 0x555555557c50 <VTT for A>}, <No data fields>}

所以,类 A 对象占用的空间大小为 16 字节。

二、类成员数据的布局

假如我们有这样一个类:

1
2
3
4
5
6
7
8
9
10
class A {
public:
float x;
static int y;
private:
double z;
static char m;
public:
char* n;
};
  • C++ 标准只要求,在同一个访问权限区段中(也就是 private、public、protected 等区段),成员的排列只需符合“较晚出现的成员在类对象中有较高的地址” 这一个条件即可。也就是说,各个成员并不一定得连续排列
  • 一般由于内存对齐,数据成员之间的边界可能会填补一些字节
  • 数据成员的排列和访问权限区段(private、public、protected)没有关系。一般编译器会按照声明的顺序进行排列
  • 编译器也有可能合成一些内部使用的数据成员,比如虚表指针等。至于虚表指针放在哪里,C++ 标准并未规定,由编译器决定。传统上他被放在所有显式声明的数据成员的最后。

三、继承与数据成员

在 C++ 标准中,对于继承,继承类成员和基类成员的排列顺序并未强制规定,编译器可以自由安排。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A {
private:
int val{1};
char c1{2};
};

class B : public A {
private:
char c2{3};
};

class C : public B {
private:
char c3{4};
};

如上的代码,在没有多态的情况下,也就是没有虚函数的时候,派生类就只是拿到了基类的数据成员。如 gdb 所示:

1
2
3
4
(gdb) p &c
$1 = (C *) 0x7fffffffde70
(gdb) x /8xb 0x7fffffffde70
0x7fffffffde70: 0x01 0x00 0x00 0x00 0x02 0x03 0x04 0x00

如上可以看到,类 A、B、C 占用都为 8 字节。编译器对内存布局是比较紧密的。

1. 多态下的类数据成员

我们再来看看多态下的类对象的变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class X {
public:
virtual float get_a() { return a; }

protected:
float a{0}, b{0};
};

class Y : public X {
protected:
float c{0};
};

int main() {
X x;
Y y;
std::cout << sizeof(X) << " " << sizeof(Y) << std::endl; // 16 24
return 0;
}

当加入虚函数的时候,我们的类会发生如下的几点变化,可能会带来空间和存取时间上的额外负担。

  • 类 X 会产生一个虚表(virtual table),用来存放他所声明的每一个虚函数的地址。这个虚表中元素个数一般就是虚函数的个数,再加上一个或两个 slots(用于支持 runtime type identification
  • 在每一个类 X 的对象中会导入一个虚表指针(vptr),提供执行期的链接,使每一个对象能够找到相应的虚表
  • 会优化构造函数,使其能够为 vptr 设定初值,让他指向类所对应的虚表。这意味着,派生类和每一个基类的构造函数中,都会重新设定 vptr 的值。
  • 会优化析构函数,使其能够析构掉 vptr。析构的调用顺序是反向的,从派生类到基类

我们通过 gdb 可以看到对象的结构:

1
2
3
4
(gdb) p x
$1 = {_vptr.X = 0x555555557d50 <vtable for X+16>, a = 0, b = 0}
(gdb) p y
$2 = {<X> = {_vptr.X = 0x555555557d38 <vtable for Y+16>, a = 0, b = 0}, c = 0}

如果基类对象中有 vptr,那么派生类中也会有 vptr。用于指向不同的虚表。