目录

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 的大小由两条规则共同决定:

  1. union 的大小至少等于其最大成员的大小。
  2. 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 的内存布局可归纳为以下三点:

  1. 所有成员从偏移 0 共享同一段内存,这是它区别于 struct 的根本。
  2. union 的大小至少等于最大成员,实际会补齐到对齐量的整数倍,末尾可能填充。
  3. 同一时刻只有一个成员活跃,C++ 中读取非活跃成员属于未定义行为(共用初始序列的情形除外),类型双关应改用 memcpy 或 std::bit_cast。

前面说的这些场景,无论用 union 做什么,要弄清楚的都是同一类问题:它占多少字节、字段落在哪个偏移,哪种读法会触发未定义行为,对齐又补了多少填充。前文的布局规则、活跃成员规则和汇编对照,讲的就是这些内容。

参考资料

  1. ISO/IEC 14882:2024, Programming Languages — C++:[class.union](共用地址、活跃成员、成员函数与虚函数限制、共用初始序列)、[class.mem](对象布局、大小与对齐、共用初始序列)、[basic.lval](严格别名与类型可访问)、[bit](std::bit_cast)、[obj.lifetime](std::start_lifetime_as)。
  2. ISO/IEC 9899:2011, Programming Languages — C:6.5.2.3 结构体与联合体成员及其脚注(union 类型双关的重解释语义与陷阱表示余地)。
  3. P0593R6, Implicit creation of objects for low-level object manipulation:核心语言部分于 C++20 采纳,规定 memcpy、malloc、bit_cast 等操作在目标存储区隐式创建对象。
  4. P2590R2, Explicit lifetime management:std::start_lifetime_as 与 start_lifetime_as_array,C++23 采纳。
  5. cppreference.com:Union declaration、std::variant、std::start_lifetime_as、std::bit_cast 等条目的转述与可检索示例。
  6. System V Application Binary Interface, AMD64 Architecture Processor Supplement (psABI):x86-64 的聚合对象大小与对齐约定。