《Go 语言编程入门》9.1 类型参数与约束

本节给 TaskAPI 引入泛型:先看类型参数如何取代 interface{} 与类型断言,再讲约束——内置 comparable、标准库 cmp.Ordered 与自定义类型集,说明 ~ 近似元素为何让别名也能匹配。随后用泛型类型 Page[T] 表达「任意元素的分页结果」,并实测 Sum、Max 与 MyInt 三个例子。

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.Orderedcmp 包支持 </> 的有序类型(整数、浮点、字符串)

用 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 的取值范围)。记住三条:

  1. 泛型取代的是「为每种类型复制一份代码」与「any + 类型断言」,不是取代接口。
  2. ~ 让命名类型也能满足约束,自定义约束几乎总要带它。
  3. 能用标准库约束就用:comparable、cmp.Ordered。

下一节我们把这套语法用到实处:用 slices 与 maps 两个泛型标准库,重写 TaskAPI 的排序、去重与查询,并认识 cmp.Compare 与 cmp.Or。

阅读导航:上一节:8.3 测试替身与接口 mock · 下一节:9.2 slices/maps/cmp 标准库 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练