目录
union 是 C 语言延续下来的一种复合类型,它可以把多个成员打包成一个类型(这种让多个成员叠在同一块内存上的做法,在主流语言里并不多见)。
C++ 不仅保留了它,还做了扩展。
在 C++ 里 union 还可以带成员函数与访问控制,但不能有虚函数。
虽然工作中用到 union 的机会不多,但它的内存模型是理解 C++ 对象布局的一块基石,值得花几分钟弄清楚。
union 同址,struct 顺序排
按 C++ 的标准,union 的所有非静态数据成员都拥有相同的内存地址,也就是都从偏移(offset,从对象起点算起的字节数)0 开始。
而 struct 则不同,它是把成员依次排在递增的偏移上,中间还可能插入填充字节(padding,编译器为满足对齐而插入、不存放数据的字节)。
看下面这个例子:一个 union 同时容纳 int、float 和 char 数组三种类型,三者的内存都从同一基址起布局。
#include <cstddef>
#include <cstdio>
// 三种成员共享同一段内存:int、float、char 数组
union U {
int i;
float f;
char c[4];
};
int main()
{
std::printf("sizeof(U) = %zu\n", sizeof(U));
std::printf("alignof(U) = %zu\n", alignof(U));
std::printf("offsetof(U, i) = %zu\n", offsetof(U, i));
std::printf("offsetof(U, f) = %zu\n", offsetof(U, f));
std::printf("offsetof(U, c) = %zu\n", offsetof(U, c));
return 0;
}
一次 x86-64 上的运行结果:
sizeof(U) = 4
alignof(U) = 4
offsetof(U, i) = 0
offsetof(U, f) = 0
offsetof(U, c) = 0
运行结果验证了前面的说法,三个成员的偏移都是 0,它们确实共享同一段 4 字节内存。
这一点编译成汇编后看得更直接。让三个函数各自只写一个成员:
union U {
int i;
float f;
char c[4];
};
// 三个函数分别只写一个成员,观察编译器算出的地址
void set_i(U* u) { u->i = 1; }
void set_f(U* u) { u->f = 2.0f; }
void set_c(U* u) { u->c[0] = 'a'; }
clang -O1 生成的 x86-64 汇编(Intel 语法,省略函数框架,名称修饰已还原,只留核心指令):
; 写 int 成员
set_i:
mov dword ptr [rdi], 1
; 写 float 成员,1073741824 是 2.0f 的位模式
set_f:
mov dword ptr [rdi], 1073741824
; 写 char 成员,97 是 'a' 的 ASCII 码
set_c:
mov byte ptr [rdi], 97
三个函数的写入目标都是 [rdi],也就是参数 u 指向的 union 基址,偏移全是 0。机器指令层面,三个成员的写入落在完全相同的地址上,这就是”成员同址”在汇编里的样子。
struct 和 union 的存储模型对照如下图所示。
图1:struct 顺序排,union 重叠存
左:struct 含 int、float、4 字节字符数组,共 12 字节;右:union 含同样三种类型,共 4 字节
struct 把每个成员的长度加起来,再算上对齐填充,就是它的总长。
union 的大小则主要由最大的那个成员决定,再看对齐要不要补齐。
大小:最大成员 + 对齐量
union 的大小由两条规则共同决定:
- union 的大小至少等于其最大成员的大小。
- union 的对齐要求取所有成员中最严格的那个对齐量(即该类型对象的地址必须是这个对齐量的整数倍)。实际编译出的大小会补齐到对齐量的整数倍,因此末尾可能多出填充字节。这一条很容易被忽略,用下面的例子来体会一下。
#include <cstddef>
#include <cstdio>
// 最大成员是 5 字节的 char 数组,对齐要求 4(来自 int)
union W {
char buf[5];
int i;
};
int main()
{
std::printf("sizeof(char[5]) = %zu\n", sizeof(char[5]));
std::printf("alignof(W) = %zu\n", alignof(W));
std::printf("sizeof(W) = %zu\n", sizeof(W));
return 0;
}
运行结果:
sizeof(char[5]) = 5
alignof(W) = 4
sizeof(W) = 8
最大成员只有 5 字节,但成员 int 的对齐要求是 4,union 自身的对齐被抬到 4。
按”大小必须是对齐量的整数倍”这条规则,5 字节要向上补到 4 的倍数,最小满足条件的是 8 字节,因此编译器在末尾补了 3 字节填充。
从本例可以得出结论,union 的大小不小于最大成员的大小。当最大成员的大小不是对齐量的整数倍时,编译器会在末尾补填充,把它抬到不小于它的最小整数倍。第一个例子中 sizeof(U) 恰好等于最大成员的 4 字节,属于不需要补齐的情况。
图2:union 大小取最大成员,再按对齐量在末尾补齐
在 x86-64 System V ABI 下,对齐量通常就等于各原生类型的宽度(int 4、double 8、指针 8、long double 16),聚合类型的对齐取成员最大值。这条规则对预测 union 的 sizeof 与相邻字段的偏移都适用。
字节序与类型双关
union 成员共享地址,同一段字节就能按不同类型去解释,这种做法叫类型双关(type punning)。
最直观的例子是字节序。
下面这个例子用 memcpy 把 32 位整数的字节原样拷出来观察。
#include <cstdint>
#include <cstring>
#include <cstdio>
// 把 32 位整数的字节原样拷出,观察字节序
void dump_bytes(std::int32_t x)
{
unsigned char b[4];
std::memcpy(b, &x, sizeof(b));
std::printf("%08X -> %02X %02X %02X %02X\n",
x, b[0], b[1], b[2], b[3]);
}
int main()
{
dump_bytes(0x01020304);
return 0;
}
小端机器上的输出:
01020304 -> 04 03 02 01
向一个 4 字节整数写入 0x01020304 后,低地址先放最低字节 04,依次是 03、02、01。
为了更直观地理解,下面这张图把写入后的逐字节布局画了出来。
图3:写入 int 后按字节读出,低字节落在低地址(小端)
刚才那段读字节的动作,把 int 的 4 个字节当作”四个 char”去看,可以看作类型双关的一种温和形式,用另一种类型观察同一段字节,它在 C 和 C++ 里都是合法的。如果不用 memcpy,而是借助 union 本身,先写入一个成员、再从另一个成员把同一段字节读出来,这种读法在两种语言里的待遇就不一样了。
C 标准以脚注的方式认可了这种双关。用另一个成员去读时,按新类型重新解释这段对象表示(object representation,对象在内存中的那串字节)。同时它也保留了一个例外:读出来的位模式如果不表示新类型的任何一个合法值(trap representation,陷阱表示),这次读取就会出问题。
这里的”陷阱表示”是指这样一种值:它虽然能存进这段内存,但并不表示该类型的任何一个合法值,常见情形是某些位并不参与取值,只用作填充或奇偶校验。一旦程序真的按这个类型去读它,行为是未定义的,在个别平台上会表现为硬件异常。C 标准允许实现把某些位保留作这种用途,历史上 Itanium 平台的整数取值就会因寄存器保留位触发陷阱。主流桌面平台(x86-64、ARM64)几乎不存在这种情况,日常编程里基本遇不到,这也是 C 标准把它当作”例外”而不是”主规则”的原因。
C++ 把这种读法列为未定义行为。原因在于,被读的那个成员的对象还不存在,既然对象不存在,那么自然就没办法确定程序接下来该做什么。
但是从 C++20 起有了改变。C++ 标准规定 std::memcpy、std::malloc、std::bit_cast 这一类操作,会在目标存储区建立对象。因此用 memcpy 取字节是合法做法。但标准同时明确,这种变化并不打算让 union 与 reinterpret_cast 式的类型双关一并合法,不能靠它绕开 C++ 那条未定义行为的规则。
要做类型双关,就要改用 memcpy,或在 C++20 之后改用 std::bit_cast。两种写法都明确告诉编译器这是一次对象表示的转换,编译器不会对它们套用严格别名规则(strict aliasing,编译器假定不同类型的指针不会指向同一对象,并据此优化)。
当前活跃的是哪个成员:回到对象生命期
union 同一时刻只有一个成员处于活跃(active)状态。标准对活跃的定义落在生命期上:该成员名字所指的对象,其生命期(lifetime,对象从开始存在到结束存在的区间)已经开始且尚未结束。对平凡类型(trivial type,可以按字节直接复制、不需要特殊构造与析构的类型)而言,这与”最近一次被写入的那个成员”是同一回事。读取非活跃成员在 C++ 中未定义,原因就在这条生命期定义上。
要在 C++ 中切换活跃成员,需要让新成员的对象生命期开始。对标量成员,直接赋值即可。更一般的情形用 placement new(在指定的已有内存上原地构造对象)重新构造,C++17 起可配合 std::launder(告诉编译器这块内存里已经是一个新对象,把旧指针作废)取回指向新对象的指针,C++23 起有 std::start_lifetime_as 显式开始生命期。
标准还留了一个例外,不过条件很严。union 本身必须是 standard-layout 类型(布局规则简单、成员顺序与偏移有保证的那类类型),参与对比的几个结构体成员也必须是 standard-layout,并且它们开头若干字段的类型与顺序完全一致(即共用初始序列,common initial sequence),当前活跃成员还要在其中。这些条件都满足时,可以通过任意一个这样的成员读取那段公共前缀,无需切换活跃成员。这条规则常用来实现带公共头部的数据包解析。
自己手写一个 tagged union
仅使用 union 存在一个隐患:当前哪个成员处于活跃状态,需要由程序员自己记住。常见做法是额外配一个枚举标签,组成 tagged union(判别联合体,即额外用一个字段记录当前存的是哪种类型)。
#include <cstddef>
#include <cstdio>
enum class Kind { Int, Double };
// 手写带判别标签的联合体值
struct Value {
Kind tag;
union {
int i;
double d;
} u;
};
int main()
{
std::printf("alignof(Value) = %zu\n", alignof(Value));
std::printf("offsetof(Value, tag) = %zu\n", offsetof(Value, tag));
std::printf("offsetof(Value, u) = %zu\n", offsetof(Value, u));
std::printf("sizeof(Value) = %zu\n", sizeof(Value));
return 0;
}
运行结果:
alignof(Value) = 8
offsetof(Value, tag) = 0
offsetof(Value, u) = 8
sizeof(Value) = 16
逐个字段分析这段布局。tag 是 4 字节的枚举,落在偏移 0。成员 u 所属的 union 里,int 对齐 4、double 对齐 8,合并后该 union 对齐 8、大小 8。为满足 union 的 8 字节对齐,编译器在 tag 之后补了 4 字节填充,union 子对象被推到偏移 8。于是 4 加 4 填充再加 8,得到 16 字节,整体对齐 8。
这 4 字节填充,就是对齐要求让判别标签付出的代价:标签本身只占 4 字节,却连带把整个结构推到 16 字节。
这段布局在汇编里同样可以直接看到,下面是代码与汇编的对照。
enum class Kind { Int, Double };
struct Value {
Kind tag;
union {
int i;
double d;
} u;
};
// 写入判别标签与 int 成员,观察两个字段的偏移
void set_int(Value* v, int x)
{
v->tag = Kind::Int;
v->u.i = x;
}
clang -O1 生成的核心指令:
; tag 是枚举,Kind::Int 的值为 0,写在对象基址
set_int:
mov dword ptr [rdi], 0
; int 成员写在基址加 8 的位置,与 offsetof(Value, u) 一致
mov dword ptr [rdi + 8], esi
tag 落在 [rdi](偏移 0),union 成员落在 [rdi + 8],与前面 offsetof 的输出一一对应。标签的 4 字节和连带的对齐填充,在汇编里就是基址与 +8 之间那段没被用到的空间。
C++17 起标准库提供 std::variant,它是类型安全的 tagged union:典型实现仍是 union 加一个判别索引,但活跃成员的跟踪、取值与访问都由库完成,不必手写标签判断,也避开了切换活跃成员时的未定义行为。它替代的正是上面那套手工劳动:自己记标签、自己管生命期。
它在哪些场景里适用
理解了布局和活跃成员规则之后,再看 union 在实际代码里被用在哪里。
协议与二进制数据解析。 从网络包、磁盘文件读进来的是一段字节流,接收缓冲区可以用一个 union 来声明,一个成员提供逐字节访问,另一个成员则让整块缓冲满足目标结构体的对齐要求。
#include <cstdint>
#include <cstring>
#include <cstdio>
struct Header {
std::uint16_t type;
std::uint16_t len;
};
// 接收缓冲区:raw 提供逐字节访问,hdr 提供目标结构体的对齐
union Buf {
unsigned char raw[sizeof(Header)];
Header hdr;
};
int main()
{
Buf buf{};
unsigned char net[4] = {0x01, 0x00, 0x64, 0x00};
// 模拟从网络按字节收满一个包
std::memcpy(buf.raw, net, sizeof(net));
// 字节收齐后,用 memcpy 还原成结构体
Header h;
std::memcpy(&h, buf.raw, sizeof(h));
std::printf("type=%d len=%d\n", h.type, h.len);
return 0;
}
运行结果:
type=1 len=100
union 在这里解决的是缓冲区的对齐问题,全程只写 raw、只读 raw,没有跨成员读,字节收齐后靠 memcpy 还原成结构体,这是 C++ 里合法可行的写法。
值容器。 一个变量在不同时刻要装不同类型的值,上一节手写的”判别标签加 union”就是这类用法的雏形。
#include <cstdio>
enum class Kind { Int, Double };
struct Value {
Kind tag;
union {
int i;
double d;
} u;
};
int main()
{
// 写入时先置标签,再写对应成员
Value v{};
v.tag = Kind::Int;
v.u.i = 42;
// 读取时先看标签,只读活跃的那个成员
if (v.tag == Kind::Int)
std::printf("int = %d\n", v.u.i);
return 0;
}
运行结果:
int = 42
写谁读谁由标签保证,这就是 tagged union 完整的工作方式。
这类需求在实际项目里,直接用 C++17 起的 std::variant 更省事,标签与生命期都由库代管。
与 C 接口互通。 不少 C 库的接口直接把 union 用作参数类型,调用方绕不开。Linux 的 epoll 就是一例,它的用户数据字段在 glibc 头文件 sys/epoll.h 里就是一个四选一的 union。
#include <stdint.h>
// Linux glibc 头文件 sys/epoll.h 中的真实定义
typedef union epoll_data
{
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
C++ 代码调用 epoll_ctl 注册事件时,就要给这个 union 填值。四个成员里选哪一个由调用方自己定,比如把文件描述符写进 fd。内核把这份数据随注册信息存起来,事件触发时再原样带回。取回后要从同一个成员去读,注册时写的是 fd,取数据时读的也是 fd。写谁读谁,这正是活跃成员规则在 C 接口里的实际形态。
内存紧凑的表示。 有些数据天然是几选一的关系,比如一个设置项的值要么是整数、要么是字符串,任何时刻只会用到其中一种。把这样互为备选的几个类型放进 union,它们叠在同一块内存上,整个 union 只占最大成员那一份空间,比 struct 为每个成员各留一份要省。
把同一组备选类型分别装进 union 和 struct,差别立刻显现:
#include <cstdio>
// 同一组备选类型:一个并进 union,一个并排进 struct
union Packed {
int i;
const char* s;
};
struct Spaced {
int i;
const char* s;
};
int main()
{
std::printf("sizeof(Packed) = %zu\n", sizeof(Packed));
std::printf("sizeof(Spaced) = %zu\n", sizeof(Spaced));
return 0;
}
运行结果:
sizeof(Packed) = 8
sizeof(Spaced) = 16
两个成员在 struct 里各占一份,中间还要为对齐补上 4 字节,合计 16 字节。并进 union 后只留最大的那一份,合计 8 字节。
局限性与实践问题
union 把空间省了下来,代价是把一部分检查工作留给了程序员。实际使用中最容易出问题的是下面几处。
跨成员读在 C++ 里是未定义行为。 读取非活跃成员时,那个成员的对象还不存在,编译器不必保证读出什么。开优化之后,可能读到错值,甚至这次读取会被直接优化掉。所以在 C++ 里做类型双关,要走 std::memcpy 或 std::bit_cast,不能图省事直接从另一个成员读。
活跃成员要程序员自己记。 union 本身不会记住当前存的是哪种类型,写的时候当一种类型用、读的时候当另一种类型用,编译器不会拦。切换成员时漏了 placement new、std::launder、std::start_lifetime_as 这些生命期操作,读到的同样是非活跃成员,同样是未定义行为。
共用初始序列的适用条件苛刻。 想读公共前缀,得同时满足三个条件:union 本身是 standard-layout,参与对比的那几个结构体成员也是 standard-layout,当前活跃成员就在它们之中。多数项目用不上这条规则,更多是靠 std::variant,或者在消息层用 memcpy 做显式解析来回避它。
std::variant 也不是万能的。 前三条说的是 union 自身的限制,改用 variant 同样有它的边界。它不能存引用、数组和 void。同一类型重复出现时,不能再按类型取值,只能按下标访问。
#include <variant>
int main()
{
// 类型重复时无法按类型取值,只能按下标构造和访问
std::variant<int, int> v(std::in_place_index<1>, 42);
return std::get<1>(v) == 42 ? 0 : 1;
}
反过来,这些限制在 union 里都不存在,数组可以直接做成员,同类型成员各起各的名字互不干扰。
#include <cstdio>
// 数组成员:variant 存不了数组,union 可以直接装
union Data {
int i;
char raw[16];
};
int main()
{
Data d{};
d.raw[0] = 'x';
std::printf("%c\n", d.raw[0]);
return 0;
}
所以写底层协议解析、与 C 互通的代码时,union 仍然是要用的工具。
可读性与调试成本。 union 的成员布局不像 struct 那样一眼看清,编译器也不会检查活跃成员是否匹配,运行期出问题时的错误信息往往不直观。
小结
union 的内存布局可归纳为以下三点:
- 所有成员从偏移 0 共享同一段内存,这是它区别于 struct 的根本。
- union 的大小至少等于最大成员,实际会补齐到对齐量的整数倍,末尾可能填充。
- 同一时刻只有一个成员活跃,C++ 中读取非活跃成员属于未定义行为(共用初始序列的情形除外),类型双关应改用 memcpy 或 std::bit_cast。
前面说的这些场景,无论用 union 做什么,要弄清楚的都是同一类问题:它占多少字节、字段落在哪个偏移,哪种读法会触发未定义行为,对齐又补了多少填充。前文的布局规则、活跃成员规则和汇编对照,讲的就是这些内容。
参考资料
- ISO/IEC 14882:2024, Programming Languages — C++:[class.union](共用地址、活跃成员、成员函数与虚函数限制、共用初始序列)、[class.mem](对象布局、大小与对齐、共用初始序列)、[basic.lval](严格别名与类型可访问)、[bit](std::bit_cast)、[obj.lifetime](std::start_lifetime_as)。
- ISO/IEC 9899:2011, Programming Languages — C:6.5.2.3 结构体与联合体成员及其脚注(union 类型双关的重解释语义与陷阱表示余地)。
- P0593R6, Implicit creation of objects for low-level object manipulation:核心语言部分于 C++20 采纳,规定 memcpy、malloc、bit_cast 等操作在目标存储区隐式创建对象。
- P2590R2, Explicit lifetime management:std::start_lifetime_as 与 start_lifetime_as_array,C++23 采纳。
- cppreference.com:Union declaration、std::variant、std::start_lifetime_as、std::bit_cast 等条目的转述与可检索示例。
- System V Application Binary Interface, AMD64 Architecture Processor Supplement (psABI):x86-64 的聚合对象大小与对齐约定。