前言union是 C 时代留下来的少数几个「看起来简单、实际很容易用错」的语言设施之一。它的表面语义只有一句:所有成员共用同一块存储,同一时刻只有一个成员是「活的」。但「活的」这三个字在 C++ 里意味着对象的生命周期,而生命周期恰恰是很多人写 union 时完全忽略的东西。常见的误解有三个。第一,认为「往一个成员写、从另一个成员读」是天经地义的类型转换手段,实际上在 C++ 里这通常是未定义行为(undefined behavior, UB),只有少数特定情形例外。第二,认为 union 只能放基本类型,实际上它可以有成员函数、可以放带构造函数的类型,只是必须自己管理构造与析构。第三,认为std::variant出现之后 union 就没有用了,实际上在需要精确控制布局、避免额外开销或者与 C 结构体对接的场合,union 仍然是不可替代的。本文讲三种我认为真正值得掌握的用法:用带标签的联合表达「多选一」的数据、用 union 手工管理对象生命周期以实现可选值、以及利用公共初始序列在标准允许的范围内复用布局。最后专门用一节讲清楚类型双关(type punning)为什么不能靠 union 来做,以及正确的替代写法。示例按 C++17 编写,没有在本机编译或运行过。一、先搞清楚 union 的语义一个 union 的大小至少能容纳最大的成员,对齐要求是所有成员对齐要求的最大值,这两点决定了它在布局上的价值:不会为了「可能用到的最大成员」之外的东西多付一个字节(尾部填充除外)。真正需要记住的是生命周期规则。对 union 来说,在任何时刻最多只有一个非静态数据成员是活跃的。写一个成员会让它变成活跃成员;如果它是有非平凡构造函数的类型,标准要求你在此之前用 placement new 在 union 的地址上构造它。读取一个非活跃成员是 UB,除非满足下面两种例外之一:读取的是公共初始序列(common initial sequence)中的成员,且涉及的结构体都是标准布局类型。你读的是unsigned char或std::byte形式的对象表示,这属于按字节观察,而不是按类型读取。另外还有几条硬约束:union 不能有虚函数,不能有基类,也不能作为基类;带引用成员的 union 不能默认构造;有非平凡成员的 union 的默认构造函数和析构函数会被定义为 deleted,必须自己写。操作是否合法说明写入成员 A,读成员 A合法A 是活跃成员写入成员 A,读成员 B(平凡类型)UB常见编译器扩展,但标准不保证写 A 后读公共初始序列成员合法仅限标准布局结构体按unsigned char读对象表示合法观察字节,不是类型双关union 含非平凡成员但不写构造/析构编译失败默认构造与析构被删除union 有虚函数或基类编译失败语言层面禁止二、妙用一:带标签的联合表达「多选一」最实用的场景是协议报文或配置值:一条记录可能是整数、浮点或布尔,同一时刻只有一种。用三个独立字段加一个枚举,会浪费空间,而且「哪个字段有效」靠约定;用基类和虚函数,会引入指针、虚表和堆分配。带标签的联合在布局上是紧凑的,语义也是明确的。// 适用于 C++17#include <cstdint>#include <iostream>class Value {public:enum class Kind : std::uint8_t { Int, Real, Bool };static Value make_int(int v) noexcept{Value x;x.kind_ = Kind::Int;x.u_.i = v;return x;}static Value make_real(double v) noexcept{Value x;x.kind_ = Kind::Real;x.u_.d = v;return x;}static Value make_bool(bool v) noexcept{Value x;x.kind_ = Kind::Bool;x.u_.b = v;return x;}Kind kind() const noexcept { return kind_; }double as_double() const noexcept{switch (kind_) {case Kind::Int: return static_cast<double>(u_.i);case Kind::Real: return u_.d;case Kind::Bool: return u_.b ? 1.0 : 0.0;}return 0.0;}private:Kind kind_ = Kind::Int;union Storage {int i;double d;bool b;Storage() : i(0) {}} u_;};int main(){Value a = Value::make_int(7);Value b = Value::make_real(2.5);Value c = Value::make_bool(true);std::cout << a.as_double() << ' '<< b.as_double() << ' '<< c.as_double() << '\n';return 0;}这里所有成员都是平凡类型,所以 union 的默认构造和析构是平凡的,不需要 placement new,也不会泄漏。Storage() : i(0) {}只是为了给一个确定的初始状态,让拷贝和默认构造行为可预测。访问永远先看kind_,这是整个设计成立的前提:标签和数据必须同步更新,任何一处漏写都会让后续读取变成 UB。如果成员里有std::string这类非平凡类型,代码会立刻复杂起来:必须在切换成员前显式调用旧成员的析构函数,用 placement new 构造新成员,并且手写或删除拷贝、移动、析构。这已经是在实现一个std::variant了,除非有特殊理由,直接用它更好。三、妙用二:用手工生命周期实现可选值std::optional在 C++17 才进入标准库。在此之前,很多库用 union 加一个布尔标记来实现「可能没有值」的容器。这个技巧今天仍然值得理解,因为它把「存储」和「生命周期」分离得最清楚,也是理解optional、variant内部实现的入口。// 适用于 C++17#include <new>#include <stdexcept>#include <string>#include <utility>template <class T>class Optional {public:Optional() noexcept : has_(false) {}Optional(const Optional&) = delete;Optional& operator=(const Optional&) = delete;~Optional() { reset(); }template <class... Args>T& emplace(Args&&... args){reset();T* p = ::new (static_cast<void*>(&storage_.value))T(std::forward<Args>(args)...);has_ = true;return *p;}void reset() noexcept{if (has_) {storage_.value.~T();has_ = false;}}bool has_value() const noexcept { return has_; }T& value(){if (!has_) {throw std::logic_error("Optional: no value");}return storage_.value;}private:union Storage {T value;char empty;Storage() : empty(0) {}~Storage() {}} storage_;bool has_;};int main(){Optional<std::string> opt;opt.emplace("hello");return opt.has_value() ? 0 : 1;}关键点有四个。第一,Storage的构造函数初始化empty而不是value,因为默认构造时没有活跃的T,构造一个空的char是合法的。第二,Storage的析构函数写成空函数体,真正的析构由外层Optional::~Optional调用reset完成;如果让Storage的析构去析构value,在没有值的时候就会析构一个不存在的对象。第三,emplace用 placement new 在成员地址上构造,返回的是 placement new 的返回值,而不是重新按名字访问成员,避免在对象刚构造时产生歧义。第四,拷贝构造被删除,因为正确实现它需要判断has_再决定拷贝哪个分支,写全了就是规则五的完整练习。这段代码的意义不是让你在项目里自己造optional,而是说明 union 的「妙」在于它把一块存储的所有权交给了你:你决定何时开始对象的生命周期,也由你负责结束它。标准库的std::optional、std::variant内部做的是同一件事,只是把拷贝、移动、异常安全、constexpr都处理完了。四、妙用三:公共初始序列标准里有一条容易被忽略的规则:如果 union 的多个成员是标准布局结构体,并且它们开头有相同类型的成员序列,那么在任何成员活跃时,都可以读取这段公共初始序列。这让 union 可以在不引入继承的情况下实现「共同前缀 + 不同载荷」的布局。// 适用于 C++17#include <cstdint>#include <iostream>struct Header {std::uint32_t type;std::uint32_t length;};struct TextFrame {std::uint32_t type;std::uint32_t length;char text[16];};struct BinaryFrame {std::uint32_t type;std::uint32_t length;unsigned char data[16];};union Frame {Header header;TextFrame text;BinaryFrame binary;};int main(){Frame f{};f.text.type = 1;f.text.length = 4;// 合法:读取的是公共初始序列中的成员std::cout << f.header.type << ' ' << f.header.length << '\n';return 0;}这段代码之所以合法,是因为Header、TextFrame、BinaryFrame都是标准布局类型,且前两个成员的类型和顺序完全一致。注意规则只覆盖「公共初始序列」这一段,读f.header之外的内容、或者把TextFrame的数组当成BinaryFrame的数组读,仍然是 UB。还要注意,公共初始序列规则描述的是「可以检查」,并不等于「可以随意写入另一个分支的成员」,写入仍然要遵守活跃成员的规则。这条特性在解析网络报文时很有用:先按头部读取类型和长度,再根据类型切换到具体的帧结构。但它对布局的假设很强,涉及跨平台传输时还要处理字节序和打包,别把它当成序列化的通用手段。五、类型双关:union 不是转换工具很多人第一次见到 union 是为了做「把 float 的位模式当整数看」这类操作:// 不推荐:在 C++ 中是未定义行为union Pun {float f;std::uint32_t u;};std::uint32_t bits(float x){Pun p;p.f = x;return p.u; // 读取非活跃成员}在 C 里这种写法有明确的规定(读出的值受实现影响),但在 C++ 里,读取非活跃成员是 UB。编译器完全可以把p.u优化成任意值,或者在开启严格别名分析后生成出乎意料的代码。GCC 和 Clang 在文档里把它列为受支持的扩展,但「某个编译器支持」不等于「标准保证」,换平台或换优化等级都可能变化。标准认可的写法有两种。C++17 及以前用std::memcpy,它按字节复制,不涉及类型双关,编译器通常能识别并优化成一条移动指令:// 适用于 C++17#include <cstdint>#include <cstring>std::uint32_t bits(float x){static_assert(sizeof(float) == sizeof(std::uint32_t));std::uint32_t out = 0;std::memcpy(&out, &x, sizeof out);return out;}C++20 起有更直接的std::bit_cast,它要求源类型和目标类型大小相同、都是平凡可复制类型,语义清晰且可以用于常量表达式:// 需要 C++20#include <bit>#include <cstdint>std::uint32_t bits(float x){return std::bit_cast<std::uint32_t>(x);}如果你的目标只是「按字节观察一个对象」,也可以用reinterpret_cast<const unsigned char*>得到指向对象表示的指针,这是标准明确允许的;但它只适合读写字节,不适合把字节重新解释成另一种类型。常见坑点❌ 写u.f = 1.0f;之后直接读u.i,认为这是标准的位转换。✅ 在 C++ 中这是 UB;用std::memcpy(C++17)或std::bit_cast(C++20)。❌ 在 union 里放std::string,却不写构造函数和析构函数。✅ 非平凡成员会让默认构造/析构被删除;要么手工 placement new 加显式析构,要么改用std::variant。❌ 切换成员前不析构旧成员,直接覆盖。✅ 先调用旧成员的析构函数,再 placement new 构造新成员,否则旧成员持有的资源会泄漏。❌ 读取 union 的成员时不检查标签,靠「上次写的是哪个」来猜。✅ 标签和数据必须一起更新、一起读取;没有标签的 union 无法安全地区分活跃成员。❌ 把公共初始序列的规则扩大化,认为「布局差不多的结构体都能互读」。✅ 只有标准布局类型、且成员类型与顺序完全一致的前缀才受保护,其余仍是 UB。❌ 用 union 的数组形式去序列化结构体,再把字节直接写入文件。✅ 对象表示包含填充字节,填充内容不确定;序列化应逐字段写入并明确字节序。❌ 在 union 里放带虚函数的类,或试图让 union 继承某个基类。✅ 语言层面禁止;需要多态就用基类指针或std::variant。❌ 认为std::variant一定比手写 union 慢很多,于是全部手写。✅ 两者布局相近,实际开销取决于访问方式和优化;应该测量而不是假设,且手写版本的维护成本往往更高。总结用法价值代价带标签联合布局紧凑,无堆分配,语义明确标签需手工维护,非平凡成员要写生命周期代码手工 optional精确控制构造与析构,理解标准库实现拷贝、移动、异常安全都要自己处理公共初始序列共享前缀的多种布局,零额外开销仅限标准布局类型,规则边界窄类型双关看似方便C++ 中是 UB,应改用memcpy或bit_castunion 的「妙」不在于它能省几个字节,而在于它把存储与对象生命周期拆开,让你在需要的时候精确控制布局。代价同样明确:标准只保护很窄的几种情形,其余全靠你自己保证。日常业务代码里,std::variant和std::optional已经覆盖了绝大多数需求;只有在与 C 结构体对接、实现底层容器、或者确实需要压布局时,才值得亲手写 union,并且要把构造、析构和标签管理当成一等公民对待。
前言union是 C 时代留下来的少数几个「看起来简单、实际很容易用错」的语言设施之一。它的表面语义只有一句:所有成员共用同一块存储,同一时刻只有一个成员是「活的」。但「活的」这三个字在 C++ 里意味着对象的生命周期,而生命周期恰恰是很多人写 union 时完全忽略的东西。常见的误解有三个。第一,认为「往一个成员写、从另一个成员读」是天经地义的类型转换手段,实际上在 C++ 里这通常是未定义行为(undefined behavior, UB),只有少数特定情形例外。第二,认为 union 只能放基本类型,实际上它可以有成员函数、可以放带构造函数的类型,只是必须自己管理构造与析构。第三,认为std::variant出现之后 union 就没有用了,实际上在需要精确控制布局、避免额外开销或者与 C 结构体对接的场合,union 仍然是不可替代的。本文讲三种我认为真正值得掌握的用法:用带标签的联合表达「多选一」的数据、用 union 手工管理对象生命周期以实现可选值、以及利用公共初始序列在标准允许的范围内复用布局。最后专门用一节讲清楚类型双关(type punning)为什么不能靠 union 来做,以及正确的替代写法。示例按 C++17 编写,没有在本机编译或运行过。一、先搞清楚 union 的语义一个 union 的大小至少能容纳最大的成员,对齐要求是所有成员对齐要求的最大值,这两点决定了它在布局上的价值:不会为了「可能用到的最大成员」之外的东西多付一个字节(尾部填充除外)。真正需要记住的是生命周期规则。对 union 来说,在任何时刻最多只有一个非静态数据成员是活跃的。写一个成员会让它变成活跃成员;如果它是有非平凡构造函数的类型,标准要求你在此之前用 placement new 在 union 的地址上构造它。读取一个非活跃成员是 UB,除非满足下面两种例外之一:读取的是公共初始序列(common initial sequence)中的成员,且涉及的结构体都是标准布局类型。你读的是unsigned char或std::byte形式的对象表示,这属于按字节观察,而不是按类型读取。另外还有几条硬约束:union 不能有虚函数,不能有基类,也不能作为基类;带引用成员的 union 不能默认构造;有非平凡成员的 union 的默认构造函数和析构函数会被定义为 deleted,必须自己写。操作是否合法说明写入成员 A,读成员 A合法A 是活跃成员写入成员 A,读成员 B(平凡类型)UB常见编译器扩展,但标准不保证写 A 后读公共初始序列成员合法仅限标准布局结构体按unsigned char读对象表示合法观察字节,不是类型双关union 含非平凡成员但不写构造/析构编译失败默认构造与析构被删除union 有虚函数或基类编译失败语言层面禁止二、妙用一:带标签的联合表达「多选一」最实用的场景是协议报文或配置值:一条记录可能是整数、浮点或布尔,同一时刻只有一种。用三个独立字段加一个枚举,会浪费空间,而且「哪个字段有效」靠约定;用基类和虚函数,会引入指针、虚表和堆分配。带标签的联合在布局上是紧凑的,语义也是明确的。// 适用于 C++17#include <cstdint>#include <iostream>class Value {public:enum class Kind : std::uint8_t { Int, Real, Bool };static Value make_int(int v) noexcept{Value x;x.kind_ = Kind::Int;x.u_.i = v;return x;}static Value make_real(double v) noexcept{Value x;x.kind_ = Kind::Real;x.u_.d = v;return x;}static Value make_bool(bool v) noexcept{Value x;x.kind_ = Kind::Bool;x.u_.b = v;return x;}Kind kind() const noexcept { return kind_; }double as_double() const noexcept{switch (kind_) {case Kind::Int: return static_cast<double>(u_.i);case Kind::Real: return u_.d;case Kind::Bool: return u_.b ? 1.0 : 0.0;}return 0.0;}private:Kind kind_ = Kind::Int;union Storage {int i;double d;bool b;Storage() : i(0) {}} u_;};int main(){Value a = Value::make_int(7);Value b = Value::make_real(2.5);Value c = Value::make_bool(true);std::cout << a.as_double() << ' '<< b.as_double() << ' '<< c.as_double() << '\n';return 0;}这里所有成员都是平凡类型,所以 union 的默认构造和析构是平凡的,不需要 placement new,也不会泄漏。Storage() : i(0) {}只是为了给一个确定的初始状态,让拷贝和默认构造行为可预测。访问永远先看kind_,这是整个设计成立的前提:标签和数据必须同步更新,任何一处漏写都会让后续读取变成 UB。如果成员里有std::string这类非平凡类型,代码会立刻复杂起来:必须在切换成员前显式调用旧成员的析构函数,用 placement new 构造新成员,并且手写或删除拷贝、移动、析构。这已经是在实现一个std::variant了,除非有特殊理由,直接用它更好。三、妙用二:用手工生命周期实现可选值std::optional在 C++17 才进入标准库。在此之前,很多库用 union 加一个布尔标记来实现「可能没有值」的容器。这个技巧今天仍然值得理解,因为它把「存储」和「生命周期」分离得最清楚,也是理解optional、variant内部实现的入口。// 适用于 C++17#include <new>#include <stdexcept>#include <string>#include <utility>template <class T>class Optional {public:Optional() noexcept : has_(false) {}Optional(const Optional&) = delete;Optional& operator=(const Optional&) = delete;~Optional() { reset(); }template <class... Args>T& emplace(Args&&... args){reset();T* p = ::new (static_cast<void*>(&storage_.value))T(std::forward<Args>(args)...);has_ = true;return *p;}void reset() noexcept{if (has_) {storage_.value.~T();has_ = false;}}bool has_value() const noexcept { return has_; }T& value(){if (!has_) {throw std::logic_error("Optional: no value");}return storage_.value;}private:union Storage {T value;char empty;Storage() : empty(0) {}~Storage() {}} storage_;bool has_;};int main(){Optional<std::string> opt;opt.emplace("hello");return opt.has_value() ? 0 : 1;}关键点有四个。第一,Storage的构造函数初始化empty而不是value,因为默认构造时没有活跃的T,构造一个空的char是合法的。第二,Storage的析构函数写成空函数体,真正的析构由外层Optional::~Optional调用reset完成;如果让Storage的析构去析构value,在没有值的时候就会析构一个不存在的对象。第三,emplace用 placement new 在成员地址上构造,返回的是 placement new 的返回值,而不是重新按名字访问成员,避免在对象刚构造时产生歧义。第四,拷贝构造被删除,因为正确实现它需要判断has_再决定拷贝哪个分支,写全了就是规则五的完整练习。这段代码的意义不是让你在项目里自己造optional,而是说明 union 的「妙」在于它把一块存储的所有权交给了你:你决定何时开始对象的生命周期,也由你负责结束它。标准库的std::optional、std::variant内部做的是同一件事,只是把拷贝、移动、异常安全、constexpr都处理完了。四、妙用三:公共初始序列标准里有一条容易被忽略的规则:如果 union 的多个成员是标准布局结构体,并且它们开头有相同类型的成员序列,那么在任何成员活跃时,都可以读取这段公共初始序列。这让 union 可以在不引入继承的情况下实现「共同前缀 + 不同载荷」的布局。// 适用于 C++17#include <cstdint>#include <iostream>struct Header {std::uint32_t type;std::uint32_t length;};struct TextFrame {std::uint32_t type;std::uint32_t length;char text[16];};struct BinaryFrame {std::uint32_t type;std::uint32_t length;unsigned char data[16];};union Frame {Header header;TextFrame text;BinaryFrame binary;};int main(){Frame f{};f.text.type = 1;f.text.length = 4;// 合法:读取的是公共初始序列中的成员std::cout << f.header.type << ' ' << f.header.length << '\n';return 0;}这段代码之所以合法,是因为Header、TextFrame、BinaryFrame都是标准布局类型,且前两个成员的类型和顺序完全一致。注意规则只覆盖「公共初始序列」这一段,读f.header之外的内容、或者把TextFrame的数组当成BinaryFrame的数组读,仍然是 UB。还要注意,公共初始序列规则描述的是「可以检查」,并不等于「可以随意写入另一个分支的成员」,写入仍然要遵守活跃成员的规则。这条特性在解析网络报文时很有用:先按头部读取类型和长度,再根据类型切换到具体的帧结构。但它对布局的假设很强,涉及跨平台传输时还要处理字节序和打包,别把它当成序列化的通用手段。五、类型双关:union 不是转换工具很多人第一次见到 union 是为了做「把 float 的位模式当整数看」这类操作:// 不推荐:在 C++ 中是未定义行为union Pun {float f;std::uint32_t u;};std::uint32_t bits(float x){Pun p;p.f = x;return p.u; // 读取非活跃成员}在 C 里这种写法有明确的规定(读出的值受实现影响),但在 C++ 里,读取非活跃成员是 UB。编译器完全可以把p.u优化成任意值,或者在开启严格别名分析后生成出乎意料的代码。GCC 和 Clang 在文档里把它列为受支持的扩展,但「某个编译器支持」不等于「标准保证」,换平台或换优化等级都可能变化。标准认可的写法有两种。C++17 及以前用std::memcpy,它按字节复制,不涉及类型双关,编译器通常能识别并优化成一条移动指令:// 适用于 C++17#include <cstdint>#include <cstring>std::uint32_t bits(float x){static_assert(sizeof(float) == sizeof(std::uint32_t));std::uint32_t out = 0;std::memcpy(&out, &x, sizeof out);return out;}C++20 起有更直接的std::bit_cast,它要求源类型和目标类型大小相同、都是平凡可复制类型,语义清晰且可以用于常量表达式:// 需要 C++20#include <bit>#include <cstdint>std::uint32_t bits(float x){return std::bit_cast<std::uint32_t>(x);}如果你的目标只是「按字节观察一个对象」,也可以用reinterpret_cast<const unsigned char*>得到指向对象表示的指针,这是标准明确允许的;但它只适合读写字节,不适合把字节重新解释成另一种类型。常见坑点❌ 写u.f = 1.0f;之后直接读u.i,认为这是标准的位转换。✅ 在 C++ 中这是 UB;用std::memcpy(C++17)或std::bit_cast(C++20)。❌ 在 union 里放std::string,却不写构造函数和析构函数。✅ 非平凡成员会让默认构造/析构被删除;要么手工 placement new 加显式析构,要么改用std::variant。❌ 切换成员前不析构旧成员,直接覆盖。✅ 先调用旧成员的析构函数,再 placement new 构造新成员,否则旧成员持有的资源会泄漏。❌ 读取 union 的成员时不检查标签,靠「上次写的是哪个」来猜。✅ 标签和数据必须一起更新、一起读取;没有标签的 union 无法安全地区分活跃成员。❌ 把公共初始序列的规则扩大化,认为「布局差不多的结构体都能互读」。✅ 只有标准布局类型、且成员类型与顺序完全一致的前缀才受保护,其余仍是 UB。❌ 用 union 的数组形式去序列化结构体,再把字节直接写入文件。✅ 对象表示包含填充字节,填充内容不确定;序列化应逐字段写入并明确字节序。❌ 在 union 里放带虚函数的类,或试图让 union 继承某个基类。✅ 语言层面禁止;需要多态就用基类指针或std::variant。❌ 认为std::variant一定比手写 union 慢很多,于是全部手写。✅ 两者布局相近,实际开销取决于访问方式和优化;应该测量而不是假设,且手写版本的维护成本往往更高。总结用法价值代价带标签联合布局紧凑,无堆分配,语义明确标签需手工维护,非平凡成员要写生命周期代码手工 optional精确控制构造与析构,理解标准库实现拷贝、移动、异常安全都要自己处理公共初始序列共享前缀的多种布局,零额外开销仅限标准布局类型,规则边界窄类型双关看似方便C++ 中是 UB,应改用memcpy或bit_castunion 的「妙」不在于它能省几个字节,而在于它把存储与对象生命周期拆开,让你在需要的时候精确控制布局。代价同样明确:标准只保护很窄的几种情形,其余全靠你自己保证。日常业务代码里,std::variant和std::optional已经覆盖了绝大多数需求;只有在与 C 结构体对接、实现底层容器、或者确实需要压布局时,才值得亲手写 union,并且要把构造、析构和标签管理当成一等公民对待。