9.1 类型参数与约束
Go 在 1.18 才加入泛型,是主流语言里最晚的一批。这种「迟到」换来了一个克制的设计:泛型不是万能的抽象工具,而是解决「同一套逻辑作用于多种类型」的专用手段。对 TaskAPI 来说,最典型的场景是分页——Page[Task]、Page[User]、Page[Log] 的元信息(总数、页码、页大小)完全相同,只有元素类型不同,正是泛型的用武之地。
本节给 TaskAPI 引入第一个泛型类型
Page[T],并把它的容器放在internal/pagination包。为了让读者先掌握语法,我们会从最小的泛型函数Sum、Max讲起,再过渡到泛型类型。第 9 章的另外两节会用slices/maps重写查询、并讨论泛型的取舍。
9.1.1 没有泛型时怎么写
在泛型之前,要写一个「求任意数值切片之和」的函数,只有两条路。
第一条:为每种类型写一份。 SumInt、SumFloat64……代码重复,且每加一种类型就要加一个函数。
第二条:用 any(interface{})加类型断言。 写出来是这样:
func SumAny(xs []any) any {
var total float64
for _, x := range xs {
switch v := x.(type) {
case int:
total += float64(v)
case float64:
total += v
}
}
return total
}
问题一目了然:返回值丢失了类型信息(变成了 any,调用方还得断言回来),每种类型都要在 switch 里补一笔,而且编译期完全不检查——传个字符串进去,运行时才炸。泛型要解决的就是这三宗罪。
9.1.2 泛型函数:类型参数登场
把 Sum 重写成泛型版本:
// Number 是一个类型集约束,~ 表示底层类型匹配即可。
type Number interface {
~int | ~int64 | ~float64
}
// Sum 对任意数值切片求和。
func Sum[T Number](xs []T) T {
var total T
for _, x := range xs {
total += x
}
return total
}
拆解语法:
[T Number]是类型参数列表,T是类型形参,Number是它的约束。- 返回值是
T而不是any,调用方拿到的是精确类型。 total += x能编译,因为约束Number保证了T支持+。
调用时类型由实参推断,无需手写:
fmt.Println(Sum([]int{1, 2, 3})) // 6
fmt.Println(Sum([]float64{1.5, 2.5})) // 4
9.1.3 约束:类型集与 ~ 近似元素
约束的本质是描述一个类型集合。Number 说「T 可以是 int、int64 或 float64」。这里有两个关键字要理解:
|(并集):把若干类型并列,表示「是其中之一即可」。~(近似元素):表示「底层类型是某个类型的所有类型」。
~ 为什么重要?看这个例子:
// MyInt 的底层类型是 int,靠 ~int 也能满足 Number。
type MyInt int
func main() {
fmt.Println(Sum([]MyInt{1, 2, 3})) // 6
}
如果 Number 写成 int | int64 | float64(不带 ~),MyInt 就不满足约束——尽管它的底层类型就是 int。带上 ~int,所有底层类型为 int 的命名类型都能进来。实践准则:自定义约束时几乎总是写 ~,除非你确实只想匹配那一个具体类型。
实测三个调用的输出:
6
4
6
9.1.4 标准库现成的约束
不必每个约束都自己定义。标准库提供了两个最常用的:
| 约束 | 位置 | 含义 |
|---|---|---|
comparable | 语言内置 | 可用 ==/!= 比较的类型(含 != 自反) |
cmp.Ordered | cmp 包 | 支持 </> 的有序类型(整数、浮点、字符串) |
用 cmp.Ordered 写一个泛型 Max:
import "cmp"
// Max 返回两个值中较大者,约束用标准库的 cmp.Ordered。
func Max[T cmp.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
fmt.Println(Max("apple", "pear")) // pear
fmt.Println(Max(3, 7)) // 7
cmp.Ordered 的定义就是一组带 ~ 的并集(~int | ~int8 | ... | ~string),可以直接拿来用,别重复造轮子。注意 comparable 与 cmp.Ordered 的分工:能比较相等用 comparable,能排序才用 cmp.Ordered。字符串两者都满足,但结构体通常只满足前者。
9.1.5 类型推断:什么时候可以省略
Go 的类型推断规则是:能从实参推断出来的类型参数,调用时可以不写。三种情况:
Sum([]int{1, 2, 3}) // 推断 T=int
Max("a", "b") // 推断 T=string
Max[int](1, 2) // 显式指定,也可以
什么时候必须显式写?当类型参数无法从参数推断时。最典型的是「只出现在返回值里」的类型,例如一个构造空 Page[T] 的函数:
p := pagination.New[task.Task](nil, 1, 10) // 必须写明 T
因为参数是 nil,编译器无从推断 T。这条规则解释了为什么很多泛型函数的类型参数会「自然地」出现在参数列表里——那是为了可推断性做的设计。
9.1.6 泛型类型:Page[T]
函数之外,类型也能带类型参数。TaskAPI 的分页结果 internal/pagination/page.go:
// Package pagination 提供与元素类型无关的分页结果。
package pagination
// Page 是一页数据及其元信息。
type Page[T any] struct {
Items []T
Total int
Number int
Size int
}
// New 从完整切片切出第 number 页(从 1 开始)。
func New[T any](all []T, number, size int) Page[T] {
if size <= 0 {
size = 10
}
if number < 1 {
number = 1
}
start := (number - 1) * size
if start > len(all) {
start = len(all)
}
end := start + size
if end > len(all) {
end = len(all)
}
return Page[T]{Items: all[start:end], Total: len(all), Number: number, Size: size}
}
几处关键点:
- 约束是
any。分页对元素类型没有任何要求——不比较、不排序、不运算,所以用最宽的any。 Page[T]是完整类型,可以作字段、作返回值、作 map 的 value,甚至再嵌套Page[Page[T]]。- 方法定义在泛型类型上时,接收者要写成
func (p Page[T]) ...,方法里不能引入新的类型参数(这是 Go 的刻意限制,避免复杂化)。
实测切页:
tasks := []task.Task{
task.New(1, "写第 9 章"),
task.New(2, "复习 slices"),
task.New(3, "补充测试"),
}
p := pagination.New(tasks, 1, 2)
fmt.Printf("total=%d page=%d size=%d items=%d\n", p.Total, p.Number, p.Size, len(p.Items))
total=3 page=1 size=2 items=2
3 条任务、每页 2 条,第一页返回 2 条——符合预期。
9.1.7 常见错误
初学泛型最容易踩的四个坑:
| 现象 | 原因 | 修法 |
|---|---|---|
cannot use x (variable of type T) as T value in ... | 约束没保证该操作可用 | 加宽约束或改用 cmp.Ordered |
T does not satisfy comparable | 拿 T 当 map key 却没约束 | 约束写 comparable |
cannot infer T | 类型只出现在返回值 | 显式写 New[task.Task](...) |
invalid use of ... outside a generic function | 在方法里引入新类型参数 | 把新参数提到函数或类型上 |
一个经验法则:约束宁窄勿宽。约束越窄,函数体内能做的操作越明确,编译错误也越早暴露。写 any 之前先问自己:「我真的不需要对元素做任何操作吗?」多数时候答案是「其实需要」。
9.1.8 小结
本节的核心概念只有两个:类型参数([T ...])与约束(描述 T 的取值范围)。记住三条:
- 泛型取代的是「为每种类型复制一份代码」与「
any+ 类型断言」,不是取代接口。 ~让命名类型也能满足约束,自定义约束几乎总要带它。- 能用标准库约束就用:
comparable、cmp.Ordered。
下一节我们把这套语法用到实处:用 slices 与 maps 两个泛型标准库,重写 TaskAPI 的排序、去重与查询,并认识 cmp.Compare 与 cmp.Or。
阅读导航:上一节:8.3 测试替身与接口 mock · 下一节:9.2 slices/maps/cmp 标准库 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。