mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2456 字
7 分钟
标签联合体(Tagged Union)
2026-06-13

一、为什么需要标签联合体#

假设你在写一个 JSON 解析器。一个 JSON 值可能是 null、布尔、数字、字符串、数组或对象——六种完全不同的类型。怎么用一个变量表示它们?

最直觉的做法是 void*interface{}:什么都能往里塞,取出来的时候自己判断类型。但问题马上来了——你不知道里面到底存的是什么。把一个字符串当数字用,编译器不会报错,运行时直接崩溃。C 语言里 void* 是类型安全的黑洞,Go 的 interface{} 也只是把类型检查推迟到了运行时,而且每次取值都要类型断言,既啰嗦又容易遗漏分支。

那用类继承呢?定义一个基类 JsonValue,再派生 JsonNullJsonBoolJsonNumber……可以,但杀鸡用牛刀。六种类型就要六个类、六个头文件、一套虚函数表,仅仅为了表示一个值可能是什么。更麻烦的是,加一个新操作要改所有子类——想给所有 JSON 值加个 serialize() 方法?六个类全得动。

标签联合体就是在这两个极端之间找到的平衡点:用一个标签(tag)标记当前值的类型,用一个联合体(union)存储实际的值。取值前先检查标签,根据标签决定怎么解释联合体里的数据。简单、直接、零虚函数开销。

二、现实类比#

快递站处理包裹。每个包裹上贴着一张标签:「易碎」「生鲜」「普通」。标签决定了后续所有操作——易碎品轻拿轻放、生鲜品优先派送、普通品走标准流程。

包裹本身只有一个,但标签告诉你该怎么对待它。如果标签撕了,工作人员就不知道里面是玻璃杯还是生鲜水果,只能猜——猜错了,要么摔碎要么变质。标签联合体里的 tag 就是这张标签,union 就是那个包裹:标签决定行为,包裹承载内容,缺一不可

三、核心思想#

标签联合体由两部分组成:一个枚举类型的标签,和一个覆盖所有可能值类型的联合体。运行时通过检查标签来决定如何处理联合体中的数据。

flowchart LR subgraph 标签联合体 direction LR T["Tag<br/>标签:当前值是哪种类型"] U["Union<br/>联合体:实际值<br/>(所有变体共享内存)"] end T -->|"Tag = INT"| UI["解释为 int"] T -->|"Tag = STRING"| US["解释为 string"] T -->|"Tag = BOOL"| UB["解释为 bool"]

内存布局:标签占固定大小(通常 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/AN/A低(一个指针)C、Go
Rust enum安全(穷尽匹配)容易(加函数)困难(改所有 match)最低(编译器优化标签大小)Rust

这里体现了经典的 Expression Problem(表达式问题):数据类型和操作构成一个二维矩阵,标签联合体方便加操作(加一行函数),不方便加类型(要改所有 switch);类继承正好反过来,方便加类型(加一个子类),不方便加操作(要改所有子类)。选择哪种方案,取决于你的需求更常变的是类型还是操作。

Note

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 是动态类型语言,每个值都是一个 VariantVariant 内部用 Type 枚举(38 种类型)做标签,配合联合体存储实际值。从 NIL、BOOL、INT 到 VECTOR3、TRANSFORM3D、DICTIONARY,所有 GDScript 值统一由 Variant 承载。

PyTorch — IValue 类型

PyTorch 的 TorchScript 解释器用 IValue 表示运行时值。IValue 内部有一个 Tag 枚举(Tensor、Int、Double、String、Tuple 等 20 多种),配合联合体存储不同类型的值。源码注释明确指出标签编码可能变化(比如未来改用 NaN boxing),外部代码应通过 isX() 方法判断类型,不要直接操作标签。

Lua — TValue 类型

Lua 虚拟机中每个值都是 TValue(Tagged Value)。TValue 由一个 Value 联合体(存 gc 指针、整数、浮点数等)和一个 tt_ 类型标签字节组成。标签字节的低 4 位编码基础类型,高位编码变体信息(比如短字符串 vs 长字符串、整数 vs 浮点数)。这是教科书级的标签联合体实现——每个栈槽、局部变量、表条目都是 TValue。

七、小结#

适合用标签联合体的场景

  • 脚本语言的值类型(Godot Variant、Lua TValue)——类型有限且稳定,操作频繁变化
  • 序列化格式的值表示(JSON、protobuf oneof)——类型在协议中固定,解析和序列化是主要操作
  • 编译器 IR 中的指令操作数——类型在编译器设计阶段确定,遍历和变换操作经常新增
  • 数据库驱动的返回值——SQL 查询结果类型有限,不同语言绑定需要统一表示

不适合用标签联合体的场景

  • 同构集合——所有元素类型相同,直接用数组,不需要标签
  • 性能敏感的内层循环——每次操作都要 switch 分发,分支预测失败代价高
  • 变体超过 50 种的深层层次——switch 维护成本爆炸,改用类继承或 trait 对象
  • 需要频繁扩展类型的场景——加一个类型要改所有 switch,类继承更合适

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

标签联合体(Tagged Union)
https://blog.souloss.com/posts/programming/data-structures/data-structures-tagged-union/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时