一、为什么需要标签联合体
假设你在写一个 JSON 解析器。一个 JSON 值可能是 null、布尔、数字、字符串、数组或对象——六种完全不同的类型。怎么用一个变量表示它们?
最直觉的做法是 void* 或 interface{}:什么都能往里塞,取出来的时候自己判断类型。但问题马上来了——你不知道里面到底存的是什么。把一个字符串当数字用,编译器不会报错,运行时直接崩溃。C 语言里 void* 是类型安全的黑洞,Go 的 interface{} 也只是把类型检查推迟到了运行时,而且每次取值都要类型断言,既啰嗦又容易遗漏分支。
那用类继承呢?定义一个基类 JsonValue,再派生 JsonNull、JsonBool、JsonNumber……可以,但杀鸡用牛刀。六种类型就要六个类、六个头文件、一套虚函数表,仅仅为了表示一个值可能是什么。更麻烦的是,加一个新操作要改所有子类——想给所有 JSON 值加个 serialize() 方法?六个类全得动。
标签联合体就是在这两个极端之间找到的平衡点:用一个标签(tag)标记当前值的类型,用一个联合体(union)存储实际的值。取值前先检查标签,根据标签决定怎么解释联合体里的数据。简单、直接、零虚函数开销。
二、现实类比
快递站处理包裹。每个包裹上贴着一张标签:「易碎」「生鲜」「普通」。标签决定了后续所有操作——易碎品轻拿轻放、生鲜品优先派送、普通品走标准流程。
包裹本身只有一个,但标签告诉你该怎么对待它。如果标签撕了,工作人员就不知道里面是玻璃杯还是生鲜水果,只能猜——猜错了,要么摔碎要么变质。标签联合体里的 tag 就是这张标签,union 就是那个包裹:标签决定行为,包裹承载内容,缺一不可。
三、核心思想
标签联合体由两部分组成:一个枚举类型的标签,和一个覆盖所有可能值类型的联合体。运行时通过检查标签来决定如何处理联合体中的数据。
内存布局:标签占固定大小(通常 1-4 字节),联合体占所有变体中最大那个的大小。因为联合体的所有成员共享同一块内存,同一时刻只有一种类型是有效的。整体大小 = 标签大小 + 最大变体大小(可能还有对齐填充)。
// C 语言中的典型布局enum ValueTag { TAG_INT, TAG_STRING, TAG_BOOL };
struct TaggedValue { enum ValueTag tag; // 4 字节(标签) union { // 最大变体决定大小 int int_val; // 4 字节 char* str_val; // 8 字节(64 位系统) bool bool_val; // 1 字节 } data; // 实际占 8 字节};// 总计:4(标签)+ 4(填充)+ 8(联合体)= 16 字节分发逻辑:对标签联合体的操作通过 switch 语句分发——检查标签,跳转到对应分支处理。编译器通常能把 switch 优化为跳转表,开销接近一次数组索引。
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 构造 | O(1) | 设置标签 + 赋值对应联合体成员 |
| 读取 | O(1) | 检查标签 + 读取对应成员 |
| 类型判断 | O(1) | 直接比较标签值 |
| 分发操作 | O(1) | switch 跳转表,分支数不影响速度 |
| 内存开销 | 标签 + 最大变体 | 联合体共享内存,不累加 |
四、变体与对比
标签联合体不是表示「多种类型」的唯一方式。下表对比四种常见方案:
| 方案 | 类型安全 | 加新操作 | 加新类型 | 内存开销 | 典型语言 |
|---|---|---|---|---|---|
| 标签联合体 | 安全(switch 覆盖检查) | 容易(加函数) | 困难(改所有 switch) | 低(标签 + 最大变体) | C、Rust enum |
| 类继承 | 安全(虚函数分发) | 困难(改所有子类) | 容易(加子类) | 高(vtable + 各自字段) | Java、C++ |
| void/interface{}* | 不安全(无编译期检查) | N/A | N/A | 低(一个指针) | C、Go |
| Rust enum | 安全(穷尽匹配) | 容易(加函数) | 困难(改所有 match) | 最低(编译器优化标签大小) | Rust |
这里体现了经典的 Expression Problem(表达式问题):数据类型和操作构成一个二维矩阵,标签联合体方便加操作(加一行函数),不方便加类型(要改所有 switch);类继承正好反过来,方便加类型(加一个子类),不方便加操作(要改所有子类)。选择哪种方案,取决于你的需求更常变的是类型还是操作。
Rust 的 enum 本质上就是标签联合体,但编译器会强制你穷尽匹配所有变体——漏掉一个编译都过不了。这是 Rust 相比 C 手写标签联合体的核心优势:把运行时的遗漏分支提升为编译期错误。
五、多语言实现
Go:手动标签联合体
Go 没有代数数据类型,但可以用 iota 枚举 + 结构体嵌套联合体字段来模拟。下面是一个简化版的 JSON 值实现:
package jsonvalue
import "fmt"
// Tag 标记当前值的类型type Tag int
const ( TagNull Tag = iota TagBool TagInt TagFloat TagString)
// Value 标签联合体:tag 决定 data 中哪个字段有效type Value struct { tag Tag // 所有变体平铺在结构体中,同一时刻只有一个有效 boolVal bool intVal int64 floatVal float64 stringVal string}
// 构造函数——保证标签和值一致func Int(v int64) Value { return Value{tag: TagInt, intVal: v}}
func String(v string) Value { return Value{tag: TagString, stringVal: v}}
// Type 返回当前标签func (v Value) Type() Tag { return v.tag }
// String 根据 tag 分发,生成可读表示func (v Value) String() string { switch v.tag { case TagNull: return "null" case TagBool: return fmt.Sprintf("%t", v.boolVal) case TagInt: return fmt.Sprintf("%d", v.intVal) case TagFloat: return fmt.Sprintf("%g", v.floatVal) case TagString: return fmt.Sprintf("%q", v.stringVal) default: return "unknown" }}Go 的实现把所有变体字段平铺在结构体中,而不是用 C 风格的 union。这意味着内存占用是所有字段之和,而非最大字段。好处是简单安全(不需要 unsafe),坏处是浪费内存。如果变体大小差异大(比如一个 int64 和一个 []string),可以考虑用 interface{} 存值、用 Tag 做类型判断——本质上还是标签联合体,只是把联合体换成了 Go 的类型系统。
TypeScript:判别联合类型
TypeScript 的判别联合(Discriminated Union)是标签联合体在类型系统层面的直接支持。编译器会根据标签自动收窄类型,不需要手动类型断言:
// 标签联合体:kind 是判别属性(标签)type JsonValue = | { kind: "null" } | { kind: "bool"; value: boolean } | { kind: "int"; value: number } | { kind: "float"; value: number } | { kind: "string"; value: string };
// 构造const num: JsonValue = { kind: "int", value: 42 };const str: JsonValue = { kind: "string", value: "hello" };
// 分发:switch 后自动收窄类型,无需类型断言function stringify(v: JsonValue): string { switch (v.kind) { case "null": return "null"; case "bool": return v.value ? "true" : "false"; // v.value 自动识别为 boolean case "int": case "float": return v.value.toString(); // v.value 自动识别为 number case "string": return `"${v.value}"`; // v.value 自动识别为 string }}TypeScript 的实现比 Go 更优雅:编译器会检查 switch 是否覆盖了所有变体(开启 strict 模式时),漏掉一个变体会报错。这和 Rust 的穷尽匹配是同一个思路——让编译器替你守门。
六、生产验证
标签联合体不是纸上谈兵,大量生产级项目用它作为核心数据结构。
Godot Engine — Variant 类型
Godot 的 GDScript 是动态类型语言,每个值都是一个 Variant。Variant 内部用 Type 枚举(38 种类型)做标签,配合联合体存储实际值。从 NIL、BOOL、INT 到 VECTOR3、TRANSFORM3D、DICTIONARY,所有 GDScript 值统一由 Variant 承载。
- 源码:godot/variant.h#L96-L146 —
Type枚举定义了所有变体类型 - 源码:godot/variant.h#L148 —
Type type = NIL;标签字段
PyTorch — IValue 类型
PyTorch 的 TorchScript 解释器用 IValue 表示运行时值。IValue 内部有一个 Tag 枚举(Tensor、Int、Double、String、Tuple 等 20 多种),配合联合体存储不同类型的值。源码注释明确指出标签编码可能变化(比如未来改用 NaN boxing),外部代码应通过 isX() 方法判断类型,不要直接操作标签。
- 源码:pytorch/ivalue.h#L159-L187 —
TORCH_FORALL_TAGS宏定义所有标签 - 源码:pytorch/ivalue.h#L1205-L1209 —
enum class Tag展开
Lua — TValue 类型
Lua 虚拟机中每个值都是 TValue(Tagged Value)。TValue 由一个 Value 联合体(存 gc 指针、整数、浮点数等)和一个 tt_ 类型标签字节组成。标签字节的低 4 位编码基础类型,高位编码变体信息(比如短字符串 vs 长字符串、整数 vs 浮点数)。这是教科书级的标签联合体实现——每个栈槽、局部变量、表条目都是 TValue。
- 源码:lua/lobject.h#L49-L69 —
Value联合体和TValue结构体 - 源码:lua/lua.h#L64-L73 — 基础类型常量定义
七、小结
适合用标签联合体的场景:
- 脚本语言的值类型(Godot Variant、Lua TValue)——类型有限且稳定,操作频繁变化
- 序列化格式的值表示(JSON、protobuf oneof)——类型在协议中固定,解析和序列化是主要操作
- 编译器 IR 中的指令操作数——类型在编译器设计阶段确定,遍历和变换操作经常新增
- 数据库驱动的返回值——SQL 查询结果类型有限,不同语言绑定需要统一表示
不适合用标签联合体的场景:
- 同构集合——所有元素类型相同,直接用数组,不需要标签
- 性能敏感的内层循环——每次操作都要 switch 分发,分支预测失败代价高
- 变体超过 50 种的深层层次——switch 维护成本爆炸,改用类继承或 trait 对象
- 需要频繁扩展类型的场景——加一个类型要改所有 switch,类继承更合适
八、参考资料
- Algebraic data type - Wikipedia,标签联合体的理论基础
- The Expression Problem - Philip Wadler,1998,类型与操作的可扩展性权衡
- Godot Variant 源码 - 38 种类型的标签联合体,GDScript 的值类型基础
- Lua TValue 源码 - 教科书级标签联合体实现,Lua VM 的核心数据结构
- NaN Boxing - 用 NaN 的空闲位编码标签,把标签和值压缩到一个 64 位整数中
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






