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,除非满足下面两种例外之一:



  1. 读取的是公共初始序列(common initial sequence)中的成员,且涉及的结构体都是标准布局类型。

  2. 你读的是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*>得到指向对象表示的指针,这是标准明确允许的;但它只适合读写字节,不适合把字节重新解释成另一种类型。


常见坑点



  1. ❌ 写u.f = 1.0f;之后直接读u.i,认为这是标准的位转换。


✅ 在 C++ 中这是 UB;用std::memcpy(C++17)或std::bit_cast(C++20)。



  1. ❌ 在 union 里放std::string,却不写构造函数和析构函数。


✅ 非平凡成员会让默认构造/析构被删除;要么手工 placement new 加显式析构,要么改用std::variant。



  1. ❌ 切换成员前不析构旧成员,直接覆盖。


✅ 先调用旧成员的析构函数,再 placement new 构造新成员,否则旧成员持有的资源会泄漏。



  1. ❌ 读取 union 的成员时不检查标签,靠「上次写的是哪个」来猜。


✅ 标签和数据必须一起更新、一起读取;没有标签的 union 无法安全地区分活跃成员。



  1. ❌ 把公共初始序列的规则扩大化,认为「布局差不多的结构体都能互读」。


✅ 只有标准布局类型、且成员类型与顺序完全一致的前缀才受保护,其余仍是 UB。



  1. ❌ 用 union 的数组形式去序列化结构体,再把字节直接写入文件。


✅ 对象表示包含填充字节,填充内容不确定;序列化应逐字段写入并明确字节序。



  1. ❌ 在 union 里放带虚函数的类,或试图让 union 继承某个基类。


✅ 语言层面禁止;需要多态就用基类指针或std::variant。



  1. ❌ 认为std::variant一定比手写 union 慢很多,于是全部手写。


✅ 两者布局相近,实际开销取决于访问方式和优化;应该测量而不是假设,且手写版本的维护成本往往更高。


总结




用法价值代价



带标签联合布局紧凑,无堆分配,语义明确标签需手工维护,非平凡成员要写生命周期代码

手工 optional精确控制构造与析构,理解标准库实现拷贝、移动、异常安全都要自己处理

公共初始序列共享前缀的多种布局,零额外开销仅限标准布局类型,规则边界窄

类型双关看似方便C++ 中是 UB,应改用memcpy或bit_cast



union 的「妙」不在于它能省几个字节,而在于它把存储与对象生命周期拆开,让你在需要的时候精确控制布局。代价同样明确:标准只保护很窄的几种情形,其余全靠你自己保证。日常业务代码里,std::variant和std::optional已经覆盖了绝大多数需求;只有在与 C 结构体对接、实现底层容器、或者确实需要压布局时,才值得亲手写 union,并且要把构造、析构和标签管理当成一等公民对待。




相关新闻

手写JSON解析器:从词法分析到语法树的编译原理实战

手写JSON解析器:从词法分析到语法树的编译原理实战

1. 从一段JSON文本到内存对象&#xff1a;解析器到底在做什么很多人第一次接触编译原理&#xff0c;都是从"写一个JSON解析器"开始的。这个选择很聪明——JSON的语法足够简单&#xff0c;简单到一页纸就能写完文法&#xff1b;但它又足够完整&#xff0c;完整到能覆盖…

2026/11/15 14:44:35 阅读更多 →
Java俄罗斯方块源码解析:游戏主循环、碰撞检测与消行实现

Java俄罗斯方块源码解析:游戏主循环、碰撞检测与消行实现

简介&#xff1a;这是一份面向Java初学者与游戏编程爱好者的俄罗斯方块完整源码&#xff0c;以Java语言实现&#xff0c;适合用来理解经典游戏的核心逻辑与面向对象设计。源码围绕游戏主面板、方块形状、流程控制器与程序入口等关键类展开&#xff0c;涵盖游戏循环、方块生成与…

2026/11/15 14:44:34 阅读更多 →
NLP与无人船:从自然语言指令到自主靠泊的完整技术链路

