Go 的泛型既不是 C++ 那样每个类型一份代码,也不是 Java 那样完全擦除。它按“GC shape”分组生成机器码,再用一个字典传递真实类型信息。这篇用符号表与汇编验证这个设计,并量出它对运行速度和二进制体积的影响。
程序输出:1 2 3 2.5 1.5 s &{1} &{2} [1] map[a:1] | 5 3 1 1.5 4
main.Show[go.shape.*uint8]
main.Show[go.shape.[]int]
main.Show[go.shape.float64]
main.Show[go.shape.int32]
main.Show[go.shape.int]
main.Show[go.shape.map[string]int]
main.Show[go.shape.string]
main.Sum[go.shape.float64]
main.Sum[go.shape.int32]
main.Sum[go.shape.int]
共 10 个函数符号。注意:*A、*B 共用同一个符号(指针的 GC shape 相同);int 与 MyInt 共用 go.shape.int;float64 与 Celsius 共用 go.shape.float64
另有字典符号 14 个(每个“真实类型实参的组合”一个):
main..dict.Show[*main.A]
main..dict.Show[*main.B]
main..dict.Show[[]int]
main..dict.Show[float64]
main..dict.Show[int32]
main..dict.Show[int]
main..dict.Show[main.Celsius]
main..dict.Show[main.MyInt]
main..dict.Show[map[string]int]
main..dict.Show[string]
main..dict.Sum[float64]
main..dict.Sum[int32]
main..dict.Sum[int]
main..dict.Sum[main.MyInt]符号表里的 go.shape.int、go.shape.*uint8 就是 GC shape:内存布局与 GC 看法相同的类型共用一份机器码。MyInt 的底层类型是 int,所以与 int 共用;所有指针类型共用 *uint8 这一份。这就是为什么 16 种指针类型只生成了一个函数。
(<gtmp>/gi2/main.go:24) MOVD $main..dict.Show[int](SB), R0
(<gtmp>/gi2/main.go:24) CALL main.Show[go.shape.int](SB)
(<gtmp>/gi2/main.go:24) MOVD $main..dict.Show[int32](SB), R0
(<gtmp>/gi2/main.go:24) CALL main.Show[go.shape.int32](SB)
(<gtmp>/gi2/main.go:24) MOVD $main..dict.Show[main.MyInt](SB), R0
(<gtmp>/gi2/main.go:24) CALL main.Show[go.shape.int](SB)
(<gtmp>/gi2/main.go:24) MOVD $main..dict.Show[float64](SB), R0
(<gtmp>/gi2/main.go:24) CALL main.Show[go.shape.float64](SB)
(<gtmp>/gi2/main.go:24) MOVD $main..dict.Show[main.Celsius](SB), R0
(<gtmp>/gi2/main.go:24) CALL main.Show[go.shape.float64](SB)
(<gtmp>/gi2/main.go:24) MOVD $main..dict.Show[string](SB), R0
(<gtmp>/gi2/main.go:24) CALL main.Show[go.shape.string](SB)
(<gtmp>/gi2/main.go:24) MOVD $main..dict.Show[*main.A](SB), R0
(<gtmp>/gi2/main.go:24) CALL main.Show[go.shape.*uint8](SB)
函数头(STEXT 行里的 args= 是参数占用的字节数)——泛型实例化 Show[go.shape.int] 与同样函数体的非泛型 ShowInt(x int) 对比:
main.Show[go.shape.int] STEXT dupok size=112 align=0x0 args=0x10 locals=0x38 funcid=0x0
main.ShowInt STEXT size=96 align=0x0 args=0x8 locals=0x38 funcid=0x0共用机器码的代价是:函数体不知道自己具体是哪个类型,于是每个实例化多带一个字典参数。调用点用 MOVD $main..dict.Show[main.MyInt](SB), R0 先传字典的地址,再调用 shape 实例。Show[go.shape.int] 的参数大小比同样函数体的非泛型函数多 8 字节(args=0x10 对 0x8),吻合“多一个指针参数”。字典里具体放了什么(类型描述符、子字典、方法表),我没有解析。
SumInt([]int) 具体类型 中位 305.59 ns/op(最小 302.65 / 最大 315.24) SumGeneric[int]([]int) 中位 314.15 ns/op(最小 304.65 / 最大 326.79) TotalDirect([]Foo) 直接调用方法 中位 307.96 ns/op(最小 303.29 / 最大 313.86) TotalGeneric[Foo] 约束里的方法 中位 909.62 ns/op(最小 903.11 / 最大 922.69) TotalGeneric[*PFoo] 指针类型实参 中位 938.29 ns/op(最小 902.63 / 最大 1102.09) TotalIface([]Valer) 接口切片 中位 2024.07 ns/op(最小 2009.24 / 最大 2032.19)
两种情形要分开看。不涉及方法调用时(求和),泛型与手写具体类型一样快;要调用约束里的方法时,实例化后的代码要通过字典找到方法再间接调用,实测约 3 倍慢于直接调用(但仍快于接口切片的 2024 ns)。注意这是 //go:noinline 函数上的微基准,内联后编译器可能做得更好,本文没有测。
1 种值类型:二进制 2430162 字节,main.Work 的函数符号 1 个 4 种不同大小的值类型:二进制 2430690 字节,main.Work 的函数符号 4 个 16 种不同大小的值类型:二进制 2465858 字节,main.Work 的函数符号 16 个 16 种指针类型(指向不同结构体):二进制 2448162 字节,main.Work 的函数符号 1 个
shape 不同的类型才多一份机器码。16 种不同大小的值类型生成 16 个函数符号,二进制比 1 种时多约 35 KB;16 种指针类型只有 1 个符号,体积只多了字典与类型信息。所以大量用指针类型实例化泛型不会让代码膨胀。