C++ union 的一种妙用法 前言union是 C 时代留下来的少数几个「看起来简单、实际很容易用错」的语言设施之一。它的表面语义只有一句所有成员共用同一块存储同一时刻只有一个成员是「活的」。但「活的」这三个字在 C 里意味着对象的生命周期而生命周期恰恰是很多人写 union 时完全忽略的东西。常见的误解有三个。第一认为「往一个成员写、从另一个成员读」是天经地义的类型转换手段实际上在 C 里这通常是未定义行为undefined behavior, UB只有少数特定情形例外。第二认为 union 只能放基本类型实际上它可以有成员函数、可以放带构造函数的类型只是必须自己管理构造与析构。第三认为std::variant出现之后 union 就没有用了实际上在需要精确控制布局、避免额外开销或者与 C 结构体对接的场合union 仍然是不可替代的。本文讲三种我认为真正值得掌握的用法用带标签的联合表达「多选一」的数据、用 union 手工管理对象生命周期以实现可选值、以及利用公共初始序列在标准允许的范围内复用布局。最后专门用一节讲清楚类型双关type punning为什么不能靠 union 来做以及正确的替代写法。示例按 C17 编写没有在本机编译或运行过。一、先搞清楚 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 有虚函数或基类编译失败语言层面禁止二、妙用一带标签的联合表达「多选一」最实用的场景是协议报文或配置值一条记录可能是整数、浮点或布尔同一时刻只有一种。用三个独立字段加一个枚举会浪费空间而且「哪个字段有效」靠约定用基类和虚函数会引入指针、虚表和堆分配。带标签的联合在布局上是紧凑的语义也是明确的。// 适用于 C17#include cstdint#include iostreamclass 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_castdouble(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在 C17 才进入标准库。在此之前很多库用 union 加一个布尔标记来实现「可能没有值」的容器。这个技巧今天仍然值得理解因为它把「存储」和「生命周期」分离得最清楚也是理解optional、variant内部实现的入口。// 适用于 C17#include new#include stdexcept#include string#include utilitytemplate class Tclass Optional {public:Optional() noexcept : has_(false) {}Optional(const Optional) delete;Optional operator(const Optional) delete;~Optional() { reset(); }template class... ArgsT emplace(Args... args){reset();T* p ::new (static_castvoid*(storage_.value))T(std::forwardArgs(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(){Optionalstd::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 可以在不引入继承的情况下实现「共同前缀 不同载荷」的布局。// 适用于 C17#include cstdint#include iostreamstruct 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 在文档里把它列为受支持的扩展但「某个编译器支持」不等于「标准保证」换平台或换优化等级都可能变化。标准认可的写法有两种。C17 及以前用std::memcpy它按字节复制不涉及类型双关编译器通常能识别并优化成一条移动指令// 适用于 C17#include cstdint#include cstringstd::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;}C20 起有更直接的std::bit_cast它要求源类型和目标类型大小相同、都是平凡可复制类型语义清晰且可以用于常量表达式// 需要 C20#include bit#include cstdintstd::uint32_t bits(float x){return std::bit_caststd::uint32_t(x);}如果你的目标只是「按字节观察一个对象」也可以用reinterpret_castconst unsigned char*得到指向对象表示的指针这是标准明确允许的但它只适合读写字节不适合把字节重新解释成另一种类型。常见坑点❌ 写u.f 1.0f;之后直接读u.i认为这是标准的位转换。✅ 在 C 中这是 UB用std::memcpyC17或std::bit_castC20。❌ 在 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并且要把构造、析构和标签管理当成一等公民对待。