Go 泛型不是银弹,但该用不用也是蠢
Go 1.18 引入泛型那天,社区分了两派。
一派高潮:终于不用写 interface{} + 类型断言的狗屎样板了。另一派恐慌:这玩意肯定把 Go 的简洁性毁了,等着看代码变得跟 C++ 模板一样没法读吧。
四年过去,两派都错了。
泛型既没有拯救 Go,也没有毁灭 Go。它只是在某些场景下恰好是正确工具,在另一些场景下纯粹是画蛇添足。这篇文章不讲泛型的语法——今天能用 Go 4.0 的人不需要再看教程。我直接给结论:哪些地方该用,哪些地方碰都别碰。
三个该用的场景
1. 数据结构——这是泛型最没有争议的战场
泛型诞生的原点就是容器。你手写一个 int 版的 Set,再手写一个 string 版的 Set,又手写一个 int64 版的 Set——写到第三个你就会问自己:我他妈在干什么?
该用:任何需要类型安全的容器/包装器。
// Before: interface{} + 运行时反射,写的时候骂编译器,跑的时候骂自己
type Set struct {
items map[interface{}]struct{}
}
func (s *Set) Add(item interface{}) {
s.items[item] = struct{}{}
}
// 调用时永远要记得类型
s.Add("hello") // 编译通过
s.Add(42) // 编译也通过——但如果你以为是 string set,运行时炸
// After:编译器帮你兜底
type Set[T comparable] struct {
items map[T]struct{}
}
func (s *Set[T]) Add(item T) {
s.items[item] = struct{}{}
}
// 类型不对,编译期就告诉你
s := Set[string]{}
s.Add("hello") // 👍
s.Add(42) // ❌ 编译错误:cannot use 42 as string
这不仅仅是舒服的问题。interface{} 把类型检查推到了运行时,泛型把它拉回到编译时。 对于一个在生产环境跑了三年的系统,这类问题导致的线上事故我见过不止一次——某次拼接 key 时类型不一致,缓存穿透直接把 DB 打挂了。用泛型,这种 bug 在 go build 阶段就死了。
同理的还有:LRU Cache、Ring Buffer、Heap、Pool——凡是容器类的,无脑泛型。
2. 通用算法函数——减少样板代码,但不牺牲可读性
写 Go 的人一定写过这个 Pattern:
// 三个函数,除了类型签名,一模一样
func MapStrings(items []string, fn func(string) string) []string { ... }
func MapInts(items []int, fn func(int) int) []int { ... }
func MapFloats(items []float64, fn func(float64) float64) []float64 { ... }
或者更常见的——写了一个 JSON 解析 helper:
// Before:interface{} 需要调用方自己断言
func ParseJSON[T any](data []byte) (*T, error) {
var v T
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return &v, nil
}
// 调用方直接拿类型安全的对象,不用断言
user, err := ParseJSON[User](resp.Body)
注意:这个例子太常见了,以至于被滥用到了一些不该用的地方(后面说)。但如果它确实减少了样板重复,就用。
判断标准只有一个:有没有消除重复逻辑? 如果泛型函数体里的代码逻辑本身不是重复的(只是签名不同),那就不该用泛型。如果是同样的逻辑,只是类型不同,泛型就是正解。
3. 约束接口——用类型参数替代你手动写的通用接口
这是 Go 泛型最被低估的能力。
以前你想写一个「任何可以排序的类型」的通用方法,需要定义一个接口,然后每个类型实现它。有了泛型,你可以直接约束类型参数的行为:
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](items []T) T {
var total T
for _, v := range items {
total += v
}
return total
}
不是非要写数学运算。这个模式在写策略模式、装饰器模式、管道模式时特别有用——约束接口替代了传统的 interface{} + 类型断言,同时保留了编译时检查。
三个不该用的场景
上面是甜区。下面是雷区。
1. 你只需要一个具体类型——别给未来的幽灵做抽象
这是最常见的滥用:想太多。
你写了一个项目,当前只需要处理 User 类型。但你总觉得"万一以后要处理 Order 呢?"于是你给 Service 层加了个泛型参数、给 Repository 层加了个泛型参数——整个代码变成了类型参数的迷宫。
最经典的现场:
// ❌ 过度抽象:未来可能永远不会到来
type Repository[T Entity] interface {
Get(id string) (*T, error)
Save(entity *T) error
}
type UserRepository struct { ... }
func (r *UserRepository) Get(id string) (*User, error) { ... }
// 整个系统多了一层类型参数,看代码的人需要多转一圈脑子
// 而实际场景中 UserRepository 和 OrderRepository 的 Get/Save 逻辑完全不同
// 泛型接口只是让代码看起来「通用」了,实际上每一行都是具体的
等你有第二个类型了再抽象。 没有第二个之前,写死的具体代码就是最好的代码。YAGNI(You Ain't Gonna Need It)在泛型时代比在面向对象时代更正确——因为泛型引入的类型参数认知成本比继承高。
2. 标准库 io.Reader/Writer 能解决的问题——别画蛇添足
Go 标准库的设计哲学是基于接口组合,而不是泛型。io.Reader、io.Writer、error——这些是 Go 最优雅的设计,它们靠接口就能工作,不需要泛型。
// ❌ 别这么干:io.Reader 本身就是通用的
func Process[T io.Reader](r T) ([]byte, error) {
return io.ReadAll(r)
}
// ✅ 就这么写:接口已经够通用了
func Process(r io.Reader) ([]byte, error) {
return io.ReadAll(r)
}
判断标准:如果接口的参数和返回值不需要关联类型约束,那就是接口的事,不是泛型的事。 io.Reader 的 Read 方法参数和返回值都是 []byte,不涉及调用方的类型。这种情况下套一层泛型除了增加编译开销和代码噪音,没有任何好处。
3. 性能敏感路径——不要假设泛型没有开销
泛型不是 C++ 模板——Go 的泛型实现采用了字典传递(dictionary passing)加上部分单态化(monomorphization)。大部分场景下性能差异在误差范围内,但以下情况需要留意:
- 热路径上的装箱操作:如果泛型类型参数含有方法调用,编译器可能无法完全内联,产生额外的间接调用开销
- 代码膨胀:每个不同的具体类型组合都会产生一份代码实例。如果你的泛型函数被几十种不同类型调用,二进制体积会显著增长。对于运行在 1.5GB 内存 VPS 上的服务,这可能是个问题
看一个我遇到的真实例子:
// 某个 Logger 框架的泛型包装
func LogAndProcess[T any](ctx context.Context, input T) (T, error) {
// 记录日志
log.Printf("processing: %T", input)
// 处理
result, err := process(ctx, input)
if err != nil {
log.Printf("failed: %v", err)
}
return result, err
}
这段代码被用在 20+ 不同类型上。结果:二进制体积增加了约 8%,GC 压力轻微增加。对于服务端程序这不是问题,但对于 CLI 工具或者嵌入式场景,这就是真实的代价。
结论:先写具体的,profile 确认热点后,只在确实减少样板代码的地方泛型化。 不要为了「优雅」在热路径上搞泛型。
我现在的原则
四年实战,我现在的原则就三条:
- 容器和包装器,无脑泛型。Set、Cache、Pool、Retry、Optional——这些类型安全本身就是业务需求。
- 算法逻辑重复且类型无关,谨慎泛型。Map/Filter/Reduce 这种,如果项目里确实出现了三四种类型的同样操作,泛型化。只出现一种就不动。
- 接口已经够用的、只有一个具体类型的、热路径的,不碰泛型。硬上就是装逼。
泛型是工具,不是信仰。该用的时候就该用,不该用的时候别他妈硬用。这和 Go 哲学没有任何冲突——Go 的简洁性从来不是「功能少」,而是「每个功能解决真实问题,不制造新问题」。
泛型解决了一个真实问题。但它不解决所有问题。不要把螺丝刀当锤子用。