Go在类型系统上一直比较克制,没有引入联合类型这种特性。比如解析一个动态 JSON 字段时,它可能是字符串、数字或布尔值,直接的做法是声明成 interface{},然后在运行时不断做类型断言。这种写法在编译期几乎得不到任何保护,分支一多还容易漏掉类型。

一、从接口出发:用方法集约束联合成员
Go 的接口本质上是方法集合,只要某个类型实现了接口定义的全部方法,就可以赋值给该接口。利用这个特性,可以定义一个小写开头的标记接口,再让有限的类型实现它,从而形成一个封闭的联合类型。未导出的方法名会限制其他包实现该接口,这样编译器就能阻止外部类型意外混入。
这种模式经常出现在 AST 节点、配置值或令牌等场景中。接口本身不暴露任何行为,只作为类型集合的边界。使用者拿到接口值后,通过类型切换把不同成员还原出来。由于集合是封闭的,default 分支通常不会触发,但保留它可以防止代码演变时遗漏新成员。
package main
import "fmt"
type Value interface {
valueNode()
}
type IntValue int
func (IntValue) valueNode() {}
type StringValue string
func (StringValue) valueNode() {}
func printValue(v Value) {
switch x := v.(type) {
case IntValue:
fmt.Println("int:", int(x))
case StringValue:
fmt.Println("string:", string(x))
default:
fmt.Println("unknown")
}
}
这个方案的优点是类型安全,不依赖任何额外库,代码结构清晰。缺点也很明显:每增加一个成员都要定义一个新类型并实现标记方法,略显啰嗦。不过对于成员数量不多且需要长期维护的项目来说,这种显式约束带来的收益通常大于成本。
封闭接口的另一个好处是可以配合编辑器或静态分析工具做穷尽检查。虽然 Go 编译器不会强制要求 switch 覆盖所有成员,但团队可以约定在 default 分支返回错误,这样当新增类型却忘记处理对应分支时,至少不会静默地产生错误结果。
二、引入泛型:用类型约束表达可穷举的联合
Go 1.18 引入泛型后,类型约束接口里可以写出多个类型的并集。例如约束 ~int | ~int32 | ~float64 表示允许底层类型为这些基础类型的任何类型。这种写法在形式上非常接近联合类型,但它只能用在类型参数位置,不能直接作为普通变量的类型使用。
泛型约束适合表达算法层面的联合:几个类型拥有相同的操作集合,函数只需要依赖这些共同操作。比如切片求和、比较大小等场景。约束限制了传入类型,函数内部则可以安全地执行加法、比较等操作,编译器会确保类型参数满足约束。
type Number interface {
~int | ~int32 | ~float64
}
func Sum[T Number](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
不过泛型约束并不能完全替代运行时联合类型。如果成员类型的行为差异很大,比如一个分支要调用字符串方法,另一个分支要做数值运算,泛型函数内部无法直接根据类型参数 T 做分支。这种情况下仍需要结合类型断言或接口来处理,约束只负责限制入口,不负责运行时分发。
此外,类型约束中的并集只允许包含接口类型或基础类型,不能包含结构体、指针等非接口类型。这意味着想用泛型约束表达任意自定义类型的联合并不现实,它更适合基础类型的算法复用,而不是承载复杂的业务数据。
三、显式标签结构体:最贴近 union 的运行时方案
如果希望避免接口分配,同时保留明确的分支信息,可以用结构体加上枚举标签来模拟联合类型。结构体里放置一个 Kind 字段标识当前值的实际类型,再为每种可能的类型准备对应的值字段。构造时通过工厂函数设置 Kind 和对应字段,读取时先判断 Kind 再取字段。
type Kind int
const (
KindInt Kind = iota
KindString
KindBool
)
type Value struct {
Kind Kind
IntVal int
StrVal string
BoolVal bool
}
func NewInt(v int) Value {
return Value{Kind: KindInt, IntVal: v}
}
func Describe(v Value) string {
switch v.Kind {
case KindInt:
return fmt.Sprintf("int %d", v.IntVal)
case KindString:
return fmt.Sprintf("string %q", v.StrVal)
case KindBool:
return fmt.Sprintf("bool %t", v.BoolVal)
default:
return "unknown"
}
}
这种方案的最大优点是值类型语义,不涉及堆分配,序列化和反序列化也容易控制。所有分支都在一个结构体中,复制、比较、存储都比较直观。缺点是结构体需要为每个成员预留字段,如果联合成员很多,结构体会变得臃肿,且大量字段长期处于未使用状态,造成内存浪费。
为了解决字段浪费问题,可以退而求其次,在结构体中只保留一个 any 字段存储实际值,再配合 Kind 做分发。但这样一来又回到了运行时类型断言的老路,失去了部分编译期检查。因此该方案通常用在成员数量有限、性能敏感、需要值语义的场景中,例如解析 JSON 后生成的紧凑值对象。
四、按场景选型:让模拟策略服务于代码表达
没有一种方案能在所有场景中胜出。封闭接口适合那些成员类型需要各自实现独立行为的情况,比如 AST 节点需要分别实现 Codegen、TypeCheck 等方法;泛型约束适合算法复用,比如对多种数值类型执行相同的数学运算;显式标签结构体适合数据载体,尤其是需要序列化、长时间存储的配置或消息对象。
错误处理中的联合类型也有类似考量。error 是一个开放的接口,调用方可以通过类型断言或 errors.As 判断具体错误类型。这在本质上就是一种开放联合,但由于 error 接口可以无限扩展,编译器无法提供穷尽检查,因此使用时要额外小心默认分支和错误链的处理。
最终判断标准很简单:如果类型集合是封闭的,优先考虑封闭接口或标签结构体;如果只是算法层面对基础类型的复用,泛型约束最轻量;如果只是临时解析且分支很少,直接使用 any 加类型断言也未尝不可。关键是把联合成员的边界表达清楚,避免让类型断言散落到代码库的各个角落。模拟联合类型并不是要复刻其他语言的特性,而是用 Go 现有的工具把值和行为的可能性限制在可控范围内。
Go语言Union Types类型断言修改时间:2026-09-27 18:19:41