我第一次看到这句输出。
是很多年前。
一个老同事把打印丢给我。
他说。
“你看,C++ 连空都不让你空。”
#include <iostream>
struct Empty {};
int main() {
std::cout << sizeof(Empty) << "\n";
}
屏幕回了个 1。
我那时也跟着不服。
一肚子 OS。
“你这不纯浪费吗。”
后来写久了。
我反而开始觉得。
这 1 个字节。
挺讲道理。
它不是给数据用的。
它是给“身份”用的。
在 C++ 里,对象不是一段抽象概念。
对象是一段能被取地址的存储。
0 字节会把世界弄乱
你可以把 Empty 当成“没有字段的盒子”。
但标准不让这个盒子变成 0。
原因很朴素。
你总得能分清“这是 A”还是“这是 B”。
先看这个。
struct Empty {};
int main() {
Empty a;
Empty b;
}
如果 sizeof(Empty) 是 0。
那 a 和 b 在内存里就没法“占位置”。
没位置。
就没地址。
或者更可怕。
两个对象会落在同一个地址上。
你拿指针做比较。
就会得到一堆鬼故事。
再看数组。
struct Empty {};
int main() {
Empty arr[2];
auto p0 = &arr[0];
auto p1 = &arr[1];
}
如果元素大小是 0。
那 p0 和 p1 会相等。
数组下标突然失去意义。
指针算术也失去意义。
这不是“实现细节”。
这是 C/C++ 这套指针模型的地基。
所以标准干脆给了个硬规矩。
空类型也要有非零大小。
通常就是 1。
不多。
但足够让每个对象有自己的一块“名牌”。
这 1 个字节,其实是你买的保险
很多新同学会问。
“我又不取地址,凭什么让我付这 1 个字节?”
这句话听起来合理。
但 C++ 的哲学是。
你可以不取。
但语言得允许你取。
你写 &obj 的那一刻。
编译器必须给得出一个合法指针。
这就是对象模型里很重要的一点。
对象的“存在”。
不是看你有没有字段。
是看它是不是一个可以被引用、被传递、被放进数组的实体。
那 1 个字节。
就是让这件事能成立的最低成本。
然后我们开始嫌它贵
故事没结束。
C++ 程序员很快就学会了另一件事。
你能不能把“身份”保留住。
但别真的占空间。
尤其是在模板开始流行之后。
大家喜欢把“策略”写成类型。
比如一个“记录日志”的策略。
它可能根本不需要成员。
struct NoLog {
void on_event() const {}
};
struct VerboseLog {
void on_event() const {
// 假装这里会打印日志
}
};
你想把它塞进业务对象里。
但你又不想为一个空策略付 1 个字节。
那时候。
老一辈的做法很直接。
不当成员。
当基类。
这就是空基类优化。
EBO。
Empty Base Optimization。
EBO:把空东西塞进“继承缝”里
看一眼这种写法。
struct NoLog {
void on_event() const {}
};
struct Job : NoLog {
int id;
};
很多主流编译器会让 sizeof(Job) 等于 sizeof(int)。
不是 8。
也不是 4 + 1 + padding。
它会把空的基类子对象。
跟派生类对象的起始地址重叠。
你可以把它理解成。
“这块空的东西,不用单独给床位。”
但注意。
EBO 不是让空基类不存在。
它只是让它不额外占字节。
你依然可以把 Job* 转成 NoLog*。
依然能调用 on_event()。
空基类子对象还是一个真实的子对象。
只是它的地址可能跟 Job 本体一样。
这就是 C++ 的老风格。
表面很优雅。
底下全是“允许重叠”。
EBO 也有脾气
最常见的坑。
是你想塞两个“同一种空东西”。
标准要求。
同一个完整对象里。
两个同类型的基类子对象。
地址得能区分开。
所以你会看到这种绕法。
struct Tag {};
struct T1 : Tag {};
struct T2 : Tag {};
struct X : T1, T2 {
int v;
};
这时候很多实现会不得不额外塞一点东西。
让 T1 和 T2 的地址别撞上。
你以为你在省。
结果还是得付。
这也提醒你。
别把 EBO 当成“永远零开销”。
它是“在规则允许的地方尽量不收费”。
继承不总是你想要的
EBO 好用。
但它有个副作用。
它逼你用继承。
很多团队对继承是有洁癖的。
不想把“一个策略对象”做成 is-a。
他们更想写 has-a。
也就是成员。
比如这样。
struct NoLog {
void on_event() const {}
};
struct Job {
NoLog log;
int id;
};
这就回到了最开始的问题。
NoLog 作为成员。
默认要占 1。
再加上对齐。
Job 往往会比你想的大一截。
很多库在 C++11/C++14 那个年代。
会用各种小技巧写“压缩 pair”。
目的就一个。
把空成员挤出去。
别在对象里浪费字节。
C++20:[[no_unique_address]] 把 EBO 送进成员里
后来标准终于给了一个正经按钮。
[[no_unique_address]]。
它说的很直白。
这个成员。
不保证有独立地址。
你别依赖它。
编译器也就有资格把它跟别的成员重叠。
你可以写成这样。
template <class T, class Policy>
struct Box {
[[no_unique_address]] Policy policy;
T value;
};
当 Policy 是空类型时。
很多实现会让 sizeof(Box<int, NoLog>) 等于 sizeof(int)。
你不必再为了省那 1 个字节去用继承。
你可以保持组合。
但拿到和 EBO 类似的空间效果。
这在写容器、写小对象、写“策略类”时很舒服。
写起来也更像人话。
但它也会咬人
[[no_unique_address]] 的代价。
是它会打破一个直觉。
成员不一定有自己独立的地址。
比如这种代码。
Box<int, NoLog> b;
auto p_policy = &b.policy;
auto p_value = &b.value;
p_policy 和 p_value 可能相等。
甚至 p_policy 可能等于 &b。
这不是 bug。
这是它的承诺。
它在告诉你。
别把成员地址当成身份。
你真需要“独立身份”。
那就别用这个属性。
或者别让这个成员是空的。
写在最后
空类的 sizeof 是 1。
不是为了折磨你。
是因为 C/C++ 的世界里。
“能取地址”这件事太基础。
基础到你以为它不值钱。
EBO 和 [[no_unique_address]] 也不是炫技。
它们是另一代工程师在现实里写出来的妥协。
一边要保留对象模型的规矩。
一边又不想为“空的策略”交房租。
所以你今天看到的这个 1。
你可以把它当成一个小标记。
提醒你。
C++ 的很多设计。
都不是因为“漂亮”。
而是因为。
当年真的有人被这些字节逼到过。