用了一周 Go 泛型之后的一些体会
泛型从 1.18 落地到现在也有些日子了,但我之前的几个老项目一直停在 1.17,没真正用上。最近新起了一个小工具,干脆直接上 1.22,顺手把能泛型化的地方都泛型化了一遍。用了一周,有些体会想记下来。
先说真正省事的地方:通用容器工具
以前每个项目里都免不了抄一遍这种东西——从一个 slice 里筛出满足条件的元素:
// 泛型版:一套写完,所有类型通用
func Filter[T any](s []T, f func(T) bool) []T {
out := make([]T, 0, len(s))
for _, v := range s {
if f(v) {
out = append(out, v)
}
}
return out
}
这种"和具体类型无关、只和 slice 这个形状有关"的工具,是泛型最大的用武之地。以前要么每个类型抄一份,要么写成 interface{} 然后到处类型断言,丢失编译期检查。现在一行 [T any] 解决,调用方还能享受类型推导,不用显式写 Filter[int](...)。
同理,Contains、Map(把 []T 转成 []U)、Keys/Values 这些 Map 工具,都值得收进一个 xslice / xmap 包里。一周下来我大概攒了十来个,明显比以前清爽。
需要适应的:constraints
真正写复杂一点的泛型时,会遇到"我想约束 T 必须是数字"这种需求。Go 的做法不是内置 Number 这种类型,而是让你用 constraints 包里预定义的接口集合:
import "golang.org/x/exp/constraints"
func Sum[T constraints.Integer | constraints.Float](s []T) T {
var sum T
for _, v := range s {
sum += v
}
return sum
}
这套"用接口的类型集来表达约束"的设计,初看有点绕,但理解之后其实是自洽的——它本质上是在类型层面做集合运算。只是相比别的语言(比如 Rust 的 trait bound),Go 的表达力目前还比较有限,~int 这种"底层类型是 int 的自定义类型"的写法也要单独记一下。
什么时候我还是选择 interface
不是所有场景都该上泛型。这周我刻意留意了几次"犹豫"的瞬间,发现一个判断标准挺好用:
如果这个抽象的目的是"对调用方隐藏多种实现"(比如一个存储接口,背后可能是内存、可能是数据库),那它要的是 interface。如果目的是"对实现方复用同一套逻辑、只是数据类型不同",那才是泛型。
换句话说,interface 是关于行为多态的,泛型是关于类型参数化的。混在一起用往往会写出又难读又难维护的代码。我这周就删掉了一个强行泛型化的 Repository,改回 interface + 具体实现,反而清楚多了。
性能:别瞎担心
很多人(包括一周前的我)会担心泛型有没有性能开销。Go 的泛型实现是"部分单态化"——编译器会针对不同的类型参数生成专门的代码,大多数情况下和手写一份具体类型的版本没有可观测的差异。我这周写的工具跑了 benchmark,泛型版和手写 int 版基本持平,偶尔甚至更快(可能是内联的边界条件不同)。
所以结论很简单:该用就用,别为了想象中的性能损失而放弃类型安全。
小结
- 通用容器工具(Filter/Map/Contains 等)是泛型最该用的地方,赶紧攒一套。
- constraints 需要点时间适应,但设计是自洽的。
- 分清"行为多态用 interface、类型参数化用泛型"。
- 性能不是问题,别瞎担心。
一周下来,总体感受是:泛型是个"用对了很舒服、用错了反而增加复杂度"的工具。它不会改变 Go 的写法风格,只是在一些原本啰嗦的地方给了你一把更顺手的刀。