泛型

用类型参数、约束与类型集编写可复用且类型安全的 Go 代码,并避开命名类型、推断和泛型方法的常见陷阱。

难度 进阶 时长 标准深度约 12分钟
版本 Go 1.27
what

泛型(generic) 让函数和类型声明类型参数,同一份实现因而能处理一组具体类型,同时保留输入与输出之间的静态类型关系。

trap

约束不是标签,而是编译器允许泛型代码执行的操作集合。comparable 只允许 ==!=,不能用来支持 <

fix

从实现真正需要的操作反推最小约束;需要接纳具有相同底层类型的命名类型时,在 type term 前写 ~

是什么,为什么存在

泛型是带有一个或多个类型参数的函数或类型。调用泛型函数或实例化泛型类型时,具体类型会替换这些参数。编译器仍在编译期检查操作、参数和返回值,不需要调用方把结果从 any 断言回目标类型。

泛型解决的是跨类型重复但结构相同的代码。切片映射、集合查找和栈等代码只关心元素之间的类型关系,却不应为 intstring 和每个业务类型各写一份。类型参数把这种关系写进签名,例如 Map[T, U any]([]T, func(T) U) []U 表明转换函数的输入来自原切片,输出决定结果切片的元素类型。

接口与泛型解决的问题不同。接口值隐藏一个动态具体类型,适合让调用方通过一组方法使用不同实现;泛型在每次实例化中保留具体静态类型,适合算法和容器。若实现只调用 ReadString 这样的方法,普通接口参数往往更直接;若同一个类型出现在多个参数或返回位置,类型参数通常更能表达契约。

Go 1.18 加入了泛型。你会在标准库的 slicesmapscmp 包、通用数据结构以及避免反射的辅助函数中遇到它。它不是消除所有重复代码的机制:当不同类型需要不同语义时,分别实现通常更清楚。

工作原理

类型参数保留类型关系

类型参数(type parameter) 写在声明名称后的方括号中。TKV 只是声明内部使用的类型名称;每个名称后都必须有约束。相邻参数约束相同时可以合写成 [T, U any]

泛型函数的普通参数和返回值可以使用这些名称。泛型类型的字段也可以使用它们,而该类型的方法会在接收器中重新声明对应的接收器类型参数,例如 func (s *Stack[T]) Push(value T)。这里的 T 对应 Stack 的类型参数,不是方法新增的独立参数。

实例化包含两个检查。编译器先确认类型实参满足约束,再用这些实参建立具体的函数或类型实例。实例化后的 Stack[string]Stack[int] 是不同的具名类型,二者不能相互赋值。

约束决定可写的操作

泛型约束(generic constraint) 是一种接口。它描述允许替换类型参数的 type set,也决定函数体中能对该参数执行哪些操作。约束为 any 时,代码只能依赖所有类型都具备的行为,例如赋值、传递和返回。

内置约束 comparable 接受可作为 map 键的类型,并允许 ==!=。它没有承诺顺序,所以 a < b 无法通过编译。需要顺序运算时,可以使用标准库的 cmp.Ordered,也可以声明只覆盖领域所需类型的约束。

约束接口可以嵌入方法、type term 或两者。fmt.Stringer 要求 String() string 方法;~int | ~int64 是一个 union,接纳底层类型为 intint64 的类型。约束中各项取交集,因此同时写方法和 type term 时,类型必须满足两边。

类型集描述候选类型

类型集(type set) 是接口所代表的全部非接口类型集合。方法元素保留实现这些方法的类型,union 元素合并候选 type term,嵌入的多个元素再取交集。泛型代码只能使用集合中每个类型都支持且语义一致的操作。

普通 type term int 只表示预声明类型 int~int 还包括 type Score int 这样的命名类型,因为它们的 底层类型(underlying type) int~ 不是近似运行时匹配;它是约束语法的一部分,由编译器静态判断。

包含 type term 的接口只能用作约束,不能作为普通变量类型。你可以写 func Max[T Ordered](...),但不能声明 var value Ordered 来保存任意有序值。需要装入接口值时,应改用只含方法的基本接口,或明确使用 any 并在边界验证动态类型。

类型推断减少调用噪声

类型推断(type inference) 让编译器从函数实参和约束关系求出省略的类型实参。调用 Map(ids, strconv.Itoa) 时,ids 给出 T,函数值 strconv.Itoa 的签名给出 U。推断成功后,调用方不必写 Map[int, string]

