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.Readerio.Writererror——这些是 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.ReaderRead 方法参数和返回值都是 []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 确认热点后,只在确实减少样板代码的地方泛型化。 不要为了「优雅」在热路径上搞泛型。


我现在的原则

四年实战,我现在的原则就三条:

  1. 容器和包装器,无脑泛型。Set、Cache、Pool、Retry、Optional——这些类型安全本身就是业务需求。
  2. 算法逻辑重复且类型无关,谨慎泛型。Map/Filter/Reduce 这种,如果项目里确实出现了三四种类型的同样操作,泛型化。只出现一种就不动。
  3. 接口已经够用的、只有一个具体类型的、热路径的,不碰泛型。硬上就是装逼。

泛型是工具,不是信仰。该用的时候就该用,不该用的时候别他妈硬用。这和 Go 哲学没有任何冲突——Go 的简洁性从来不是「功能少」,而是「每个功能解决真实问题,不制造新问题」。

泛型解决了一个真实问题。但它不解决所有问题。不要把螺丝刀当锤子用。