尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Go泛型深度解析:类型参数、约束与性能实践

Go泛型深度解析:类型参数、约束与性能实践 今天这篇是《每日一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并且给出了约束constraintcmp.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 显式指定为 intB 从后面的实参推断为 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)差不多都是“什么类型都能传”但它们的本质差异在编译阶段就决定了。先看anyfunc 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]删掉如果删掉之后还能编译说明推断没问题如果删掉就报错那就老老实实留着显式类型实参。这个流程帮我避开了不少“供稿型推断带来的可读性下降”问题。泛型是个好工具但它要服务于代码而不是让代码服务于它的复杂度。
返回列表