推断只适用于函数调用等规定的上下文,不会让泛型类型字面量凭字段值推断实参。Stack[string]{} 必须写出 string。函数调用也可能因没有足够信息而失败,例如某个类型参数只出现在返回值中;这时要显式提供类型实参,或重新设计签名,让输入携带所需关系。

未类型化常量会参与推断和表示性检查。混合不同种类的常量可能得到比预期更宽的默认类型,也可能找不到同时满足约束与实参的类型。API 的示例和测试应包含变量、命名类型和未类型化常量,而不只测试整数字面量。

泛型类型仍遵守零值规则

泛型结构体、切片别名式定义和其他具名类型在实例化后仍遵守普通 Go 规则。字段的零值由实际类型实参决定:var item Tint0,对指针是 nil。当零值有业务含义时,只返回 T 无法表示操作是否成功。

泛型容器是否可零值使用,取决于其字段和方法。以切片作为存储的栈可以直接 append,因此零值很好用;以 map 作为存储的集合在第一次写入前必须初始化。构造函数、惰性初始化或文档化的零值契约都可以,关键是只选一种清楚的行为。

示例

映射时保留输入输出关系

第一个例子把发票编号转成标签。TU 可以不同,调用点则完全依靠推断。返回类型仍是 []string,无需类型断言。

Go
package main

import (
	"fmt"
	"strconv"
)

func Map[T, U any](values []T, convert func(T) U) []U {
	result := make([]U, len(values))
	for i, value := range values {
		result[i] = convert(value)
	}
	return result
}

func main() {
	invoiceIDs := []int{7, 21, 42}
	labels := Map(invoiceIDs, func(id int) string {
		return "INV-" + strconv.Itoa(id)
	})

	fmt.Println(labels)
}
[INV-7 INV-21 INV-42]

Map 的实现只分配结果、读取 T、调用转换函数并写入 U,所以 any 已经足够。给它增加数值 union 不会带来可用操作,只会无端拒绝字符串或结构体。这个签名的价值在于保留关系,不在于放宽运行时类型。

用 type set 接纳命名类型

这个最大值函数需要 >,因此声明真正支持顺序比较的 type set。Score 是独立的命名类型,但其底层类型为 int~int 让它满足约束。空输入使用额外的布尔值与合法的零值区分。

Go
package main

import "fmt"

type Ordered interface {
	~int | ~int64 | ~float64 | ~string
}

type Score int

func Max[T Ordered](values []T) (T, bool) {
	if len(values) == 0 {
		var zero T
		return zero, false
	}

	best := values[0]
	for _, value := range values[1:] {
		if value > best {
			best = value
		}
	}
	return best, true
}

func main() {
	best, ok := Max([]Score{72, 91, 84})
	fmt.Println(best, ok)

	empty, ok := Max([]Score(nil))
	fmt.Println(empty, ok)
}
91 true
0 false

如果把 ~int 改成 intScore 就不再满足约束。函数不需要知道实际实参是不是 Score;约束已经证明 > 对所有候选类型有效。返回的 best 仍保持 Score 类型。

让泛型容器的零值可用

Stack[T] 的底层表示是切片。nil 切片可以 append,所以调用方不需要构造函数。Pop 返回 (T, bool),避免把空栈与压入的零值混为一谈。

Go
package main

import "fmt"

type Stack[T any] []T

func (s *Stack[T]) Push(value T) {
	*s = append(*s, value)
}

func (s *Stack[T]) Pop() (T, bool) {
	if len(*s) == 0 {
		var zero T
		return zero, false
	}

	last := len(*s) - 1
	value := (*s)[last]
	*s = (*s)[:last]
	return value, true
}

func main() {
	var stages Stack[string]
	stages.Push("review")
	stages.Push("publish")

	for range 3 {
		stage, ok := stages.Pop()
		fmt.Printf("%q %t\n", stage, ok)
	}
}
"publish" true
"review" true
"" false

接收器写成 *Stack[T],因为 PushPop 都要替换切片描述符。元素类型仍由实例化时的 string 决定。若把存储改成 map,零值不再能安全写入,API 契约也必须随之改变。

陷阱

comparable 当成有序约束

切片、map 和函数不满足 comparable;多数标量、指针、通道、接口以及字段均可比较的数组和结构体满足它。但可比较不等于有顺序,布尔值和结构体就是直接反例。

修复方法: 使用标准库 cmp.Ordered,或声明只包含所需预声明类型及其命名类型的约束。若顺序来自业务规则,应接收比较函数,而不是假定 < 能表达该规则。

约束太宽却执行额外操作

把代码改成类型 switch 通常只是绕开静态关系,还会漏掉命名类型。它也会让新增类型分支变成运行时维护工作。

