空基类优化(EBO)与 [[no_unique_address]]:零开销抽象的实践
C++ 中每个完整对象至少占 1 字节存储,即使它的类型没有任何非静态数据成员。这条规则的存在是为了保证不同对象拥有不同地址。但当空类被用作基类时,编译器可以把该基类子对象优化为零字节——这就是空基类优化(Empty Base Optimization, EBO),也是语言中最古老的零开销抽象机制之一。
C++20 通过 [[no_unique_address]] 属性把同样的思路扩展到了数据成员。两者配合,消除了无状态策略类、分配器等空类型在标准库中无处不在的存储开销。
一字节下限
标准要求:
任何对象或成员子对象的大小至少为 1。
struct Empty {};
static_assert(sizeof(Empty) >= 1); // 始终成立
原因是地址同一性。在这个数组中,每个元素必须有独立地址:
Empty arr[10];
static_assert(&arr[0] != &arr[1]); // 语言保证
如果 Empty 大小为零,所有元素会共享同一个地址,指针运算和比较都会崩溃。
EBO:零字节的基类子对象
基类子对象不受 1 字节下限约束。当空类被继承时,编译器可以将基类子对象的大小优化为零:
struct Empty {};
struct Derived : Empty {
int i;
};
static_assert(sizeof(Derived) == sizeof(int)); // EBO:Empty 不占空间
没有 EBO 的话,sizeof(Derived) 会是 sizeof(int) + 1 再加填充——64 位平台上通常是 8 字节而非 4。基类子对象在对象表示中根本不存在。
这不仅是编译器的优化——对于标准布局类型,标准要求 EBO(详见下文)。
EBO 何时不能生效
当空基类同时也是第一个非静态数据成员的类型(或该类型的基类)时,EBO 被禁止。原因同样是地址唯一性:同一最派生类型中的两个同类型子对象必须有不同地址。
首成员与基类同类型
struct Empty {};
struct NoEbo : Empty {
Empty c; // 首成员与基类同类型
int i;
};
// EBO 不能生效:基类占 1 字节,成员 c 占 1 字节,
// 加上 int 对齐填充
static_assert(sizeof(NoEbo) == 2 * sizeof(int)); // 通常 8
基类子对象和成员 c 都是 Empty 类型。如果 EBO 生效,两者都占零字节、共享同一地址——违反同类型子对象地址必须不同的规则。
首成员的基类与当前基类相同
struct Derived1 : Empty { int i; }; // EBO 在这里生效
struct NoEbo2 : Empty {
Derived1 c; // Derived1 继承自 Empty
int i;
};
// EBO 不能生效:Empty 基类至少占 1 字节,
// 避免与 c 内部的 Empty 子对象地址重合
static_assert(sizeof(NoEbo2) == 3 * sizeof(int)); // 通常 12
NoEbo2 的 Empty 基类和 Derived1 c 内部的 Empty 基类会在 EBO 生效时地址冲突。
调整成员顺序作为变通
如果把空类型成员移到非首位,基类就可以享受 EBO:
struct EboWorkaround : Empty {
int i;
Empty c; // 不是首成员——基类可以应用 EBO
};
static_assert(sizeof(EboWorkaround) == sizeof(int) + 1);
// 加填充后通常 8 字节(与 NoEbo 实际相同,
// 但基类本身是零大小的)
基类子对象现在是零字节,但 c 仍占 1 字节。由于填充,总大小可能相同,但布局在语义上不同。
标准布局类型
自 C++11 起,标准布局类型必须满足 EBO。这不是优化——是正确性要求。标准布局保证:指向对象的指针经 reinterpret_cast 转换后,指向其第一个非静态数据成员:
struct S { int x; };
static_assert(reinterpret_cast<char*>(&s) == reinterpret_cast<char*>(&s.x));
为了让这个保证在类型拥有空基类时依然成立,那些基类不能在第一个数据成员之前占据任何存储。这就是为什么标准布局规则包括:
- 所有非静态数据成员必须声明在同一个类中(不能分散在基类和派生类中)。
- 没有基类的类型与第一个非静态数据成员的类型相同。
这些规则的存在正是为了避免 EBO 不生效时会导致的地址冲突,从而破坏 reinterpret_cast 保证。
[[no_unique_address]](C++20)
EBO 只能通过继承起作用。如果空类型是数据成员,无论优化级别多高,它至少占 1 字节。C++20 用 [[no_unique_address]] 属性弥补了这一空白:
struct Empty {};
struct WithAttribute {
int i;
[[no_unique_address]] Empty e;
};
static_assert(sizeof(WithAttribute) == sizeof(int)); // e 占 0 字节
该属性告诉编译器:这个成员不需要唯一地址——它可以和其他成员共享地址。这就是 EBO 在数据成员上的等价物。
多个同类型空成员
当多个 [[no_unique_address]] 成员的类型相同时,它们可以共享地址:
struct Empty {};
struct Multi {
int i;
[[no_unique_address]] Empty e1;
[[no_unique_address]] Empty e2;
};
// GCC/Clang: sizeof(Multi) == sizeof(int) — e1 和 e2 共享地址
// MSVC: sizeof(Multi) == sizeof(int) + 1 — MSVC 不合并同类型成员
标准允许但不要求这种合并,这导致了实现分歧(见下文 MSVC 章节)。
不同空类型
不同空类型的成员总是可以共享地址,因为地址唯一性规则只适用于同类型子对象:
struct Empty1 {};
struct Empty2 {};
struct TwoTypes {
int i;
[[no_unique_address]] Empty1 e1;
[[no_unique_address]] Empty2 e2;
};
static_assert(sizeof(TwoTypes) == sizeof(int)); // 两个都被优化掉
compressed_pair:EBO 的实战
在 [[no_unique_address]] 出现之前,将空类型与非空类型组合的标准机制是 boost::compressed_pair。它通过条件继承来应用 EBO:
简化实现
template<typename T1, typename T2, bool = std::is_empty_v<T1>>
struct compressed_pair;
// 两个类型都为空:同时继承两者
template<typename T1, typename T2>
struct compressed_pair<T1, T2, true> : T1, T2 {
// T1 和 T2 各占 0 字节(EBO)
};
// 仅 T1 为空:继承 T1,存储 T2 为成员
template<typename T1, typename T2>
struct compressed_pair<T1, T2, false> {
T2 second_;
// T1 通过继承 EBO 占 0 字节
};
真实实现更复杂(处理同类型对、引用类型等),但核心思路很简单:空类型用继承获得 EBO,非空类型存为成员。
优化前后对比
struct StatelessAlloc {};
// 朴素方案:pair 把两者都存为成员
struct NaiveVector {
int* begin_;
int* end_;
int* capacity_;
StatelessAlloc alloc_; // 1 字节 + 7 字节填充
};
static_assert(sizeof(NaiveVector) == 32); // 3 个指针 + 1 字节 + 填充
// 优化方案:compressed_pair 对分配器应用 EBO
struct OptimizedVector : private StatelessAlloc {
int* begin_;
int* end_;
int* capacity_;
// StatelessAlloc 通过 EBO 占 0 字节
};
static_assert(sizeof(OptimizedVector) == 24); // 仅 3 个指针
每个 vector 实例节省 8 字节。对于容器的容器(std::vector<std::vector<int>>),这个节省会累积。
STL 容器与分配器开销
标准库在分配器感知容器中广泛使用 EBO。分配器作为内部结构(持有指针成员)的基类存储,使得无状态分配器不增加任何开销:
| 类型 | 无状态分配器 | 无 EBO |
|---|---|---|
std::vector<int> | 24 字节(3 个指针) | 32 字节(3 个指针 + 1 字节 + 填充) |
std::shared_ptr<int> | 16 字节(2 个指针) | 24 字节(2 个指针 + 1 字节 + 填充) |
std::function<void()> | 32 字节(vtable + 捕获) | 40 字节(+ 分配器开销) |
以上数据为 64 位 Linux + GCC 环境。「无 EBO」列展示的是分配器作为普通数据成员时的大小。
std::function 细节
std::function 通常存储一个函数指针或一个用于捕获状态的小缓冲区,加上一个用于类型擦除的 vtable 指针。分配器(无状态时)通过 EBO 存储在内部基类中,不占字节。
std::shared_ptr 细节
std::shared_ptr 持有两个指针:被管理对象指针和控制块指针。控制块的分配器存储在控制块内部(已在堆上分配),所以栈上的 shared_ptr 对象本身不携带分配器开销。但控制块内部对分配器和删除器使用了 EBO。
MSVC:_Empty_base 的变通
历史上,MSVC 的 EBO 实现不符合标准。一个众所周知的问题是 MSVC 可能在标准要求 EBO 的场景(标准布局类型)中也无法应用 EBO。
变通方案
MSVC 标准库使用了内部的 _Empty_base 包装:
// MSVC 内部变通(简化)
struct _Empty_base {};
template<typename T>
struct _Wrap_base : _Empty_base {
T value;
};
// 而不是:struct Foo : SomeEmptyType { int x; };
// MSVC 用:struct Foo : _Wrap_base<int> { /* ... */ };
通过插入 _Empty_base 作为中间基类,MSVC 标准库避开了 EBO 在该编译器上会失败的场景。这是一个实现细节,但它解释了为什么 sizeof 结果在 MSVC 和 GCC/Clang 之间可能不同。
MSVC 上的 [[no_unique_address]]
截至 Visual Studio 2022(MSVC v19.3+),[[no_unique_address]] 已被支持,但有一个已知偏差:当多个 [[no_unique_address]] 成员具有相同类型时,MSVC 给每个成员分配独立地址(各占 1 字节),而 GCC 和 Clang 允许它们共享地址:
struct Empty {};
struct Test {
[[no_unique_address]] Empty e1;
[[no_unique_address]] Empty e2;
int i;
};
// GCC/Clang: sizeof(Test) == 4
// MSVC: sizeof(Test) == 8(每个 Empty 各占 1 字节 + 填充)
这是一个一致性问题——标准允许但不要求地址共享,所以 MSVC 的行为合法但不够优化。变通方法是为每个成员使用不同的空类型。
EBO vs [[no_unique_address]]:何时用哪个
| 场景 | 机制 | 结果 |
|---|---|---|
| 空类型作为基类 | EBO(自动) | 零字节,标准 C++98 |
| 空类型作为数据成员(C++20 前) | 继承 + compressed_pair 的 EBO | 零字节,需要包装 |
| 空类型作为数据成员(C++20 起) | [[no_unique_address]] | 零字节,无需包装 |
| 多个同类型空成员(C++20 起) | [[no_unique_address]] | 0 字节(GCC/Clang)或各 1 字节(MSVC) |
| 多个不同类型空成员(C++20 起) | [[no_unique_address]] | 零字节,所有编译器 |
经验法则:如果你能控制类型并可以使用继承,EBO 在任何地方都能工作。如果需要数据成员,使用 [[no_unique_address]](C++20)。如果需要在 C++20 前兼容数据成员场景,使用 compressed_pair 或继承技巧。
实践指南
-
策略类应该为空。 如果策略类(自定义分配器、比较器、特征类)没有状态,确保它没有非静态数据成员。这样 EBO 或
[[no_unique_address]]就能消除其存储开销。 -
避免首成员与基类同类型。 如果你必须继承一个空类型并且同时将它存为成员,把空类型成员声明在所有非空成员之后。
-
C++20 中优先用
[[no_unique_address]]而非compressed_pair。 属性更清晰,不需要条件继承层次。 -
在所有目标编译器上测试
sizeof。 EBO 和[[no_unique_address]]有实现相关的行为。用static_assert检查预期大小是捕获回归的可移植方式:
struct MyContainer : private MyAllocator { // 空分配器
int* data_;
size_t size_;
size_t capacity_;
};
static_assert(sizeof(MyContainer) == 3 * sizeof(void*),
"EBO 应消除分配器开销");
- 用
std::is_empty_v<T>守卫 EBO 相关代码。 这个类型特征检测类型是否为空(无非静态数据成员且无虚函数),允许编译期分派:
template<typename Alloc>
class container : std::conditional_t<std::is_empty_v<Alloc>, Alloc, detail::alloc_holder<Alloc>> {
// ...
};
参考
- cppreference, Empty Base Optimization: https://en.cppreference.com/w/cpp/language/ebo
- cppreference,
[[no_unique_address]]: https://en.cppreference.com/w/cpp/language/attributes/no_unique_address - Boost compressed_pair: https://www.boost.org/doc/libs/release/libs/utility/doc/html/compressed_pair.html
- Stack Overflow, “Why is EBO not working in MSVC?”: https://stackoverflow.com/questions/12701469