NLP与无人船:从自然语言指令到自主靠泊的完整技术链路

去年我带着组里的几位同学把一个挺有意思的项目从零做到了仿真验证——给一艘无人船加一套自然语言操控链路&#xff0c;让操作员可以直接说“慢速驶向测试场北侧码头&#xff0c;左舷靠泊”或者“绕开右前方漂浮物&#xff0c;停到A区泊位”&#xff0c;船载系统自己去完成意图…

2026/11/15 13:18:22 阅读更多 →

最新新闻

Electron桌面应用开发实战:从环境搭建到打包发布全流程

Electron桌面应用开发实战:从环境搭建到打包发布全流程

做前端这些年&#xff0c;我大部分时间都在跟浏览器标签页打交道&#xff0c;直到第一次把桌面应用跑起来&#xff0c;才发现原来自己一直被限制在地址栏和刷新按钮围起来的框框里。Electron这个词&#xff0c;最开始是在某段时间频繁出现在同事的讨论里&#xff0c;一个用HTML…

2026/11/15 5:13:35 阅读更多 →
滑盖智能锁T109深度解析:结构原理、安装调试与故障排查

滑盖智能锁T109深度解析:结构原理、安装调试与故障排查

简介&#xff1a;亚太天能T109滑盖智能锁产品说明书是一份PDF格式的官方技术文档&#xff0c;主要面向智能锁安装人员、终端用户及售后维护者&#xff0c;帮助快速掌握产品安装、配置与常见故障处理。全包仅1个PDF文件&#xff0c;大小4.37MB&#xff0c;内容结构清晰&#xff…

2026/11/15 5:13:35 阅读更多 →
PMSM弱磁控制原理与Simulink建模:高速AGV驱动系统设计指南

PMSM弱磁控制原理与Simulink建模:高速AGV驱动系统设计指南

上个月我在调试一台高速AGV的驱动系统&#xff0c;整车要求空载跑到8m/s&#xff0c;电机转速已经拉到额定点&#xff0c;驱动器的输出电压也顶到了母线电压极限附近。再往上踩速度指令&#xff0c;转速纹丝不动&#xff0c;三相电流反而开始震荡。那会儿我的第一反应就是&…

2026/11/15 5:13:35 阅读更多 →
C++核心知识点:从内存管理到STL的实战指南

C++核心知识点:从内存管理到STL的实战指南

C 这门语言我前前后后折腾了十几年&#xff0c;用它写过嵌入式驱动、做过游戏服务端&#xff0c;也搭过基础组件库。每次看到新人一头扎进"C 核心知识点"这个搜索词&#xff0c;我其实挺感慨的——这门语言的难点从来不是语法多&#xff0c;而是知识点之间的耦合太深…

2026/11/15 3:37:41 阅读更多 →
T109滑盖智能锁怎么装?安装调试与故障排查全攻略

T109滑盖智能锁怎么装?安装调试与故障排查全攻略

简介&#xff1a;亚太天能T109滑盖智能锁产品说明书以PDF格式发布&#xff0c;适合智能锁用户、安装人员与售后维修人员阅读。内容围绕T109系列指纹密码刷卡锁展开&#xff0c;先介绍前面板、后面板、锁体、机械钥匙、电池盒盖等部件名称与功能&#xff0c;再给出完整安装步骤&…

2026/11/15 4:18:38 阅读更多 →
LLM知识降熵:从信息垄断到理解权下放

LLM知识降熵:从信息垄断到理解权下放

你有没有过这种经历&#xff1a;翻开源码注释、论文摘要或者一份专业报告&#xff0c;每个字都认识&#xff0c;连在一起却像天书。想找人聊聊&#xff0c;身边要么没人懂&#xff0c;要么懂的懒得跟你解释。后来我试着把同一段内容丢给一个大语言模型&#xff0c;几秒钟之后&a…

2026/11/15 5:13:35 阅读更多 →

日新闻

周新闻

月新闻