修复方法: 从函数体需要的操作推导约束。需要方法时嵌入方法接口;需要运算符时声明合适的 type term;其实只需传递值时保留 any

忘记 ~ 后拒绝领域类型

强制调用方先转换成 int 会丢失有用的静态类型,还可能让不同领域的标识混在一起。约束是关于类型身份和底层类型的规则,不是一般的转换规则。

修复方法: 如果算法对所有底层类型相同的命名类型都有效,使用 ~int。如果业务上只允许精确的预声明类型,就保留 int,并把这种限制写进 API 文档和测试。

给方法新增类型参数

把所有未来可能用到的类型参数提前塞进接收器,会让每个实例化都携带无关参数。它还把一次操作的关系错误地提升为整个类型的身份。

修复方法: 需要引入 U 时写独立函数,例如 MapStack[T, U any](Stack[T], func(T) U) Stack[U]。只有当某个类型参数决定字段或所有方法的长期契约时,才把它放到泛型类型上。

用零值表示查找失败

生成代码常用 var zero T 避免编译错误,却没有处理调用方如何理解它。这个问题不是泛型独有,但类型参数会让零值形状在写函数时不可知。

修复方法: 返回 (T, bool)(T, error),由调用方显式处理缺失。只有当零值按领域契约就是唯一正确结果时,才返回单个 T

深入 type set 的组合规则

type set 的组合规则

接口元素相交

约束接口的 type set 从所有非接口类型出发,再由每个元素缩小。方法元素保留方法集中含该方法的类型;单个 type term 保留对应类型;嵌入接口取其 type set。一个类型要满足整个约束,必须同时留在每个元素产生的集合中。

这解释了为何约束同时写 ~intString() string 时,不是二选一。候选类型既要以 int 为底层类型,又要声明所需方法。预声明类型 int 自身没有该方法,因此不满足这个交集;定义了方法的命名整数类型可以满足。

union 合并候选项

竖线 | 只在一个 union 元素内部表示并集。~int | ~int64 | ~float64 允许任意一个 term,但约束中的其他元素仍需满足。多项 union 的非接口 term 必须两两不相交,所以 int | ~int 非法,因为前者已经包含在后者中。

union 是封闭清单。选择它意味着新增一种数值类型时,约束不会自动接纳;这有时正是 API 所需的稳定边界。如果算法实际依赖行为而不是预声明表示,比较函数或方法约束可能更容易扩展。

~ 检查底层类型

近似元素 ~TT 必须是自身的底层类型,且不能是类型参数。~MyInttype MyInt int 的情况下无效,因为 MyInt 的底层类型不是它自身;应写 ~int。编译器据此纳入所有底层类型相同的命名类型。

底层类型相同不代表值可以在不同命名类型间直接赋值。约束只决定某个类型能否作为实参以及函数体可用哪些操作。实例化之后,参数和返回值仍保留调用方传入的命名类型。

推断与实例化的边界

函数实参提供方程

编译器把函数形参中出现的类型参数与实参类型统一。若形参是 []T 而实参是 []Invoice,便可求出 TInvoice。若同一个参数在多个位置出现,各位置必须给出兼容的答案,否则调用失败,而不是选择一个共同的 any

约束还可能提供第二层关系。形如 [S ~[]E, E any] 的声明可以先从实参求出 S,再从 S 的底层切片类型推断 E。标准库的泛型切片函数经常使用这种形状,以便既保留命名切片类型,又知道元素类型。

返回上下文不是通用推断来源

不要假设赋值目标总能反向决定函数的类型实参。最可靠的公共 API 让普通实参携带推断所需信息。若构造函数只返回 T 而没有接收与 T 相关的值,调用方通常必须显式写出类型实参。

部分类型实参列表可以提供开头的参数,让编译器推断剩余参数,但这会让参数顺序成为易用性的一部分。把调用方最可能显式提供的类型参数放在前面,并用真实调用测试推断,比只看声明是否漂亮更可靠。

泛型类型必须显式实例化

使用泛型类型时需要实例化,字段值不会替你补上类型实参。写 Pair[int, string]{...}var stack Stack[Task] 或让一个已经实例化的值参与普通赋值都可以。只写 Stack{} 不是借助上下文的简写,而是遗漏类型实参。

实例化产生的类型继续参与普通的可赋值性和方法集规则。接收器是 *Stack[T] 的方法属于相应指针类型的方法集;泛型并没有改变值接收器与指针接收器的区别。审查接口实现时,仍应分别检查 Container[T]*Container[T]

