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/10/21 5:11:04 阅读更多 →
Java俄罗斯方块源码解析:游戏主循环、碰撞检测与消行实现

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

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

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

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

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

2026/10/21 5:11:04 阅读更多 →

最新新闻

SpringBoot整合SSM实验室预约系统:从设计到答辩完整实战

SpringBoot整合SSM实验室预约系统:从设计到答辩完整实战

实验室预约这类系统&#xff0c;在我接触过的毕业设计和实际项目里&#xff0c;出现频率相当高。原因也很直接&#xff1a;它的业务流程足够完整&#xff0c;涉及用户角色、资源管理、状态流转、时间冲突判断这些核心逻辑&#xff0c;却又不像电商或社交系统那样无限膨胀&#…

2026/10/21 5:50:19 阅读更多 →
STM32+PT100工业级温度采集系统设计与实现

STM32+PT100工业级温度采集系统设计与实现

简介&#xff1a;本资源是一套基于STM32单片机的PT100铂电阻高精度温度采集系统完整开发包&#xff0c;面向嵌入式初学者、电子设计竞赛学生及工业测温应用开发者&#xff0c;解决低温至高温范围内稳定、抗干扰温度传感与数字转换的核心问题。压缩包共277个文件&#xff0c;含6…

2026/10/21 5:50:19 阅读更多 →
C++控制台游戏迭代实践:迷宫生成、存档与回合制战斗设计

C++控制台游戏迭代实践:迷宫生成、存档与回合制战斗设计

1. 版本迭代概览&#xff1a;3.17更新到底改了什么"勇闯迷塔"是我用C写的一个控制台小游戏&#xff0c;从最初十几层的纯迷宫探索&#xff0c;到3.17版本加入存档、战斗和新楼层&#xff0c;前后断断续续改了大概一个多月。这次更新正好在3月17日完成打包&#xff0c…

2026/10/21 5:50:19 阅读更多 →
基于CNN卷积神经网络的辣椒类别识别:高性价比计算机视觉毕设实战指南

基于CNN卷积神经网络的辣椒类别识别:高性价比计算机视觉毕设实战指南

每年开题季&#xff0c;都会有一批同学卡在同一个位置上&#xff1a;不想做MNIST手写数字、猫狗分类这种已经被写烂了的题目&#xff0c;又不敢直接挑战语义分割、目标检测这类工程量大得吓人的方向。如果你刚好在这个路口纠结&#xff0c;我推荐一个性价比非常高的选择——基于…

2026/10/21 5:50:19 阅读更多 →
自主移动机器人建图导航实战:lingbot-map模块化方案全解析

自主移动机器人建图导航实战:lingbot-map模块化方案全解析

做自主移动机器人&#xff0c;最磨人的往往不是底盘控制&#xff0c;也不是避障算法&#xff0c;而是那张地图。很多人以为“地图”就是雷达扫一圈输出一张图&#xff0c;实际上它背后牵扯坐标变换、传感器标定、回环检测、粒子滤波、代价地图膨胀这一整条链路。lingbot-map 这…

2026/10/21 5:50:18 阅读更多 →
Elasticsearch集群安全配置:TLS/SSL与密钥库管理全解析

Elasticsearch集群安全配置:TLS/SSL与密钥库管理全解析

给Elasticsearch集群做安全配置这件事&#xff0c;远比想象中麻烦。很多人以为在elasticsearch.yml里加几行TLS/SSL配置就能收工&#xff0c;结果往往是节点之间互相不认证书、Kibana连不上、客户端报handshake failed&#xff0c;最后只能回滚。我这次把一套三节点ES集群从裸H…

2026/10/21 5:49:18 阅读更多 →

日新闻

统一内存+量化:锐龙AI Max PRO 400如何本地跑320B大模型

统一内存+量化:锐龙AI Max PRO 400如何本地跑320B大模型

这段时间硬件圈和AI圈都在传一个事&#xff1a;AMD锐龙AI Max PRO 400系列把最高192GB统一内存搬进了PC形态的产品里&#xff0c;按宣传口径&#xff0c;本地跑320B参数的大家伙不再是纸面概念。我第一反应倒不是“内存真大”&#xff0c;而是这话背后的含金量——它把过去只能…

2026/10/21 0:00:11 阅读更多 →
AI助手深度使用体验:对话质量、长文本处理与指令遵循如何留住用户

AI助手深度使用体验:对话质量、长文本处理与指令遵循如何留住用户

1. 从“封号重开”这个行为说起&#xff1a;用户到底在追求什么“封号重开”这四个字&#xff0c;放在任何产品语境里都带着一股决绝的味道。一个账号从零开始积累使用记录、调教偏好、沉淀工作流&#xff0c;背后是大量的时间和精力成本。正常情况下&#xff0c;没有人愿意主动…

2026/10/21 0:00:11 阅读更多 →
ROS中YOLOv3工程落地:通信同步、坐标变换与性能调优

ROS中YOLOv3工程落地:通信同步、坐标变换与性能调优

1. 为什么在ROS里硬刚YOLOv3不是“炫技”&#xff0c;而是工程落地的必经之路我第一次在某高校机器人实验室看到那台搭载Jetson TX2的移动底盘原地打转、反复识别错垃圾桶时&#xff0c;就意识到&#xff1a;单纯跑通darknet_ros demo和真正让YOLOv3在ROS里“干活”&#xff0c…

2026/10/21 0:00:11 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/20 10:02:13 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/20 20:43:59 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/21 4:27:17 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 10:02:08 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:17 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:45 阅读更多 →