提到 C++ 空类,很多人第一反应就是“面试八股”:sizeof(Empty)等于 1、编译器会生成六个成员函数、“空类不空”。但我在实际项目里发现,这个知识点真正容易出问题的地方不在面试,而在代码里:有人把一个空类当成普通成员塞进热点结构体,结果结构体从 4 字节涨到 12 字节;有人在模板库里想做零开销抽象,却因为没吃透空基类优化,白白多了一层继承的内存成本;还有人写了个无状态比较器传给容器,以为它不占空间,结果每个节点都多出一个字节外加 padding。这篇就围绕 C++ 空类这个核心,把它的定义边界、编译器行为、内存布局规则、空基类优化、[[no_unique_address]]、工程里的真实用法和你大概率会踩的坑,一次性梳理清楚。无论你是刚准备面试的新手,还是写了几年 C++、想确认自己对对象模型理解深度的人,都能从这里拿到可以直接复现的代码和结论。
1. 一道面试题背后的三类人
1.1 面试现场:从“1”说起的三种回答
“定义一个空类,sizeof是多少?”这道题几乎是 C++ 面试的基本门槛,但我见过的回答质量差异极大。第一类回答只说“1”,然后就没词了;第二类能补一句“因为要给对象分配一个唯一地址”,这已经及格;第三类会顺着往下讲:为什么不能是 0、这个字节在派生类和成员位置上会怎么变化、编译器怎么通过空基类优化把它省掉、什么情况下省不掉。面试官问这道题,从来不是想知道sizeof的数值,而是在确认你有没有真正理解对象同一性和内存布局这两件事。
数值本身很好查:class Empty {};在主流编译器上sizeof(Empty) == 1。难的是说清楚这个 1 是怎么来的。C++ 标准里并没有一句“空类大小为 1”的明文,它是由几条规则推导出来的:一个完整对象的sizeof至少为 1;同一类型的两个不同对象必须有不同的地址;数组元素必须能通过指针算术区分开。这三条合在一起,就逼出了那个 1 字节的占位。
1.2 空类到底“空”在哪:语言层面的定义
严格说,“空类”指的是没有非静态数据成员、没有虚函数、没有虚基类的类。注意这个定义比很多人想的宽松得多:空类里可以放静态数据成员、成员函数、类型别名、枚举、static_assert,甚至可以有构造函数和析构函数,只要不引入非静态数据成员和虚表指针,它在对象模型层面依然是“空的”。
标准库里有专门的判别工具std::is_empty,C++11 起可用。我在代码审查时经常拿它做断言,防止后人往一个依赖空类优化的类型里加成员:
#include <type_traits> class Empty { public: using value_type = int; static int counter; void hello() {} }; static_assert(std::is_empty_v<Empty>, "Empty 必须保持为空类"); static_assert(sizeof(Empty) == 1); struct NotEmpty { int x; }; static_assert(!std::is_empty_v<NotEmpty>);这里有个容易忽略的细节:空的struct和空的class在大小上完全一样,class只是默认访问权限是private,跟内存布局没有任何关系。我见过有人面试时说“class 有默认的 private,所以编译器要多存一个访问标记”,这纯属想象,访问控制在编译期完成,不占运行时空间。
1.3 为什么一个字节都省不掉:对象同一性的硬约束
假设允许sizeof(Empty) == 0,会立刻引出一串无法自洽的问题。第一,Empty a, b;如果大小是 0,&a和&b就可能相等,那么“两个不同对象地址不同”这条基本假设就失效了,指针比较、std::set<Empty*>、容器里的地址索引全部失去意义。第二,Empty arr[10];如果每个元素 0 字节,&arr[0]到&arr[9]全是同一个地址,arr + i的指针算术直接退化,无法区分元素。第三,new Empty返回的指针如果都指向同一处,delete时无法对应。
注意:这个 1 字节不是“数据”,是编译器插入的占位填充,它的值没有任何语义,读出来是不确定的,别对它做任何假设。
所以更准确的说法是:C++ 保证每个对象至少占 1 字节,以保证地址唯一性。这 1 字节在对象里存在,但从不被读写,也不会被初始化成 0(除非你做值初始化)。理解这一点,后面所有的优化与坑才有统一的解释框架。
2. 编译器替空类写了哪些函数
2.1 经典六件套与移动语义的加入
经典面试答案是“编译器会生成六个函数”:默认构造函数、析构函数、拷贝构造函数、拷贝赋值运算符、取地址运算符、const取地址运算符。这套说法来自 C++98 时代,今天已经不够用了。C++11 之后,还要加上移动构造函数和移动赋值运算符,只不过它们不是无条件生成的:只有在类没有用户声明的拷贝操作、移动操作、析构函数时,编译器才会隐式声明移动操作;而且隐式声明的移动操作在某些条件下会被定义为delete或干脆不参与重载决议。
这里的关键是理解“隐式声明”和“真正生成”是两件不同的事。编译器会声明这些特殊成员函数,但只有在你实际用到了(ODR-use)才会定义出来,这就是所谓的按需实例化。所以一个空类如果只是定义、从不实例化,编译器可能一个函数体都不会产出。我在看编译产物时验证过这点:只写class Empty {};不实例化,目标文件里连符号都没有。
#include <utility> class Empty {}; int main() { Empty a; // 触发默认构造 + 析构 Empty b = a; // 触发拷贝构造 b = a; // 触发拷贝赋值 Empty c = std::move(a); // 触发移动构造(隐式声明的) (void)b; (void)c; }Empty的这五个函数全是平凡的(trivial),编译器生成它们时基本就是“什么都不做”,甚至会被完全内联掉,最终在汇编里一个指令都不剩。这也是为什么“空类有六个函数”和“空类零开销”可以同时成立——函数存在,但不产生代码。
2.2 那个被遗忘的 operator&:老题新看
operator&被重载这件事,是 C++98 遗留下来的一道经典老题。标准里确实把取地址运算符列为可以隐式声明的特殊成员函数之一,但它跟其他五个性质完全不同:它不是“生成”出来的,而是语言内建的默认行为,只有当你不重载它时,才走内建语义。
今天的工程实践里,几乎没人会给普通类重载operator&,但它带来的坑很实际:如果一个类型重载了operator&,那么所有&obj的写法都可能拿到别的东西,包括模板库里的std::addressof之外的所有地方。这就是为什么标准库在需要真实地址时一律用std::addressof:
#include <memory> struct Evil { Evil* operator&() { return nullptr; } // 别这么写,只是演示 }; int main() { Evil e; Evil* p1 = &e; // 拿到 nullptr Evil* p2 = std::addressof(e); // 拿到真实地址 return p1 == p2 ? 1 : 0; }提示:写泛型代码需要对象地址时,一律用
std::addressof,别用&。这是一条能省掉很多莫名其妙 bug 的习惯,成本几乎为零。
顺带提一句,C++17 之后标准把“取地址运算符”从特殊成员函数的列表表述里弱化了,更准确的说法是它属于“内建运算符 + 可重载”的范畴。面试时如果被追问,能说清这个演变过程,比背六个名字有价值得多。
2.3 何时真正生成:按需实例化与弃置
隐式声明的特殊成员函数有一张复杂的“弃置规则表”。对空类来说,因为没有任何成员,这些函数几乎总是平凡的、可用的。但一旦你给空类加上成员或基类,情况就变了:如果有成员的类型不可拷贝,拷贝构造会被定义为delete;如果基类的析构不是虚的或不可访问,析构函数可能被弃置。
这套规则的实际意义在于:空类之所以能“白嫖”这些函数,是因为它真的空。我在给团队做代码评审时总结过一条经验——只要你开始往一个原本空的类型里加东西,就要回头检查所有依赖它“平凡、可拷贝、零开销”的地方,尤其是那些把它当模板参数、当基类、当成员的地方,它们很可能因为一个新增的非静态成员全部失效。这种连锁反应在模板库里特别隐蔽,编译能过,行为悄悄变慢。
3. 空类大小与内存布局的实测
3.1 单例、数组与指针算术的实测
理论说完,还是得跑一遍。下面这段代码我几乎每次讲对象模型都会拿出来,覆盖了空类最常见的几个观测点:
#include <cstdio> #include <type_traits> class Empty {}; int main() { std::printf("sizeof(Empty) = %zu\n", sizeof(Empty)); std::printf("sizeof(Empty[10]) = %zu\n", sizeof(Empty[10])); std::printf("sizeof(Empty*) = %zu\n", sizeof(Empty*)); Empty a, b; std::printf("&a = %p, &b = %p, same = %d\n", (void*)&a, (void*)&b, (&a == &b)); Empty arr[3]; std::printf("step = %td\n", (char*)&arr[1] - (char*)&arr[0]); Empty* p = new Empty(); Empty* q = new Empty(); std::printf("heap: %p vs %p, same = %d\n", (void*)p, (void*)q, (p == q)); delete p; delete q; }GCC 13、Clang 17 在 64 位 Linux 上跑出来的结果是一致的:sizeof为 1,数组总大小 10,指针大小 8(64 位),两个栈上对象地址不同,数组相邻元素地址差 1 字节,两次new得到的地址也不同。这几个数字放在一起,正好把“1 字节是为了保证地址唯一”这个结论从抽象变成了可观测的事实。
有意思的是sizeof(Empty*):指针大小跟“指向什么”无关,64 位平台上就是 8。面试里常有人把“空类大小是 1”和“空类指针大小是 1”混在一起,这是两回事,前者是对象占位,后者是指针宽度,跟类型无关。
3.2 结构体里塞一个空类会发生什么
真正的工程问题从这里开始。当空类作为成员出现在另一个结构体里,它那 1 字节不再孤立,而是会对齐、填充、总大小产生连锁影响:
struct A { Empty e; int x; }; // 1 + 3(padding) + 4 = 8 struct B { int x; Empty e; }; // 4 + 1 + 3(padding) = 8 struct C { Empty e1; Empty e2; int x; }; // 1 + 1 + 2(pad) + 4 = 8 struct D { Empty e; char c; }; // 1 + 1 + 2(pad) = 4(对齐要求 1,但整体对齐 1?)上面前三个结构体在 x86-64 上都是 8 字节,D是 2 字节(char对齐为 1,Empty对齐也是 1,所以结构体对齐 1,总大小 2)。这里最容易踩坑的是A:很多人以为int会紧接着空类放在偏移 1 处,实际不是——int需要 4 字节对齐,所以中间插了 3 字节 padding。
C更值得看:两个空类成员各自占 1 字节,再补 2 字节让int对齐,总共 8 字节。如果你只是想在结构体里放两个“类型标记”空类,成本可能是你想象的两倍。我在一个高频创建的小对象里就吃过这个亏,加了两个用于策略区分的空类成员,每个对象多出 8 字节,乘上百万级实例,内存直接涨了 8MB,而这两个成员在运行时压根没有被使用——它们的作用完全可以在编译期通过模板参数体现。
3.3 对齐、padding 与 ABI 差异
空类的对齐要求是 1,这一点在 Itanium ABI 和 MSVC 的 ABI 下是一致的。但成员重排不同:MSVC 在#pragma pack和某些standard layout场景下的行为与 GCC/Clang 有细微差异,尤其是把空类作为首成员时,部分编译器会把首成员放到偏移 0 从而“覆盖”空类的空间——这实际上是标准允许的“首成员与基类子对象同址”规则在起作用,但它对成员不适用,成员之间必须地址不同。
所以结论很明确:空类作为非静态数据成员,那 1 字节是实打实要付的,编译器不能把它优化掉(C++20 之前)。想要省掉,要么用继承(EBO),要么用 C++20 的[[no_unique_address]]。这两个方案的区别和限制,就是下一节要解决的问题。
4. 空基类优化:把那一字节省回来的正经技术
4.1 EBO 的成立条件与失效条件
空基类优化(Empty Base Optimization,EBO)做的事情很简单:当空类作为基类时,允许派生类对象不为这个基类子对象单独分配存储,让基类子对象与派生类的其他子对象共享地址。效果立竿见影:
class Empty {}; struct WithMember { Empty e; int x; }; // 8 struct WithBase : Empty { int x; }; // 4WithBase只有 4 字节,因为Empty基类子对象被放到了偏移 0,x也在偏移 0,两者地址相同但类型不同,标准允许。这是 C++ 里非常罕见的一次“继承反而更省内存”的情况。
EBO 的失效条件主要有两个。第一,同一类型的两个基类子对象不能同址。如果派生类从两个不同路径各继承了一个Empty,那两个Empty子对象必须有不同的地址,优化就失效了:
struct A : Empty {}; struct B : Empty {}; struct D : A, B { int x; }; // sizeof(D) 通常是 8,两个 Empty 各占 1 字节 + padding struct E : Empty, Empty {}; // 非法!重复的基类第二,基类子对象不能与同类型的成员同址。如果你的派生类里又放了一个Empty成员,那这个成员不能被压到基类子对象上。
注意:EBO 在标准里没有明确条文保证,它是由“基类子对象地址可以与派生类对象其他部分重合”这一 ABI 约定推出来的。实践中 GCC、Clang、MSVC 都实现了它,所以可以放心依赖,但在跨编译器、跨 ABI 的项目里,涉及
sizeof的静态断言要谨慎写死数值。
4.2 标准库里的 EBO:分配器、删除器、比较器
EBO 不是教科书里用来炫技的东西,标准库到处都在用。std::vector<T, Alloc>的典型实现会把Alloc作为基类继承,因为std::allocator<T>是空类,继承之后sizeof(std::vector<int>)就是三个指针,而不是三个指针加 1 字节加填充。std::unique_ptr<T, D>同理:当D是空删除器(比如默认的std::default_delete<T>)时,指针大小就是 8 字节,不会因为你指定了自定义删除器而变大。
#include <memory> #include <functional> #include <cstdio> struct NoStateDeleter { void operator()(int* p) const { delete p; } }; struct WithStateDeleter { int id = 0; void operator()(int* p) const { delete p; } }; int main() { std::printf("%zu\n", sizeof(std::unique_ptr<int>)); // 8 std::printf("%zu\n", sizeof(std::unique_ptr<int, NoStateDeleter>)); // 8 std::printf("%zu\n", sizeof(std::unique_ptr<int, WithStateDeleter>));// 16 std::printf("%zu\n", sizeof(std::function<void()>)); // 32 左右,跟库实现相关 }这几个数字我在多个项目里验证过:无状态删除器确实不增加unique_ptr的大小,而带一个int成员的删除器会让它从 8 涨到 16。这个差别在把函数对象当策略传入的泛型代码里非常关键,因为你可能在一个节点结构里塞了好几个unique_ptr,每个多 8 字节,整棵树的额外开销就上去了。
比较器同理。std::set<T, std::less<T>>的默认比较器是空类,std::less的operator()就是一个a < b,没有任何状态。标准库的实现会把比较器作为基类继承,从而让它不占节点空间。反过来,如果你写了一个带状态的比较器(比如 capture 了某个阈值的 lambda),那每棵树、每个节点都可能要为这个状态付出空间。
4.3 C++20 [[no_unique_address]] 的正确用法与坑
C++20 引入的[[no_unique_address]]属性,针对的正是“空类作为成员无法享受 EBO”这个长期痛点。标记之后,这个成员允许与其他非静态数据成员或基类子对象共享地址,前提是类型本身是空类:
#include <cstdio> struct Empty {}; struct StatelessAllocator {}; struct Old { Empty e; int x; }; // sizeof = 8 struct New { [[no_unique_address]] Empty e; int x; }; // sizeof = 4(GCC/Clang)这个属性和 EBO 是互补的:EBO 解决“作为基类”的场景,[[no_unique_address]]解决“作为成员”的场景。两者结合,可以覆盖绝大多数零开销抽象的需求。
但这里有几个实际踩过的坑,值得单独说清楚。第一,同类型的两个[[no_unique_address]]成员仍然不能同址,所以如果你需要两个同类型的空标记,优化会部分失效,这时候得把其中一个包装成不同类型(比如套一层模板tag<0>、tag<1>)。第二,MSVC 对[[no_unique_address]]的支持来得比较晚,早期版本需要用[[msvc::no_unique_address]],跨平台项目里通常两个都要写。第三,这个属性对非空类型基本无效(标准说可以忽略),所以别指望它能压缩有实际数据的成员。
还有一点容易被忽略:[[no_unique_address]]只影响内存布局,不影响语义。标记之后,你依然可以正常取这个成员的地址、调用它的成员函数,只是它的地址可能跟你预期的不一样。如果代码里有依赖“成员地址严格大于对象起始地址”的假设(比如某些手写的类型标记、offsetof计算),标记之后这些假设可能失效,务必检查。
4.4 手写一个带 EBO 的压缩对
为了把 EBO 和no_unique_address的差异看得更清楚,我通常会让学员手写一个压缩对(compressed pair)。下面是 C++17 下用继承实现的版本,逻辑清晰、可移植性好:
#include <type_traits> #include <utility> #include <cstdio> template <class T, class U, bool = std::is_empty_v<T> && !std::is_final_v<T>, bool = std::is_empty_v<U> && !std::is_final_v<U>> struct CompressedPair; // 两个都不是空类:普通成员 template <class T, class U> struct CompressedPair<T, U, false, false> { T first; U second; }; // T 空,U 不空:T 作为基类 template <class T, class U> struct CompressedPair<T, U, true, false> : T { U second; CompressedPair(T t, U u) : T(std::move(t)), second(std::move(u)) {} T& getFirst() { return *this; } }; // U 空:U 作为基类 template <class T, class U> struct CompressedPair<T, U, false, true> : U { T first; CompressedPair(T t, U u) : U(std::move(u)), first(std::move(t)) {} U& getSecond() { return *this; } }; int main() { struct Empty1 {}; struct Empty2 {}; static_assert(sizeof(CompressedPair<Empty1, Empty2>) <= 2); static_assert(sizeof(CompressedPair<Empty1, int>) == sizeof(int)); std::printf("%zu %zu\n", sizeof(CompressedPair<Empty1, int>), sizeof(CompressedPair<int, int>)); }这个实现里有两个细节值得注意:一是加上了!std::is_final_v<T>的判断,因为final类不能被继承,如果模板不排除这种情况,遇到final的空类参数会直接编译失败;二是Empty1和Empty2两个不同类型空类同时为空时,sizeof可以压到 2 以内,但同类型两个空类就压不下去了,原因前面说过——同类型子对象必须地址不同。
C++20 之后,这个模板可以写得更简单:直接用[[no_unique_address]]两个成员,不需要多重特化,代码量减半且行为等价(空类情况下)。但如果你的编译目标还包含 C++17,压缩对的手写版本依然是标准做法。
5. 空类在工程里的真实用途
5.1 标签分发与重载决议
空类最纯粹的用法是把编译期信息编码成类型。std::true_type和std::false_type就是空类,但它们承载了value这个静态成员,让重载决议可以在两种实现之间选择。标签分发(tag dispatch)是这套机制最直观的应用:
#include <cstdio> #include <type_traits> #include <vector> #include <list> struct random_access_tag {}; struct sequential_tag {}; template <class C> struct trait_of { using tag = sequential_tag; }; template <class T> struct trait_of<std::vector<T>> { using tag = random_access_tag; }; template <class C> void advance_impl(C& c, int n, random_access_tag) { std::printf("random access, jump %d\n", n); } template <class C> void advance_impl(C& c, int n, sequential_tag) { std::printf("sequential, step %d\n", n); } template <class C> void advance(C& c, int n) { advance_impl(c, n, typename trait_of<C>::tag{}); }这里random_access_tag和sequential_tag都是空类,作为参数传递时不携带任何运行时数据,编译后通常连一个寄存器都不占,最终内联成两段完全不同的代码。这就是零运行时开销的编译期分派,比if constexpr更早、也更通用的方案。
从性能角度看,标签对象在 x86-64 System V ABI 下作为按值传递的空类参数,通常会被完全省略;即使没有省略,它也只有 1 字节。所以“为了性能不用标签分发”这个理由是不成立的,真正的取舍在于代码可读性和模板实例化数量。
5.2 无状态策略与零成本抽象
策略类(policy class)是空类另一个高频使用场景。STL 里的分配器、比较器、哈希器、删除器,本质上都是策略:它们规定“怎么做”,但不保存“用哪个状态”。当策略无状态时,用空类表示是最自然的选择,配合 EBO 或[[no_unique_address]],可以做到真正的零开销。
template <class T, class Alloc = std::allocator<T>> class Buffer { [[no_unique_address]] Alloc alloc_; T* data_ = nullptr; std::size_t size_ = 0; public: std::size_t size() const { return size_; } }; static_assert(sizeof(Buffer<int>) == 16); // 指针 + size_t,分配器不占空间这个断言在我做过的几个精简容器项目里都成立。如果去掉[[no_unique_address]],Buffer<int>在某些编译器上会变成 24 字节,因为默认分配器那 1 字节加上对齐填充。这个差异在容器本身用得少的时候无所谓,但如果一个程序创建了几百万个小缓冲区,24 字节和 16 字节就是 50% 的内存差异。
需要强调的是,零成本抽象的前提是策略必须无状态。一旦你在分配器里加了一个成员(比如一个内存池指针、一个统计计数器),它就不再是空类,所有依赖它不占空间的代码布局假设都会失效。所以策略类要么彻底无状态,要么明确承担有状态的成本,不要两头都想要。
5.3 CRTP 与 mixin 的空基类
CRTP(奇异递归模板模式)里,基类经常是空类,只提供一个接口或者一些依赖派生类的实现:
template <class Derived> struct Comparable { bool operator!=(const Derived& rhs) const { return !(static_cast<const Derived&>(*this) == rhs); } }; struct Point : Comparable<Point> { int x = 0, y = 0; bool operator==(const Point& o) const { return x == o.x && y == o.y; } }; static_assert(sizeof(Point) == 8); // Comparable<Point> 是空基类,被 EBO 掉Comparable<Point>是空类,作为基类被 EBO 优化掉,Point依然是 8 字节。这个模式让接口复用不付出任何内存代价,也是很多库的惯用手法。不过要注意,CRTP 基类如果是final的,或者包含状态,EBO 就不成立,sizeof会立刻变大,所以模板库作者通常会在关键类型上加静态断言把这条约束固化下来。
mixin 也是类似思路:把一组相关但无状态的操作打包成基类,多重继承给目标类型。这时候如果两个 mixin 恰好都是空类且类型相同(比如同一个 mixin 被间接继承两次),EBO 就会失效,sizeof悄悄增加。这种问题在复杂的继承图里极难发现,我的经验是——只要用了多重继承,就在最外层加static_assert(sizeof(T) == 期望值),用编译错误把它拦住。
5.4 作为类型系统的“标记”
最后一类用法比较隐性:空类作为“标记类型”出现在类型系统里,纯粹为了区分泛型参数。比如std::in_place_t、std::piecewise_construct_t、std::nullopt_t,都是空类,它们的存在只是为了让构造函数重载能区分意图,运行时不承载任何数据。
这类标记在设计 API 时非常好用。举个例子,如果你有两个语义不同但参数列表相同的构造函数,用空类标记可以做得很干净:
struct clamp_tag {}; struct wrap_tag {}; template <class T> class Index { T v_; public: Index(T v, clamp_tag) : v_(v) {} Index(T v, wrap_tag) : v_(v) {} T value() const { return v_; } };clamp_tag{}作为参数出现在调用点上,可读性远好于一个bool参数,而且编译期就能检查——你不可能把clamp_tag传错位置。代价是 0 字节(传值时空类通常不占寄存器),唯一成本是类型数量增加、模板实例化可能增多。这类设计在标准库和成熟框架里很常见,学会用它比学会背sizeof有用得多。
6. 踩过的坑与排查实录
6.1 空类成员把结构体撑大
前面已经提过struct A { Empty e; int x; };是 8 字节而不是 5 字节。实际项目里的版本更隐蔽:一个配置结构体里有三个用于策略区分的空类成员,.data()和.bss()都正常,但对象数量一上来,内存就吃紧。排查过程通常是这样:先用sizeof打印出结构体大小,跟“字段大小求和”对不上,然后一个个删字段做二分,最后定位到空类成员。
提示:如果发现结构体大小与字段大小之和对不上,先算对齐,再查空类成员。空类成员是最容易被误认为“不占空间”的字段。
修法有三种:一是直接删掉,把信息挪到模板参数;二是在 C++20 环境下加[[no_unique_address]];三是改成继承。选哪种取决于这些标记是否需要在运行时区分——如果不需要,模板参数永远是最优解,因为它连 1 字节都不占。
6.2 虚函数、虚继承让空类不再空
一个类只要有了虚函数,就不再是空类了:它会多一个虚表指针,64 位平台上sizeof变成 8。虚继承更复杂,通常还会引入虚基类表指针。我曾见过有人在面试里说“空类加个虚函数还是 1”,这显然是错的。
struct E1 {}; // 1 struct E2 { virtual ~E2() = default; }; // 8 struct E3 : virtual E1 { int x; }; // 通常 16(虚基类表指针 + int + padding) static_assert(sizeof(E2) == 8);这些数字在不同编译器、不同 ABI 下可能有细微差别,所以涉及虚继承的sizeof断言一定要谨慎,最好只在单一编译链内部做断言。我在跨平台项目里的做法是:只对“确定稳定的布局”做静态断言(比如三个指针的容器),对虚继承这类实现相关的部分不做断言,改用运行时日志观察。
6.3 memset/memcmp 与空类的兼容性
空类是平凡类型,理论上可以memcpy,但memset没有任何意义——那 1 字节填充本来就不表示数据,清零也不会带来任何行为改变。真正危险的是把它当成“可以二进制比较”的借口:
Empty a, b; // 不要这样比较: // int r = std::memcmp(&a, &b, sizeof(Empty));memcmp的结果依赖那 1 字节填充的值,而这个值是不确定的,比较结果没有意义。更一般地说,只要类型不是可平凡比较的(C++20 的std::is_trivially_copyable加上所有成员都参与比较),就不该用memcmp比较对象。这个原则对所有类型都成立,只是空类因为“看起来什么都没有”更容易被误用。
6.4 跨编译器差异
我整理了几处实际会遇到的差异,值得记下来:
| 场景 | GCC/Clang (Itanium ABI) | MSVC | 备注 |
|---|---|---|---|
sizeof(Empty) | 1 | 1 | 一致 |
| 空基类优化 | 默认开启 | 默认开启 | 一致 |
[[no_unique_address]] | C++20 起完整支持 | 需较新版本,旧版用[[msvc::no_unique_address]] | 跨平台建议两个都写 |
| 多重空基类 | 尽量压缩,同类型不同址 | 类似 | 不要依赖具体字节数 |
| 虚继承布局 | 虚基类表指针 | 类似但不同 | 不要在跨编译器断言 |
处理这类差异的通用策略是:能用静态断言固定的就固定,不能固定的就用“小于等于”而不是“等于”。比如static_assert(sizeof(CompressedPair<Empty, int>) <= sizeof(int));,这样在实现略有不同的编译器上依然成立,同时又能保证优化生效。
6.5 常见问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 结构体比字段之和大 | 空类成员 1 字节 + 对齐 padding | 改模板参数、[[no_unique_address]]或继承 |
unique_ptr比预期大 | 删除器有状态,或未启用 EBO | 删除器改无状态,检查sizeof |
| 容器节点意外膨胀 | 比较器/分配器带状态 | 改为空类策略,用 EBO |
sizeof断言在另一平台失败 | ABI 差异或对齐设置不同 | 改成<=或移除非必要断言 |
| 多重继承后对象变大 | 同类型空基类无法合并地址 | 用不同标记类型或改为组合 |
memcmp结果不稳定 | 填充字节值不确定 | 改用手写比较或operator== |
7. 面试答题框架:怎么把这道题答成加分项
7.1 三层递进的回答模板
被问到“空类大小是多少”时,我建议按三层回答。第一层给结论:sizeof是 1,因为标准要求每个对象有唯一地址,0 字节会让数组元素和不同对象无法区分。第二层讲延伸:如果空类作为基类,编译器会做空基类优化,让它不占额外空间;如果作为成员,C++20 之前无法优化,可以用[[no_unique_address]]解决。第三层讲细节:空类的隐式成员函数是平凡可用的,取地址运算符可以重载因此泛型代码要用std::addressof;加虚函数后不再是空类,大小会变成指针宽度。
这个顺序的好处是由浅入深,面试官可以选择在任意一层停下来继续问,而你每一层都有料可讲。相比一上来就背“六个默认函数”,这种答法更能体现你理解的是对象模型,而不是题库。
7.2 高频追问链与对应答案
面试官通常会顺着答案追问,我列几个高频的:
- “空类能不能被实例化?”能,
Empty e;完全合法,大小 1。 - “空类能不能继承?”能,而且继承它还能触发 EBO 省空间;但如果空基类是
final的就不能继承。 - “空类能不能做模板参数?”能,而且很常见,
std::true_type、标签类型都是空类。 - “空类的指针大小是多少?”跟空类无关,等于平台指针宽度,64 位上是 8。
- “空类里放静态成员还算空类吗?”算,静态成员不占对象空间,
std::is_empty依然为真。 - “为什么两个空类成员不能同址?”因为同类型子对象必须地址不同,否则无法区分,EBO 和
[[no_unique_address]]都受这条约束。
这些追问其实都在同一个知识框架里:对象同一性、子对象地址规则、平凡性、ABI。把这四条想清楚,任何变形都能推导出来,不需要死记。
7.3 收尾的加分句
如果面试聊到这里气氛不错,我喜欢补一句自己的理解:空类是 C++ 里少见的“零与一之争”的具象化——语言要求它至少占 1 字节,而抽象需求又希望它占 0 字节,两者之间的张力就催生了 EBO、[[no_unique_address]]和压缩对这一整套技术。能把这条线索讲完整,基本就说明你读过标准、也写过真正在意内存布局的代码。
我自己的习惯是:每次在项目里引入一个空类做策略或标记,就在旁边加一行static_assert(std::is_empty_v<T>)和一个sizeof断言。这两个断言几乎零成本,却能在半年后有人往里加成员时第一时间把问题暴露在编译期,而不是等到线上内存涨了才去查。这个习惯我坚持了几年,抓出来的问题没有十个也有八个,也算是空类这个知识点带给我最实在的收益。