0%

C++ 对象模型实战(一):对象布局、对齐与 sizeof 的三条规则

为什么从这个话题开始

最近开始系统补 C++ 对象模型,这是面试高频内容,也是后面学虚函数、继承、用调试器看内存的基础。第一步先搞清楚最基本的问题:一个对象在内存里到底长什么样。

我写了几个结构体做实验,总结下来核心就是三条规则,sizeof 计算题基本都能从这三条推出来。

写代码打印验证:用 sizeof / offsetof 看编译器给的答案,再用运行时打印的真实地址对一遍。两边对上了,才算真的理解。

三条规则

规则 1:成员按声明顺序排列,编译器不会替你重排。
每个成员的偏移地址必须是它自身对齐值的倍数——int 对齐 4,short 对齐 2,char 对齐 1。不满足就补 padding(填充字节)。

规则 2:结构体总大小向上取整到「最大成员对齐值」的倍数。
这是为了保证数组 A arr[10] 里每个元素都天然对齐。

规则 3:空类至少占 1 字节。
C++ 要求每个可寻址对象有独一无二的地址。空类如果是 0 字节,数组里两个元素的地址就相同了,矛盾。所以编译器硬塞 1 字节。但它作为基类时有例外——空基类优化(EBO):基类子对象可以不要独立地址,这 1 字节被优化掉。

验证工程

四个结构体,把上面三条规则一次验证完:

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
36
37
38
#include <cstdio>
#include <cstddef>

struct A { // 声明顺序决定内存顺序, 编译器不重排.
char c; // offset 0, 占 1 字节.
int i; // 需 4 对齐, 前面补 3 字节 padding.
short s; // offset 8, 结尾再补 2 字节凑齐对齐倍数.
};

struct B { // 与 A 成员相同, 按对齐值从大到小排列, padding 大幅减少.
int i; // offset 0, 占 4 字节, 天然对齐.
short s; // offset 4, 2 的倍数, 无需 padding.
char c; // offset 6, 1 的倍数, 无需 padding.
}; // 尾部补 1 字节凑齐 4 的倍数, sizeof(B) = 8, 比 A 省 4 字节.

struct Empty {}; // 空类必须可寻址, 编译器强制占 1 字节.

struct WithEmpty : Empty { // 空基类优化: 基类子对象不再占那 1 字节.
int i;
};

static_assert(sizeof(A) == 12, "A: 1 + 3 pad + 4 + 2 + 2 pad");
static_assert(sizeof(B) == 8, "B: 4 + 2 + 1 + 1 pad");
static_assert(sizeof(Empty) == 1, "empty class needs an address");
static_assert(sizeof(WithEmpty) == 4, "empty base optimized away");

int main() {
printf("A: sizeof=%2zu alignof=%zu c@%zu i@%zu s@%zu\n",
sizeof(A), alignof(A), offsetof(A, c), offsetof(A, i), offsetof(A, s));
printf("B: sizeof=%2zu alignof=%zu i@%zu s@%zu c@%zu\n",
sizeof(B), alignof(B), offsetof(B, i), offsetof(B, s), offsetof(B, c));
printf("Empty: sizeof=%2zu\n", sizeof(Empty));
printf("WithEmpty: sizeof=%2zu (empty base optimization)\n", sizeof(WithEmpty));

A a{};
printf("addresses: &a=%p &c=%p &i=%p &s=%p\n",
(void*)&a, (void*)&a.c, (void*)&a.i, (void*)&a.s);
}

四条 static_assert 是编译期自检:布局一旦被改(换编译器、换平台、动结构体),编译直接报错,不用等运行才发现。

运行结果与解读

在 Visual Studio 2022 x64 Debug 下运行,输出如下:

1
2
3
4
5
A:         sizeof=12  alignof=4  c@0 i@4 s@8
B: sizeof= 8 alignof=4 i@0 s@4 c@6
Empty: sizeof= 1
WithEmpty: sizeof= 4 (empty base optimization)
addresses: &a=000000D0340FF568 &c=000000D0340FF568 &i=000000D0340FF56C &s=000000D0340FF570

c@0 i@4 s@8 是成员的偏移量——从对象起始地址数,第几个字节是这个成员。A 的 12 字节内存地图画出来是这样:

1
2
偏移:  0    1 2 3      4 5 6 7    8 9    10 11
[c] [padding] [ i ] [ s ] [padding]
  • c@0:char 紧挨对象开头,所以 &a == &a.c(输出里两个都是 ...F568)。
  • i@4:int 要求偏移是 4 的倍数,偏移 1~3 只能空着,这就是 padding。
  • s@8:short 要求 2 的倍数,偏移 8 恰好满足,直接放。

再看最后一行的真实地址:&i - &c = F56C - F568 = 4,&s - &i = F570 - F56C = 4,和 offsetof 的结果一致。编译期算出来的布局和运行时看到的地址对得上,说明这套规则就是编译器实际在用的规则,不是纸面理论。

另外注意 A 和 B:成员完全相同,只是顺序不同,sizeof 从 12 降到 8。这就是「成员按从大到小排列省空间」说法的来源,面试里 sizeof 计算题考的也就是这个。

padding 存在的理由

padding 是编译器为满足对齐要求插入的空白字节,不属于任何成员,但实实在在占着 sizeof。为什么要对齐?这是 CPU 的硬件特性决定的:

  • x64 上 CPU 一次从内存取 8 字节。一个 4 字节的 int 落在天然边界(地址是 4 的倍数)上一次取到;跨边界就要取两次再拼接,变慢。
  • 部分架构(老 ARM、SPARC)访问未对齐数据直接硬件异常。x86/x64 只是变慢,属于「能跑但亏」。

所以编译器的做法是:牺牲少量空间,换每个成员都落在天然边界上。

让编译器自己画出布局图

除了运行时打印,MSVC 还能直接输出编译期确定的布局。在项目属性 → C/C++ → 命令行加上:

1
/d1 reportAllClassLayout

重新编译,输出窗口里就有每个类的完整布局报告。本工程实际打印如下(去掉了行首的生成前缀):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class A    size(12):
+---
0 | c
| <alignment member> (size=3)
4 | i
8 | s
| <alignment member> (size=2)
+---
class B size(8):
+---
0 | i
4 | s
6 | c
| <alignment member> (size=1)
+---
class Empty size(1):
+---
+---
class WithEmpty size(4):
+---
0 | +--- (base class Empty)
| +---
0 | i
+---

这份报告可以对照前面的结论看:

  • A 里两处 <alignment member>(3 字节和 2 字节)就是编译器补的 padding,报告里标得清清楚楚。
  • B 只有尾部 1 字节 padding,和「从大到小排列省空间」对应。
  • WithEmpty 最值得注意:基类 Empty 和成员 i 同在偏移 0。空基类不独占任何字节,EBO 在这个报告里是能直接看到的。

小结

1
2
3
4
对象布局三规则
├── 成员按声明顺序排列, 各自对齐, 不满补 padding
├── 总大小取整到最大对齐值的倍数(为了数组)
└── 空类 1 字节(为了可寻址), 作基类时可被 EBO 优化掉