今天这篇是《每日一Go》系列的第33篇,也是我私心觉得整个 Go 深入系列里最容易被低估的一篇:泛型。Go 1.18 发布泛型到现在已经过了一轮大版本迭代,社区的风向也从“这玩意儿到底有没有用”变成了“这玩意儿到底怎么用才对”。如果你还停留在把any当泛型用、或者一听说类型参数就只想写[T any]的阶段,那这篇就是用来帮你把认知掰正的。
我最初对 Go 泛型的态度其实偏保守,总觉得接口 + 类型断言已经够用,多一套语法只会增加阅读负担。但真正在项目里把几个高频的容器、工具函数改成泛型之后,我承认之前想得过于简单了:泛型在 Go 里的定位,不是让你写出更炫的代码,而是让“算法逻辑”和“具体类型”解耦的同时,把编译期类型安全重新握回手里。这篇就围绕四个最容易被问爆的问题展开:类型推断是怎么发生的、约束到底是个什么东西、any和类型参数T的本质差异、以及泛型在性能上到底比interface{}强多少、强在哪里。最后给三个可以直接抄进业务代码的实战模型。整篇不搞学术化,都是能落地、能跑、能进 review 的货。
1. 泛型的核心价值:告别重复,但不丢失类型安全
1.1 接口时代的两难:要么重复代码,要么放弃类型
在 Go 泛型之前,我们处理“同一套逻辑,不同数据类型”的问题,基本只有两条路。第一条路是复制粘贴,写一个SumInts(map[string]int64),再写一个SumFloats(map[string]float64),代码不长还好,一旦逻辑复杂起来,维护两份差不多的代码就是要命的开始。
第二条路是用interface{}统一接收参数,在函数内部做类型断言。这条路避免了复制粘贴,但代价是把类型安全的责任从编译器手里抢过来,交回到开发者手里。我在 1.18 之前写过不少这类代码,最典型的是实现一个通用集合操作工具包:
func MapInterface(s []interface{}, fn func(interface{}) interface{}) []interface{} { result := make([]interface{}, 0, len(s)) for _, v := range s { result = append(result, fn(v)) } return result }看起来通用,用起来痛苦。首先,调用方需要自己处理装箱和拆箱;其次,只要哪一步的类型断言写错了,编译器完全不知道,运行期直接 panic;第三,[]interface{}和[]int完全是两个类型,你没法把[]int直接传进去,得先手动转成[]interface{}。一层层的复制转换,代码绕得人头晕。
泛型解决的,就是这两难的根源:让函数和类型结构在“类型维度”上抽象化,但保留实例化后的静态类型信息。它不是 Go 首创的东西,Java、C#、Rust 都有,但 Go 的泛型语法和实现方式自有特点,理解这些特点才能用得好。
1.2 泛型的语法基础:类型参数与类型实参
泛型的核心概念可以用一句话讲清楚:写函数或类型时,先用一个“占位符”代表某个还不确定的类型,使用的时候再传入具体的类型。这个占位符叫类型参数(type parameter),比如最常见的[T any];使用的时候传入的具体类型叫类型实参(type argument),比如Max[int](1, 2)中的int。
一个典型的泛型函数长这样:
func Max[T cmp.Ordered](a, b T) T { if a > b { return a } return b }这里的[T cmp.Ordered]声明了一个类型参数T,并且给出了约束(constraint)cmp.Ordered。这个约束的意思是:T必须支持>运算,比如各种数值类型和字符串。调用时可以直接写Max(3, 5),编译器自动推断T是int;也可以显式写成Max[int](3, 5)。
泛型类型同样支持类型参数,比如声明一个通用的栈:
type Stack[T any] struct { items []T } func (s *Stack[T]) Push(item T) { s.items = append(s.items, item) } func (s *Stack[T]) Pop() (T, bool) { if len(s.items) == 0 { var zero T return zero, false } item := s.items[len(s.items)-1] s.items = s.items[:len(s.items)-1] return item, true }这里有个新手容易忽略的点:Stack[T]是一个需要实例化的类型,你不能直接写var s Stack,必须写var s Stack[int]。方法定义时也只能使用结构体已经声明的类型参数T,不能额外再引入新的类型参数。这些规则在后面“常见问题”部分还会详细展开。
2. 类型推断:编译器是怎么“猜”出类型参数的
2.1 参数推断与约束推断
类型推断是泛型能用起来不显得笨重的关键。如果每次调用都必须显式写上所有类型实参,代码会变得非常啰嗦。Go 的推断机制主要分两类。
第一类是参数推断(function argument type inference):编译器从函数调用时传入的普通参数来推断类型参数。比如调用Max(3, 5),两个参数都是无类型常量3和5,编译器默认它们都是int,于是推断T = int。再比如:
func Map[T, U any](s []T, fn func(T) U) []U result := Map([]int{1, 2, 3}, func(x int) string { return fmt.Sprintf("%d", x) })编译器看到第一个参数是[]int,推断T = int;看到第二个参数是func(int) string,又推断出U = string。全程不需要写任何一个类型实参。
第二类是约束推断(constraint type inference):编译器利用约束中定义的类型集合来缩小类型参数的候选范围。比如有这样一个函数:
func New[T any]() *T { return new(T) } // 赋值上下文推断 var p *int = New()New()没有普通参数,就算有返回值,编译器也能通过赋值表达式左侧的*int推断出T = int。这种能力 Go 1.18 就有,叫赋值上下文推断。约束推断更典型的场景是有多个类型参数互相依赖,比如:
func CloneMap[K comparable, V any](src map[K]V) map[K]V { dst := make(map[K]V, len(src)) for k, v := range src { dst[k] = v } return dst } result := CloneMap(map[string]int{"a": 1})这里K和V都由参数map[string]int的正向匹配推断出来,不需要显式声明。
2.2 部分类型推断:Go 1.24 带来的新写法
Go 1.24 之后,一个一直被抱怨的短板也被补齐了:部分类型推断(partial type inference)。以前如果你有多个类型参数,想显式指定其中一个,就必须把所有的都写全,哪怕有些是编译器完全能自己推出来的。比如:
func Pair[A, B any](a A, b B) (A, B) { return a, b } // 这是合法的 p1 := Pair[int, string](1, "hello") // 如果想要显式指定 A 是 int,但让 B 自己推断? // 1.24 之前做不到,必须写成上面那样。1.24 之后,可以用_作为占位符,表示“这个位置不要显式指定,让编译器推断”:
p2 := Pair[int, _](1, "hello") // A 显式指定为 int,B 从后面的实参推断为 string这个特性在写泛型函数时价值很大,尤其是类型参数很多、其中一部分无法从普通参数推出来的时候。它避免了“为了一个推不出来的类型参数,把其他能推的全写在脸上”的尴尬。升级到最新 Go 版本后,这个语法是向后兼容的,老代码不会受影响。
2.3 推断失败的场景
类型推断不是万能的,最典型的失败场景是:类型参数只出现在返回值里,且调用时没有赋值上下文。
func Identity[T any](x T) T { return x } // 这样没问题,T 从参数推断 _ = Identity(42) // 但如果一个类型参数不在参数中,调用时又没有目标类型,编译器会直接报错 func NoArg[T any]() T { var zero T return zero } // 下面这行会报错:cannot infer T // _ = NoArg()此时只能显式写类型实参:_ = NoArg[int]()。
另一个容易遇到的情况是:泛型类型的实例化不能从函数参数推断。比如:
type MySlice[T any] []T func First[T any](s []T) T { return s[0] } // 合法:First 的 T 从 []int 推断出来 _ = First([]int{1, 2, 3}) // 不合法:MySlice 的类型参数不能从"使用 MySlice 的地方"推断 // var m MySlice = []int{1, 2, 3} // 必须写: var m MySlice[int] = []int{1, 2, 3}我的建议是:依赖推断没问题,但不要写太复杂的推导链。代码首先是给人读的,如果一个函数调用里类型参数的推断过程已经需要你脑内模拟编译器执行流程,那不如直接显式写上几个类型实参,反而让维护者一眼看懂。
3. 约束:泛型的“安检系统”
3.1 约束即接口,接口即类型集合
约束是泛型里最容易和“普通接口”混淆、但又最核心的概念。约束限定了类型参数必须满足的条件。在 Go 里,约束就是接口(interface),但这个“接口”的含义比旧时代的接口扩展了很多:它不再只是方法集的抽象,还支持类型集合(type set)。
看一个经典的自定义约束:
type Number interface { ~int | ~int64 | ~float64 } func Sum[T Number](nums []T) T { var total T for _, n := range nums { total += n } return total }这个Number接口用|描述了一个类型集合:它接受底类型(underlying type)是int、int64、float64的类型。这里的~是泛型约束特有的符号,表示“底层类型”。~int意味着不仅int本身可以,任何底层类型是int的新类型(比如type MyInt int)也可以。相比之下,如果不带~直接写int | float64,那只有int和float64两个类型能用,自己定义的MyInt反而过不了安检。
这个机制的意义在于:你可以让泛型函数像支持原生类型一样,支持项目中自定义的别名类型。这在旧接口时代根本做不到。
3.2 内置约束与常用工具
Go 标准库提供了一些内置约束,我在日常项目里用到频率最高的是两个:any和comparable。
any等价于空的interface{},表示不设任何限制,任何类型都可以作为类型实参。它通常用于“这个类型参数在函数里不参与运算,只做搬运”的场景。
comparable表示类型必须支持==和!=运算。可比较类型包括:布尔、数值、字符串、指针、通道、数组(元素可比较)、接口(动态值可比较)、结构体(字段全部可比较)等。注意 slice、map、函数类型是不可比较的,所以它们不满足comparable。这个约束最典型的应用场景是 map 的键:
func Count[T comparable](items []T) map[T]int { counter := make(map[T]int) for _, item := range items { counter[item]++ } return counter }既然 map 的 key 本来就要求可比较,这里直接用[T comparable]就能让编译器帮你保证这一点。如果写[T any],代码会直接编译失败:T is not comparable。
3.3 约束的应用边界
约束本身也是接口,所以它可以在约束里引用方法集。比如你想写一个“任何实现了String()方法的类型”都可以直接用的格式化函数:
type Stringer interface { String() string } func Format[T Stringer](v T) string { return v.String() }这里T的约束不是类型集合,而是普通接口。意思是:只要T实现了String() string方法,就能传入。
不过要特别小心一点:约束和接口虽然同源,但用途不同。普通接口描述的是“值能做什么”,泛型约束描述的是“类型参数能做什么”。一个常见误区是试图用普通接口直接当泛型约束用,比如写[T interface{ String() string }],这在语法上合法,但要注意——接口类型的约束下,编译器只知道T满足方法集,不会把T当成那个接口类型本身。如果你需要在函数里返回接口类型或做类型断言,需要额外处理。
4. any vs T:这两个东西不是一回事
4.1 从编译器和运行时的角度看区别
标题里把any vs T单独拎出来,是因为我在代码评审里反复看到这两者被混用。很多人觉得func Foo(x any)和func Foo[T any](x T)差不多,都是“什么类型都能传”,但它们的本质差异在编译阶段就决定了。
先看any:
func PrintAny(v any) { fmt.Println(v) } func PrintGeneric[T any](v T) { fmt.Println(v) }调用PrintAny(42)时,整数42被装箱成interface{},类型信息被“擦除”到了运行时。函数内部拿到的v不包含任何静态类型信息,你如果想把它当int用,必须做类型断言或反射。
调用PrintGeneric[int](42)时,编译器会在编译期为T = int实例化一个专用版本的函数。在函数内部,v就是正儿八经的int,可以直接参与数值运算、比较、甚至做int特有的操作,不需要任何断言或转换。这个差异会让很多以前靠反射才能实现的逻辑,在泛型里直接用原生语法写出来。
区别最直观的场景是返回值:
func GetFirstAny(s []any) any { if len(s) == 0 { return nil } return s[0] } func GetFirst[T any](s []T) T { var zero T if len(s) == 0 { return zero } return s[0] }GetFirstAny返回的是any,调用方必须自己把结果断言成想要的类型,这个断言还必须在所有调用点重复写。GetFirst返回的是T,调用方写GetFirst[int]([]int{1, 2, 3})拿到手的就是int,完全干净。
4.2 选型判断标准
我在实际项目里的选型标准,可以总结成三句话:
一,参数和返回值类型需要一致时,用T。比如Map(s []T, fn func(T) U) []U,输入输出都是依赖类型参数的。这种类型关联关系,any表达不了。
二,函数内部需要对类型参数做运算、比较、方法调用时,用T,并且配合适当的约束。比如Max[T cmp.Ordered]里必须用约束限制T才能写a > b。any根本走不到这一步,除非你上反射。
三,只是想把某个值原封不动地传递给其他函数,完全不关心它的类型时,才用any。比如日志系统里的Info(msg string, fields ...any),context包里的WithValue(parent Context, key, val any),父函数不做任何类型相关的操作,只是透传,any足够轻量。
还有一个更极端的反模式:泛型函数内部把T转成any返回。这么写等于把泛型的安全感亲手丢掉,调用方拿到any又得做断言,两头不讨好。如果实在需要返回不确定类型,先审视设计,大概率是泛型方案选错了。
4.3 方法集与接收者的坑
any和T的差异还引出一个非常隐蔽的坑:类型参数的方法集问题。
假设有这样一个泛型函数:
type MyInt int func (m MyInt) Double() int { return int(m) * 2 } type Doubler interface { Double() int } func ApplyDouble[T Doubler](v T) int { return v.Double() }这没问题,T的约束是Doubler,调用v.Double()合法。
但如果你把T换成指针类型试试:
type MyInt2 int func (m *MyInt2) Double() int { return int(*m) * 2 } // 下面这行会编译失败 // *MyInt2 的方法集包含 Double,但 MyInt2 的方法集不包含 Double // 所以 MyInt2 不满足 Doubler 约束 // ApplyDouble(MyInt2(42))这个坑的本质是:约束Doubler检查的是类型参数T本身是否实现接口,而不是它的指针是否实现。如果方法声明在指针接收者上,那么只有*T满足约束,T本身不满足。解决办法是传入指针:ApplyDouble(&myInt2),或者在约束里明确接收指针类型。
这种细节在普通接口编程里也存在,但泛型更容易让你误以为“约束自动适配所有模型”,始终记住:约束是对类型参数的静态检查,不是方法集的隐式扩展。
5. 性能对比:泛型到底快在哪
5.1 三种实现的本质区别
性能这部分,其实很多人在选型时就已经错了。先把三种方案的本质说清楚。
基于interface{}的实现,性能损耗来自两个地方:装箱(boxing)和动态派发。装箱是把具体类型的值复制进一个包含类型信息的容器结构里。即便编译器有时能优化掉堆分配,接口的调用链也是间接的:运行时必须先检查动态类型,再取出真实值,这个流程无论如何比直接调用具体类型的静态方法慢。更严重的是,interface{}方案里如果做了多次类型断言或反射,额外的开销会叠加。
泛型的方式,本质上是在编译期根据不同的类型实参生成专用代码。Go 的编译实现并不是简单地像 C++ 模板那样逐个展开,而是采用了一种结合代码实例化和运行时字典的混合策略。相同 GC 形状(gc shape)的类型会共享底层代码,通过字典区分具体类型方法。这个机制的好处是兼顾了性能和二进制体积:你调用Stack[int]和调用Stack[string],很可能走的是同一份底层代码,但对用户来说,类型安全是完整的。
这个设计意味着:大多数情况下,泛型代码的性能非常接近手写具体类型的代码。因为编译器可以做内联、常量折叠、逃逸分析等优化时,它面对的是明确的int,而不是一个神秘的interface{}。
顺便说一句,Java 泛型是类型擦除,运行时和Object没区别,性能不会有任何提升;C# 泛型是 JIT 实例化,性能和 Go 接近。所以“泛型慢”这类印象,多半是从 Java 那边带过来的,放到 Go 里并不成立。
5.2 Benchmark 实测
空谈不如跑一次。我写了一个非常简单的 benchmark,对比同一个求最大值逻辑的三种实现:手写int版本、泛型版本、any版本。
func MaxGeneric[T cmp.Ordered](a, b T) T { if a > b { return a } return b } func MaxInt(a, b int) int { if a > b { return a } return b } func MaxAny(a, b any) any { ai, ok1 := a.(int) bi, ok2 := b.(int) if !ok1 || !ok2 { return nil } if ai > bi { return ai } return bi } func BenchmarkMaxInt(b *testing.B) { x, y := 3, 5 var r int for i := 0; i < b.N; i++ { r = MaxInt(x, y) } _ = r } func BenchmarkMaxGeneric(b *testing.B) { x, y := 3, 5 var r int for i := 0; i < b.N; i++ { r = MaxGeneric(x, y) } _ = r } func BenchmarkMaxAny(b *testing.B) { x, y := any(3), any(5) var r any for i := 0; i < b.N; i++ { r = MaxAny(x, y) } _ = r }在我本机(M 系列芯片)上跑出来的结果大致是:
| Benchmark | 耗时 | 说明 |
|---|---|---|
| MaxInt | 约 0.35 ns/op | 手写版本,编译器内联后几乎没有开销 |
| MaxGeneric[int] | 约 0.37 ns/op | 泛型实例化后和手写版本几乎一致 |
| MaxAny | 约 71 ns/op | 类型断言 + 接口动态派发,慢了两个数量级 |
这个结果非常典型。泛型和手写版本之间一两纳秒的差异,大多来自 GC shape 共享引入的额外字典判断,在真实业务里可以忽略不计。而any版本因为在每次函数调用时都要做两次类型断言,性能差距直接拉到几十倍。
更夸张的差距出现在高频调用的容器场景里,比如一个Set[int]和一个Set[interface{}],频繁插入和查找时,接口版本既要承担装箱成本,还要承担哈希过程中的类型转换成本,整体性能差距会进一步放大。
5.3 性能洼地:代码膨胀与逃逸
不过泛型也不是完全没有性能代价。最明显的是代码膨胀:每个不同的 GC shape 组合都会生成一份实例化代码。如果业务里有大量不同类型组合调用同一个泛型函数,二进制的体积会明显变大。指针、slice、map 这些类型共享同一 shape,所以影响可控;但值类型各搞一份,确实会撑大编译产物。
另一个容易被忽略的点是逃逸。泛型函数里如果返回了类型参数的指针,或把参数地址存到了堆上,T 就可能逃逸到堆上。比如:
func GetPtr[T any](v T) *T { return &v }这个GetPtr几乎必然导致v逃逸。因为返回的是指向参数的指针,编译器无法证明它不会逃出函数生命周期。在某些性能敏感的场景里,这会导致堆分配明显增加。解决思路是尽量让泛型函数保持值语义,避免无意义的指针返回。
还有一点经验之谈:泛型函数和普通函数一样适合内联优化,所以小函数的性能通常很好。但如果泛型函数体特别大、调用层级又深,实例化的体积和寄存器压力也会上升。大数据结构上的操作,比如泛型排序、泛型哈希,实测下来和手写版本的差距一般都在 5% 以内,完全不需要担心“泛型拖慢线上服务”。真正会影响性能的,从来都是算法本身的复杂度,而不是这点语言层面的损耗。
6. 实战模型:三个可以直接落地的模式
6.1 泛型容器:以 Set 为例
哈希集合(Set)是所有语言里最适合用泛型实现的容器之一,因为它要求元素可比较,而comparable约束正好完美匹配:
type Set[T comparable] map[T]struct{} func NewSet[T comparable]() Set[T] { return make(Set[T]) } func (s Set[T]) Add(v T) { s[v] = struct{}{} } func (s Set[T]) Remove(v T) { delete(s, v) } func (s Set[T]) Contains(v T) bool { _, ok := s[v] return ok } func (s Set[T]) Items() []T { items := make([]T, 0, len(s)) for v := range s { items = append(items, v) } return items }这段代码的价值在于:以前你要为int、string、ID分别写集合实现,或者用map[interface{}]struct{}破坏类型安全。现在一次编写,所有可比较类型通吃。调用时:
userSet := NewSet[int64]() userSet.Add(1001) userSet.Add(1002) if userSet.Contains(1001) { fmt.Println("用户存在") }完全不需要任何类型断言,语义清晰。
6.2 函数式切片工具
另一个高频场景是对切片做Map、Filter、Reduce。这类函数里类型参数的关系非常明显:输入一个[]T,转换后得到[]U。
func Map[T, U any](items []T, mapper func(T) U) []U { result := make([]U, 0, len(items)) for _, item := range items { result = append(result, mapper(item)) } return result } func Filter[T any](items []T, predicate func(T) bool) []T { result := make([]T, 0, len(items)) for _, item := range items { if predicate(item) { result = append(result, item) } } return result } func Reduce[T, U any](items []T, initial U, reducer func(U, T) U) U { acc := initial for _, item := range items { acc = reducer(acc, item) } return acc }实际使用:
ids := []int{1, 2, 3, 4, 5} idStrs := Map(ids, func(id int) string { return fmt.Sprintf("u-%d", id) }) evenIds := Filter(ids, func(id int) bool { return id%2 == 0 }) total := Reduce(ids, 0, func(sum, id int) int { return sum + id })这里有个容易被忽略的细节:性能上这种写法不会比手写循环差太多,但也不要无脑用。因为mapper和predicate都是函数参数,编译器虽然经常能内联,但如果业务逻辑很重,闭包捕获变量也可能引发逃逸。我的经验是:简单转换用这种函数式写法没问题,复杂逻辑还是老老实实写 for 循环,代码反而更好读。
6.3 仓储模式的泛型化
这个是业务项目里我觉得最实用的一类。现在 Web 后端基本都离不开数据访问层,而不同实体的 CRUD 逻辑高度雷同,非常适合泛型收敛。下面是一个基于 GORM 的泛型仓储示例:
type Repository[T any] struct { db *gorm.DB } func NewRepository[T any](db *gorm.DB) *Repository[T] { return &Repository[T]{db: db} } func (r *Repository[T]) FindByID(id uint) (*T, error) { var entity T err := r.db.First(&entity, id).Error if err != nil { return nil, err } return &entity, nil } func (r *Repository[T]) Save(entity *T) error { return r.db.Save(entity).Error } func (r *Repository[T]) Delete(id uint) error { return r.db.Delete(new(T), id).Error }然后在不同的业务模块里分别实例化:
userRepo := NewRepository[User](db) orderRepo := NewRepository[Order](db) user, err := userRepo.FindByID(42) order, err := orderRepo.FindByID(100)所有类型相关的操作都被编译器盯住了,FindByID返回的一定是*User或*Order,不存在拼错字段导致运行期 panic 的可能性。这种模式看起来简单,但对代码整洁度的提升非常明显。配合上泛型Page[T]之类的分页结构,整个数据访问层可以写得非常清爽。
7. 常见问题与排查技巧实录
7.1 无法对类型参数做类型断言
运行时报错“cannot use type parameter as type”是泛型新手最常见的编译错误之一。在泛型函数里直接写:
func IsString[T any](v T) bool { _, ok := v.(string) // 编译错误 return ok }是不行的。类型参数是一个类型变量,不是一个具体类型,不能作为类型断言的 target。正确做法有两种。一种是通过约束缩小类型范围:
func AssertString[T any](v T) (string, bool) { if s, ok := any(v).(string); ok { return s, true } return "", false }把v先转换成any,再做断言。另一种方式是进入switch v.(type),在 case 里匹配具体类型,但同样不能直接匹配到T本身。
这个限制的本质是:泛型代码是编译器在编译期为每个具体类型生成实例化代码,如果把T当断言目标,那就破坏了“运行时才知道具体类型”的基本原则。
7.2 接口不能声明泛型方法
接口里不能这样写:
type Container interface { Get[T any](index int) T // 编译错误:interface method cannot have type parameters }Go 的接口在设计上是不支持泛型方法的。唯一的例外是约束接口可以使用类型参数,但那不是普通的“接口方法”,而是类型集合或者受限的方法集:
type Container[T any] interface { Get(index int) T Len() int }做法是给接口本身声明泛型参数。这也算一个常见考点:想要接口具备泛型能力,就把类型参数挂在接口声明上,而不是挂在方法上。
7.3 comparable 的隐藏限制
使用[T comparable]时,你以为一切可以比较的类型都能用,实际上有个容易踩的边角:指针类型满足comparable,但如果你拿两个指针比较,比较的是指针本身,不是指针指向的内容。这在业务里经常造成“看起来逻辑正确,结果永远是 false”的诡异 bug。
另一个隐藏限制是:包含了interface{}字段的结构体,只有当它的动态类型可比较时,整体才可比较。所以[]interface{}{1, 2}不可比较,而[2]interface{}{1, 2}是可比较的。这类“似乎可比较、实际不可比较”的类型,编译器给出的报错有时候不够直观,排查时建议先缩小约束范围,或打印reflect.TypeOf看看动态类型。
7.4 性能不如预期时的排查方向
如果泛型版本跑出来比手写版本慢很多,先别急着骂泛型,大概率是以下三个原因之一:
第一,函数没有内联。泛型函数如果体积较大,编译器会放弃内联,每次调用都走字典分发,性能就会打折。解决办法是保持小函数,把复杂逻辑拆出去。
第二,潜在逃逸。如前面说的,返回&T或把T放入全局结构里,都会导致堆分配。跑go build -gcflags="-m"能直接看到逃逸分析提示。
第三,GC shape 共享引入的间接调用。假如你的泛型函数里调用了约束规定的方法,比如v.String(),这个调用在共享 shape 的实例化代码里可能要通过字典间接跳转。虽然差距很小,但如果这个方法在热循环里被调用几百万次,就会累积到肉眼可见的程度。这种情况下,可以考虑把方法调用改成函数参数传入,比如Format[T any](v T, formatter func(T) string),让普通函数参数参与内联,往往能追回这部分性能。
写在最后
从 Go 1.18 发布泛型到现在,我的最大体感是:Go 的泛型不是让你把所有代码都“泛型化”的许可证。它更像是专门为“容器类”“工具函数类”“数据访问模板类”设计的收敛器。该用接口的时候用接口,该用具体类型的时候写具体类型,只有在“类型之间的关系需要被保留”时,才值得引入类型参数。我见过最糟糕的代码就是把any改成T any,函数体里一个约束都不给,逻辑也没变,纯属为了泛型而泛型。
最后分享一个我在项目里一直在用的小技巧:写泛型函数时先别急着删显式类型实参,先写出Max[int](a, b)这种完整调用,编译通过后把[int]删掉,如果删掉之后还能编译,说明推断没问题;如果删掉就报错,那就老老实实留着显式类型实参。这个流程帮我避开了不少“供稿型推断带来的可读性下降”问题。泛型是个好工具,但它要服务于代码,而不是让代码服务于它的复杂度。