为什么从这个话题开始
最近开始系统补 C++ 对象模型,这是面试高频内容,也是后面学虚函数、继承、用调试器看内存的基础。第一步先搞清楚最基本的问题:一个对象在内存里到底长什么样。
我写了几个结构体做实验,总结下来核心就是三条规则,sizeof 计算题基本都能从这三条推出来。
写代码打印验证:用 sizeof / offsetof 看编译器给的答案,再用运行时打印的真实地址对一遍。两边对上了,才算真的理解。
三条规则
规则 1:成员按声明顺序排列,编译器不会替你重排。
每个成员的偏移地址必须是它自身对齐值的倍数——int 对齐 4,short 对齐 2,char 对齐 1。不满足就补 padding(填充字节)。
规则 2:结构体总大小向上取整到「最大成员对齐值」的倍数。
这是为了保证数组 A arr[10] 里每个元素都天然对齐。
规则 3:空类至少占 1 字节。
C++ 要求每个可寻址对象有独一无二的地址。空类如果是 0 字节,数组里两个元素的地址就相同了,矛盾。所以编译器硬塞 1 字节。但它作为基类时有例外——空基类优化(EBO):基类子对象可以不要独立地址,这 1 字节被优化掉。
验证工程
四个结构体,把上面三条规则一次验证完:
1 |
|
四条 static_assert 是编译期自检:布局一旦被改(换编译器、换平台、动结构体),编译直接报错,不用等运行才发现。
运行结果与解读
在 Visual Studio 2022 x64 Debug 下运行,输出如下:
1 | A: sizeof=12 alignof=4 c@0 i@4 s@8 |
c@0 i@4 s@8 是成员的偏移量——从对象起始地址数,第几个字节是这个成员。A 的 12 字节内存地图画出来是这样:
1 | 偏移: 0 1 2 3 4 5 6 7 8 9 10 11 |
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 | class A size(12): |
这份报告可以对照前面的结论看:
- A 里两处
<alignment member>(3 字节和 2 字节)就是编译器补的 padding,报告里标得清清楚楚。 - B 只有尾部 1 字节 padding,和「从大到小排列省空间」对应。
- WithEmpty 最值得注意:基类 Empty 和成员 i 同在偏移 0。空基类不独占任何字节,EBO 在这个报告里是能直接看到的。
小结
1 | 对象布局三规则 |