跳至内容
泛型的正确打开方式:类型集、约束与何时不要使用泛型

泛型的正确打开方式:类型集、约束与何时不要使用泛型

2026年7月2日·
yanlong

泛型最糟糕的用法,是把原本清楚的三行代码改造成一套读者必须先解方程才能调用的抽象。它最好的用法也很明确:同一段算法需要保留多种输入类型的信息,而接口会迫使我们丢失类型或做运行时断言。

判断是否该用泛型,先别问“能不能”,先问“类型参数参与了什么关系”。

泛型保留的是类型关系

下面的 Map 不仅避免重复实现,还表达了输入元素 T 与输出元素 R 的静态关系:

func Map[T, R any](input []T, transform func(T) R) []R {
    output := make([]R, len(input))
    for i, value := range input {
        output[i] = transform(value)
    }
    return output
}

调用方得到的是 []R,不需要 []any,也不需要类型断言:

ids := Map(orders, func(order Order) string {
    return order.ID
})

如果一个抽象只是“接收某个能力并调用它”,普通接口往往更直接:

func WriteReport(w io.Writer, report Report) error

把它改成 func WriteReport[W io.Writer](w W, ...) 没有增加任何有用的类型关系,反而让签名更重。

约束是允许操作的集合

约束不是为了列出“支持的业务类型”,而是为了让编译器知道泛型函数里允许哪些操作。

type Integer interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
        ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}

func Sum[T Integer](values []T) T {
    var total T
    for _, value := range values {
        total += value
    }
    return total
}

~int64 的含义是底层类型为 int64 的所有类型,因此业务自定义类型也能使用:

type Cents int64

total := Sum([]Cents{100, 250, 50})

如果约束写成 int64Cents 不在类型集中。是否使用 ~,取决于算法关心的是精确命名类型,还是底层表示支持的操作。

comparable 只保证能比较

comparable 允许 ==!=,也使类型可以作为 map key。它不保证排序,不保证可哈希结果跨进程稳定,更不代表适合做业务主键。

func IndexBy[K comparable, V any](
    values []V,
    key func(V) K,
) map[K]V {
    result := make(map[K]V, len(values))
    for _, value := range values {
        result[key(value)] = value
    }
    return result
}

这个函数中的 K comparable 是必要约束,因为实现确实构造了 map[K]V。约束应当来自实现使用的操作,而不是作者对未来需求的想象。

类型推断不是总能完成

编译器通常能从普通参数推断类型参数:

result := Map([]int{1, 2}, strconv.Itoa)

但返回值上下文通常不能替你解决所有推断。如果类型参数没有出现在可推断的函数参数位置,就需要显式提供:

func Zero[T any]() T {
    var zero T
    return zero
}

value := Zero[time.Duration]()

API 如果经常要求调用方写一长串类型实参,先检查类型参数是不是过多,或者构造流程是否更适合拆成普通类型和方法。

零值仍然是泛型代码的地基

var zero T 是获得未知类型零值的标准方式。不要用 *new(T),更不需要反射。

func First[T any](values []T) (T, bool) {
    if len(values) == 0 {
        var zero T
        return zero, false
    }
    return values[0], true
}

返回 bool 很重要,因为 T 的零值可能是合法数据。泛型不会消除领域语义,只会让忽略领域语义的代码支持更多类型。

泛型容器要小心“所有权”

type Set[T comparable] map[T]struct{}

func (s Set[T]) Add(value T) {
    s[value] = struct{}{}
}

这个类型看似自然,但零值 Set 写入会 panic。可以用构造函数解决,也可以把实现封装为 struct,在第一次写入时初始化:

type Set[T comparable] struct {
    values map[T]struct{}
}

func (s *Set[T]) Add(value T) {
    if s.values == nil {
        s.values = make(map[T]struct{})
    }
    s.values[value] = struct{}{}
}

后一种设计让零值可用,但也把复制语义变复杂了。泛型数据结构仍然要回答普通数据结构的问题:能否复制、是否并发安全、谁拥有内部存储。

不要用泛型模拟继承层级

类似下面的约束通常是在把其他语言的设计硬搬进 Go:

type Entity interface {
    User | Order | Product
}

如果函数内部最终还是 type switch,泛型没有提供统一算法,只是把枚举从 switch 移到了约束。业务实体通常通过明确方法、组合和接口协作,不需要人为建立“所有实体”的封闭类型集合。

类型联合适合操作符算法、序列化基础表示等确实基于底层类型的代码;不适合充当业务分类系统。

性能不是使用泛型的默认理由

泛型能够避免 any 和类型断言,也可能让编译器获得更多优化空间,但实际代码生成策略由编译器决定。不要因为“泛型一定零成本”就提前重写代码。对热路径使用 benchmark 和逃逸分析验证;对非热路径先优化可读性。

什么时候值得引入泛型

下面三个信号同时出现时,泛型通常很合适:

  1. 多个实现的控制流和算法真正相同;
  2. 输入和输出之间存在需要保留的静态类型关系;
  3. 约束能够用少量、稳定的操作描述。

如果只是为了调用一个方法,用接口;如果类型集合固定且行为不同,用普通分支;如果只有两段很短的重复代码,接受重复也可能比抽象更便宜。

延伸阅读