Notes on Using Go Generics

Go 泛型使用笔记

没有泛型的日子

假设要写一个求和函数,int 一份、float64 一份:

func SumInts(nums []int) int {
var total int
for _, n := range nums {
total += n
}
return total
}
func SumFloats(nums []float64) float64 {
var total float64
for _, n := range nums {
total += n
}
return total
}

两个函数除了类型外都一样,想避开重复通常只能用 any:

// 用 any(也就是 interface{})接,代价是每次都要类型断言,而且错误要等到运行期才会爆
func SumAny(nums []any) any {
var total int
for _, n := range nums {
total += n.(int) // 传进 float64 就 panic
}
return total
}

第一个泛型函数

泛型就像类型的参数,写在函数名后面的方括号里:

func Sum[T int | float64](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
  • [T int | float64]:声明一个类型参数 T,后面那串是约束(Constraint),意思是 T 只能是 int 或 float64。
  • nums []T、返回值 T:函数签名里就可以把 T 当成普通类型使用。
  • var total T:T 的零值。int 是 0、float64 是 0.0。

调用时通常什么都不用多写,编译器会从参数推断出 T:

Sum([]int{1, 2, 3}) // T 推断为 int,得到 6
Sum([]float64{1.5, 2.5}) // T 推断为 float64,得到 4
Sum[int]([]int{1, 2, 3}) // 也可以手动指定
Sum([]string{"a", "b"}) // 编译错误:string 不在约束里

错误发生在编译期,不是运行期。这是泛型和 any 搭配类型断言最大的区别。

约束怎么写

约束本质上是一个 interface,只是它描述的不只是方法,还可以是「哪些类型可以代入」。

any:什么类型都行

any 作为约束时表示不限类型,但也因此不能对值做任何假设,只能搬移、比较指针、放进容器。经典例子是 Map:

func Map[T, U any](s []T, f func(T) U) []U {
result := make([]U, 0, len(s))
for _, v := range s {
result = append(result, f(v))
}
return result
}
names := Map([]int{1, 2, 3}, func(n int) string {
return fmt.Sprintf("no.%d", n)
})
// [no.1 no.2 no.3]

一个函数可以有多个类型参数,这里 T 是输入元素类型、U 是输出元素类型,两者由 f 的签名串起来。

comparable:可以用 == 比较

要在函数里写 == 或 !=,约束就得是 comparable:

func Contains[T comparable](s []T, target T) bool {
for _, v := range s {
if v == target {
return true
}
}
return false
}
Contains([]string{"a", "b"}, "b") // true

comparable 涵盖数字、字符串、布尔值、指针、channel,以及字段全都可比较的 struct 和 array;slice、map、func 不在其中,因为它们本来就不能用 == 比较。

cmp.Ordered:可以用 < > 比较

comparable 只保证 ==,要比较大小得用标准库 cmp 提供的 cmp.Ordered:

import "cmp"
func Max[T cmp.Ordered](s []T) (T, bool) {
var zero T
if len(s) == 0 {
return zero, false
}
m := s[0]
for _, v := range s[1:] {
if v > m {
m = v
}
}
return m, true
}
Max([]int{3, 1, 4}) // 4, true
Max([]string{"b", "a", "c"}) // "c", true

自定义约束与 ~ 符号

约束多了就把它抽成具名 interface:

type Number interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~float32 | ~float64
}
func Sum[T Number](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}

~int 的波浪号读作「底层类型是 int 的所有类型」。区别在这里:

type Celsius float64
Sum([]Celsius{36.5, 37.2}) // 约束写 ~float64 才能过,写 float64 会被挡下

没有 ~ 的话,只有 float64 本身算数,Celsius 这种自定义类型会被排除。实践中写约束默认加 ~ 就对了。

约束里也可以放方法,这时它就和普通 interface 没什么两样:

type Stringer interface {
String() string
}
func JoinAll[T Stringer](items []T) string {
parts := make([]string, 0, len(items))
for _, item := range items {
parts = append(parts, item.String())
}
return strings.Join(parts, ", ")
}

泛型类型

除了函数,struct 也可以有类型参数,例如一个类型安全的 Stack:

type Stack[T any] struct {
items []T
}
func NewStack[T any]() *Stack[T] {
return &Stack[T]{}
}
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
func (s *Stack[T]) Pop() (T, bool) {
var zero T
if len(s.items) == 0 {
return zero, false
}
last := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return last, true
}
func (s *Stack[T]) Len() int {
return len(s.items)
}
// Go 1.27 起,方法也可以声明自己的类型参数。
func (s *Stack[T]) MapTo[U any](f func(T) U) *Stack[U] {
result := NewStack[U]()
for _, item := range s.items {
result.Push(f(item))
}
return result
}

用起来:

s := NewStack[string]()
s.Push("a")
s.Push("b")
v, ok := s.Pop() // "b", true
s.Push(42) // 编译错误:类型已经被锁定为 string
lengths := s.MapTo(func(v string) int { return len(v) })
lengths.Push(42) // OK,lengths 是 *Stack[int]
  • 创建类型实例时不能省略类型参数,&Stack{} 不合法,要写 &Stack[string]{}(所以才常搭配一个 NewStack[T]() 构造函数让推断生效)。
  • 方法的 receiver 要带上 [T]。Go 1.27 起,方法本身也能声明新的类型参数,如上例的 MapTo[U any];在旧版 Go 中,这种需求只能写成独立函数。
  • interface 的方法仍然不能声明类型参数,而且 generic method 不能用来实现 interface method。

标准库已经帮你写好了

上面示范的 Contains、Max 其实不用自己实现,slices 与 maps 这两个泛型包已经涵盖大部分日常需求:

import (
"maps"
"slices"
)
nums := []int{3, 1, 4, 1, 5}
slices.Contains(nums, 4) // true
slices.Index(nums, 4) // 2
slices.Max(nums) // 5
slices.Sort(nums) // 原地排序为 [1 1 3 4 5]
slices.Reverse(nums)
people := []Person{{Name: "b"}, {Name: "a"}}
slices.SortFunc(people, func(x, y Person) int {
return cmp.Compare(x.Name, y.Name)
})
m := map[string]int{"a": 1, "b": 2}
keys := slices.Collect(maps.Keys(m)) // maps.Keys 返回的是 iterator
slices.Sort(keys) // map 遍历顺序随机,要排序自己来

什么时候不要用泛型

泛型解决的是「同一段逻辑要套在多种类型上」,不是「制造多余的抽象」:

  • 只有一种类型会用到就别写泛型,等真的出现第二种再改,Go 的惯例一向是先重复再抽象。
  • 只需要调用方法就用普通 interface。像 func Print(s fmt.Stringer) 这种,写成 func Print[T fmt.Stringer](s T) 没有得到任何好处,只是多打字。区别在于泛型保留了具体类型(可以返回 T、放进 []T),普通 interface 则会抹掉。
  • 逻辑因类型而异就别硬塞。如果函数里开始出现对类型分支的判断,那代表这其实是两个函数。

总结

概念写法用途
类型参数func F[T any](...)把类型变成参数
类型推断F(v) 而非 F[int](v)大多数情况下无需手动指定
any[T any]不限类型,但不能对值做假设
comparable[T comparable]允许 ==、!=
cmp.Ordered[T cmp.Ordered]允许 <、>
~~int | ~float64涵盖底层类型相同的自定义类型
泛型类型type Stack[T any] struct类型安全的容器
类型参数写在方括号里、约束决定你能对值做什么

延伸阅读