泛型 API 的取舍

优先表达关系

好的类型参数通常在签名中出现多次。它可能连接输入元素与回调参数,连接 map 键与查找键,或者让容器方法返回同一种元素类型。只有一次出现且约束为 any 的类型参数往往没有提供关系,普通参数或接口可能更简单。

不要仅为避免一次类型断言就引入泛型。接口适合异构值和运行时分派,类型参数适合同一实例化内部的一致类型。选择取决于调用方需要保留什么信息,不取决于哪种语法更新。

让约束保持最小且有意义

约束过宽会让函数体无法表达算法,约束过窄会拒绝合法调用方。最小约束是恰好证明实现所需操作的约束,而不是包含 term 最少的约束。接收比较函数的 [T any] 排序器可能比巨大的 Ordered union 更通用,因为顺序由调用方提供。

导出的约束也是 API。增加候选类型通常兼容已有调用,但改变可用操作、删除 term 或改变方法要求会破坏使用方。若约束只服务一个函数且没有复用价值,可以保持未导出,减少承诺范围。

设计清楚的零值契约

泛型类型无法假定 T 的零值可与业务缺失区分。容器查询通常返回 (V, bool),解析或外部操作通常返回 (T, error)。这与 map 查询和标准库惯例一致,也让生成代码更容易被测试。

容器自身的零值则是另一个问题。切片存储常能自然支持零值,map、通道或必须配置容量的结构通常需要初始化策略。文档应分别说明元素缺失的返回契约和容器未初始化时的行为。

不声称未经测量的性能

泛型的代码生成属于编译器实现细节,不是语言规范给出的固定性能保证。不同类型、方法调用、逃逸行为和编译器版本会得到不同结果,因此不能从语法推导零开销或代码膨胀程度。

若性能决定 API 选择,应在目标 Go 版本、真实类型实参和真实工作负载上写基准,并查看逃逸分析与生成代码。没有这些数据时,只讨论类型安全、可读性和维护成本;这些是可以从源码直接审查的性质。

验证泛型边界

泛型实现常在一个常见类型上显得正确,却在约束边缘失败。验证工作应从声明承诺的 type set 出发,而不是从当前调用方碰巧使用的 int 出发。编译测试与运行测试各自覆盖不同问题,两者都需要。

编译期用例检查约束

为每个应支持的类型写一个最小实例化,并让包在 CI 中编译。用例至少应包含预声明类型、一个底层类型相同的命名类型,以及约束明确排除的类型。最后一类可以放进专门的编译失败测试或分析工具测试,不能留成破坏普通测试套件的源码。

编译成功只证明候选类型满足约束,不能证明算法结果正确。Max([]Score{...}) 仍需要检查顺序、重复值和空输入。反过来,只有 int 运行测试也发现不了约束错误地排除了 Score

调用点检查推断

测试应使用文档准备展示的调用形式。若示例省略类型实参,就原样编译该调用,而不是在测试中写出完整的 [int, string] 后声称推断有效。函数值、nil 实参和未类型化常量最容易改变推断结果。

重构参数顺序或把值移到返回位置后,应重新编译所有代表性调用。函数体可能完全不变,但推断所需的信息已经消失。API 的易用性存在于调用点,不只存在于声明。

运行时用例检查零值

每个返回 T 的失败路径都要使用合法零值作为成功数据测试。对 Stack[string],应真的压入 "";对 Stack[*Task],应考虑业务上是否允许压入 nil 指针。只有这样才能确认额外的布尔值或错误被正确处理。

容器也要从未经构造的零值开始测试。切片实现可能正常工作,map 实现则可能在写入时 panic。这个测试能阻止内部表示改变后,公开的零值契约悄悄失效。

工具检查不能替代契约检查

go test 负责运行行为测试并编译实例化路径,go vet 检查一部分可疑构造。两者都不会判断约束是否比业务契约更窄,也不会告诉你一个类型参数是否根本没有表达关系。这些问题仍需要按签名逐项审查。

测试矩阵不必枚举 type set 中的每个类型。选择能区分规则的代表:精确 term 与近似 term、零值与非零值、可推断与需显式实参的调用。每个用例都应对应一条可能失败的声明,而不是为了数量重复同一种证据。

验证包边界

在使用方包中至少编译一个调用,因为导出名称、类型推断和未导出约束的行为只有跨越包边界后才完整呈现。包内测试可能因可见额外名称而漏掉 API 问题。

保持调用用例很小,并让失败指向一项契约。它们是接口兼容性测试,不是另一套算法测试。

延伸阅读

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?