Go语言泛型设计
涛叔从18年转向Go语言之后,一直关注其发展。在众多缺失的特性当中,泛型无疑是呼声最高的那个。经过几年的努力,泛型特性终于要跟随 1.18 版本发布了。这是一个里程碑。考虑到Go泛型的原始设计文档比较艰涩,而且结构比较杂乱,今天就把自己的理解整理成文,分享给大家。因为内容很多,我自己对英文设计文档的理解也有限(尤其是类型推导部分),错误再所难免。欢迎各位读者通过留言批评指正。本文是Go泛型系列的第一篇,后续还会发表更多相关内容,也欢迎关注。
我之前写过Go语言泛型的进化,着重介绍了泛型语法设计的演化,欢迎阅读。
类型参数(Type parameters)
Go语言中把泛型(Generic)叫做类型参数。我们知道,Go语言是静态强类型语言。我们在写代码的时候需要为变量指定明确的类型。而泛型编程则恰恰是编写可以适应于不同类型的代码,所以我们需要一种描述不同类型的方法。
下面是Go语言泛型设计方案中的示例:
// Print 打印切片 s 的所有元素。
// 此方法可以打印任意类型的切片成员。
func Print(s []T) {
for _, v := range s {
.Println(v)
fmt}
}
T
表是切片成员的类型,但T
的实际类型在定义Print()
的时候是不确定的,需要在调用该函数的时候指明。也就是说,我们在调用Print()
函数的时候需要额外传入一个特殊参数来指定T
的具体类型。这种特殊的参数就叫类型参数。
既然函数Print()
需要接收类型参数,那它就得声明自已所需要的类型参数。于是便有了类型参数的声明语法:
// Print 打印切片 s 的所有元素。
// 该函数定义了一个类型参数 T,并且使用 T 作为入参切片 s 的元素类型
func Print[T any](s []T) {
// 同上
}
Go语言在原来的函数名和函数参数列表之间插入了一组方括号,来表示类型参数。跟函数参数一样,我们需要为每一个类型参数指定「类型」,这种类型的类型Go语言称之为约束。我们在下文会详细分析。现在,大家只需要知道any
是一种特殊的约束,表示对应的类型参数可以接受任意类型,也就是没有约束。
所有的类型参数需要在函数调用的时候指定,所以我们需要另一种语法,示例如下:
// 调用 Print 打印 []int{1,2,3}
// 因为切片 s 的成员类型为 int,所以需要指定 T 的值为 int
[int]([]int{1,2,3})
Print// 输出
// 1
// 2
// 3
在调用函数Print()
的时候,Go语言规定在函数名和参数列表之前插入方括号,在方括号里指名类型参数的实际类型。在上面的例子中,因为入参 s 的实际类型是[]int
,所以需要给类型参数T
传入类型int
。如果想打印浮点数切片,则可以:
[float64]([]float64{0.1,0.2,0.3})
Print// 输出
// 0.1
// 0.2
// 0.3
类型参数不光可以用于声明函数入参的类型,可以用于声明出参类型,比如:
// Pointer 返回任意参数的指针。
[T any](t T) *T {
Pointerreturn &t
}
使用方式如下:
[int](1) // 返回 *int 类型,指向的值为 1
Pointer[float64](0.1) // 返回 *float64 类型,指向的值为 0.1 Pointer
泛型函数可以声明多个类型参数,比如:
// Must2 接受 t1, t2 和 err 三个参数,如果 err 不为空,则 panic
// 否则返回 t1 和 t2。
// 多类型参数的约束语法跟普通函数的参数类型相同。
func Must2[T1, T2 any](t1 T1, t2 T2, err error) (T1, T2) {
if err != nil {
panic(err)
}
return t1, t2
}
// 假设我们需要调用 foo 函数
func foo() (int, float64, error)
// 如果 foo 函数返回 err 则会 panic。
, f := Must2[int, float64](foo()) i
除了泛型函数外,Go语言还支持在类型定义中声明类型参数。例如:
// Vector 是一个切片,其元素的类型由类型参数 T 确定。
// T 的实际类型需要在声明 Vector 对象的时候指定。
type Vector[T any] []T
如果我们需要保存int
元素,可以这样定义:
var v Vector[int] // v 的类型为 []int
声明了类型参数的类型定义我们称之为泛型类型(Generic Types)。对于泛型类型,我们还可以定义泛型方法,比如:
// Push 向容器尾部追加新元素
func (v *Vector[T]) Push(x T) { *v = append(*v, x) }
因为类型参数已经在声明v
的时候指定了,所以调用函数Push()
就不需要再传入类型参数:
var v Vector[int]
.Push(1) // 不需要指定类型参数
v.Println(v) // 输出 [1] fmt
无论是泛型函数还是泛型类型,都需要在使用的时候传入具体的类型参数。这很自然,却也很繁琐。比如:
[int]([]int{1,2,3})
Print[float64]([]float64{0.1,0.2,0.3}) Print
为了方便使用泛型函数或者类型的开发者,Go语言支持通过实际传入的参数类型推导(inference)参数的实际类型!
所以,上述函数调用可以简写为:
([]int{1,2,3}) // 推导出 T 的实际类型为 int
Print([]float64{0.1,0.2,0.3}) // 推导出 T 的实际类型为 float64
Println(0.1) // 推导出 T 的实际类型为 float64 Pointer
泛型类型也可以推导:
:= Vector{1} // 推导出 T 的实际类型为 float64 v
通过泛型推导,开发者可以像使用普通函数或者类型一样使用泛型函数和泛型类型,简单清晰。这是了不起的设计。但泛型推导非常复杂,我们将在本文的后半部分详细介绍。
到现在,我们已经介绍完Go语言最主要的泛型语法。简单总结如下:
- 函数可以定义类型参数:
func F[T any](p T) { ... }
。 - 可以在参数声明和函数体中使用类型参数指定类型。
- 类型定义也可以指定类型参数:
type M[T any] []T
。 - 类型参数必须指定类型约束:
func F[T Constraint](p T) { ... }
。 - 使用泛型函数或者类型需要传入类型参数。
- 通过泛型推导可以减少指定类型参数,简化泛型的使用。
下面我们详细讨论泛型的约束和泛型推导。
泛型约束(Constraints)
我们在前一节讲过,所有类型参数都需要指定类型约束。我们还介绍说any
表示一种特殊的约束,可以接受所有类型。
那为什么类型参数需要约束呢?看下面的例子:
// Stringify 将任意类型的切片转化成对应的字符串切片
func Stringify[T any](s []T) (ret []string) {
for _, v := range s {
= append(ret, v.String()) // 错误
ret }
return ret
}
这里类型参数T
的约束为any
,所以切片s
的元素可以是任何类型。也就是说s
的元素可以就没有String()
方法。比如我们尝试执行:
[int]([]int{1,2,3}) Stringify
此时T
的实际类型为int
,所以s
的类型为[]int
,进而v
的类型为int
。显然,int
没有String()
方法,必然会报错!
所以说,对于Stringify
函数,我们需要对类型参数T
的范围加以限制。具体来说,我们只能给T
传入那些带有String()
方法的类型。
再举一个例子:
// Max 返回两者中比较大的值
func Max[T any](a, b T) T {
if a > b { // 错误
return a
}
return b
}
如果我们想确定两个整数中的最大值,可以
:= Max(1, 2) // 返回 2 m
但如果我们想比较两个复数呢?
:= Max(1+2i, 3+4i) m
这样的调用会报错。为什么呢?因为复数不能比较大小,所以在Go语言中complex(64|128)
不支持比较运算符!
所以说,对于Max
函数,我们同样需要限制T
的取值范围。具体来说,我们只能给T
传入支持>
运算的类型。
以上就是需要给泛型指定约束的原因。我们需要泛型约束来限定类型参数所支持的函数和运算符。
但有一点需要明确,加了泛型约束并不意味着不再报编译错误。如果在调用泛型函数的时候传错类型仍然会报错,但编译器会在最开始调用的时候明确报错,而不是要到了对应的函数体内再报错。如果没有约束,那编译报错层级可能非常深,对开发者排查错误极不友好(参考C++的模板报错)。
如果某类型约束不限制类型实现的函数,同时也不限制类型支持的运算符,那就意味着对应的类型参数可以接受任意类型。这种特殊的约束就是any
。
Go语言已经可以使用interface
来限制对应所需要实现的方法。如果想支持所有对象,则可以使用臭名昭著的interface{}
。因为interface{}
又臭又长,这样Go官方引入了any
关键字来替换它。而且,我们以后可以在非泛型代码中使用any
来替换interface{}
了,是不是很期待。
但是Go语言的接口无法限制对象支持的运算符。Go团队综合考虑多种因素,最终决定对现有的interface
进行扩展,使其在用作泛型约束的时候支持限制运算符。具体的语法我们会在下面的章节讨论。
在深入讨论泛型约束之前,我们有必要说说这个any
约束。
any
约束
因为对类型没有限制,我们在写代码的时候只能使用所有类型都支持的语法:
- 声明或者定义变量
- 同类型变量之前相互赋值
- 用作函数的参数或者返回值
- 获取对应变量的地址
- 将对应变量转换成
interface{}
或者赋值给interface{}
类型的变量 - 将
interface{}
类型的变量转换成对应类型的变量:t, ok := v.(T)
- 在 switch 类型枚举使用对应类型:
switch v.(type) { case T: /* ... */ }
- 构造复合类型,比如
[]T
- 传给某些内置函数,比如
p := new(T)
所以any
也并非可以随心所欲,没有绝对的自由。
函数约束(Functions Constraints)
回到上面的Stringify
泛型函数
// Stringify 将任意类型的切片转化成对应的字符串切片
func Stringify[T any](s []T) (ret []string) {
// 同上
}
我们希望类型参数T
所有的取值都实现String() string
函数,所以我们可以:
// Stringer 是泛型类型约束,要求所有类型都需要实现 String 方法。
// 所以在泛型代码中可以调用该类型变量的 String 方法。
// String 方法返回变量的字符串表示。
type Stringer interface {
() string
String}
从形式到内容都跟普通的接口没有任何差别。然后我们可以修改Stringify
函数:
// Stringify 将任意类型的切片转化成对应的字符串切片
func Stringify[T Stringer](s []T) (ret []string) {
for _, v := range s {
= append(ret, v.String()) // 正确
ret }
return ret
}
这个时候,下述代码就会报编译错误:
([]int{1,2,3}) Stringify
因为int
类型没有实现String() string
方法,所以不能传给类型参数T
。
而下面的方法则会正常运行:
type MyInt int
func (i MyInt) String() string { return strconv.Itoa() }
(MyInt{1,2,3}) // 返回 []string{"1","2","3"} Stringify
我们也可以不单独声明Stringer
接口,而是写成:
func Stringify[T inference{ String() string }](s []T) (ret []string) {
// 同上
}
效果是一样的。如果我们不想对T
的范围做任何限制,可以写成:
func Stringify[T interface{}](s []T) { ... }
是不是比func Stringify[T any](s []T) { ... }
要难看很多呢?
当然,我们也可以使用别人定义好的接口限制类型参数,比如:
func Stringify[T fmt.Stringer](s []T) (ret []string) {
// 同上
}
以上就是函数约束的主要内容,下面我们讨论运算符约束。
运算符约束(Operators Constraints)
回到前面的例子:
// Max 返回两者中比较大的值
func Max[T any](a, b T) T {
if a > b { // 错误
return a
}
return b
}
函数Max
要求所有的T
都需要支持>
运算符。如何表达这种约束呢?一种办法是把所有运算符操作都转化成函数调用,这样我们就可以使用接口来限制运算符操作。但这种方式实现起来非常复杂。最终Go语言官方选择了一种不那么优雅但实现起来非常简单的办法:类型集合。
Go语言不允许重载运算符。也就是说,只能Go语言内置的对象才支持运算符操作。我们不能自已声明一个struct
然后尝试使用>
比较对应变量的大小。这就让限制对象的运算符这个事变得比较简单。因为内置类型有限,我们完全可以把所有支持的类型都枚举出来。
如果想限制类型参数的范围是所有Go语言内置的有符号整型,我们可以:
// PredeclaredSignedInteger 只能匹配内置的有符号整数类型
type PredeclaredSignedInteger interface {
int | int8 | int16 | int32 | int64
}
所以说PredeclaredSignedInteger
只允许传入内置的五种有符号整数类型,传入其他类型都会报编译错误。
我们知道,Go语言允许重新定义自已的类型,比如:
type MyInt8 int8
虽然MyInt8
的底层类型还是int8
,但MyInt8
没法匹配PredeclaredSignedInteger
约束。但是MyInt8
所支持的运算符操作又跟int8
完全相同,所以我们需要一种语法来指明可以同时匹配int8
和MyInt8
。于是Go语言引入了近似约束(Approximation Constraint)的概念,语法如下:
// Int8 匹配所有底层类型为 int8 的类型
type Int8 interface {
~int8
}
注意,这里在int8
之前添加~
表示近似匹配。只要底层类型为int8
就可以。所以MyInt8
可以匹配Int8
约束。
有了近似约束,我们的表达能力一下子就上了一个台阶。所有支持比较运算符的类型可以写成如下约束:
// Ordered 限制所有支持比较运算符的类型。
// 也就是说符合条件的类型都支持 <, <=, >, 和 >= 运算符。
type Ordered interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
~float32 | ~float64 |
~string
}
我们可以把上面的Max
函数改写成:
// Max 返回两者中比较大的值
func Max[T Ordered](a, b T) T {
if a > b { // 正确
return a
}
return b
}
这个时候我们再执行Max(1+2i, 3+4i)
就会触发编译错误。
复合约束
在泛型约束中,我们不但可以通过|
枚举可能的基本类型或者近似类型,还可以枚举其他约束。我个人称之为复合约束。
并集约束
比如匹配所有有符号整数的约束为:
// SignedInteger 匹配所有有符号整数类型
type SignedInteger interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
匹配所有无符号整数的约束为:
// UnsignedInteger 匹配所有无符号整数类型
type UnsignedInteger interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}
所以匹配所有整数的约束可以简写成:
// Integer 匹配所有整数类型
type Integer interface {
| UnsignedInteger
SignedInteger }
这里的本质是使用|
表示并集的关系,其结果就是Integer
可以匹配SignedInteger
和UnsignedInteger
匹配的所有结果的并集。
交集约束
我们可以表示两个或者多个约束的交集,比如:
// StringableInteger 匹配所有实现了 String() 方法的整数类型
type StringableInteger interface {
Integer
Stringer}
这里我们在StringableInteger
约束中嵌入了Integer
和Stringer
两个约束,表示两者匹配结果的交集。符合该约束的类型不仅底层类型是整型,还要实现String() string
方法。
我们也可以直接列出对应的类型列表和需要实现的函数列表,比如:
// StringableSignedInteger 匹配所有实现 String 方法的有符号整数类型
type StringableSignedInteger interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
() string
String}
对于一些简单的使用场景,我们甚至可以省略interface
关键字。比如:
type Max[T interface{int|uint}](a, b T) { ... }
可以直接简化成:
type Max[T int|uint](a, b T) { ... }
泛型约束
Go语言还支持在约束中声明类型参数!比如:
// SliceConstraint 匹配所有类型为 T 的切片,但 T 的类型需要是使用的时候指定!
type SliceConstraint[T any] interface {
[]T
}
我们在使用的时候需要为约束指定具体的类型参数,比如:
// Map 接受一个切片对象和一个转换函数。
// Map 声明了两个类型参数 S 和 E,其中 S 的约束为 SliceConstraint。
// SliceConstraint 声明了类型参数 T,Map 将 T 转成 E,最终 S 的实际约束为 []E。
func Map[S SliceConstraint[E], E any](s S, f func(E) E) S { ... }
这个例子看起来非常复杂,而且好像多此一举。完全可以写成这个样子嘛:
func Map[S []E, E any](s S, f func(E) E) S { ... }
其实不然,这涉及到泛型推导的问题,我们会在后文详细讲解。
在泛型约束中,我们还可以声明自已引用自已的约束。比如下面的例子:
// Equaler 限制类型必须实现 Equal 方法,但参数 T 需要在使用的时候指定。
type Equaler[T any] interface {
(v T) bool
Equal}
// Index 从切片 s 中查找元素 e 的索引。
// 类型参数 T 的约束为 Equaler[T],需要实现 Equal(v T) bool 方法。
// 这里在声明 T 的约束的时候又用到了 T 本身。
func Index[T Equaler[T]](s []T, e T) int {
for i, v := range s {
if e.Equal(v) {
return i
}
}
return -1
}
相互约束
Go语言不但支持在约束中定义类型参数,更支持约束相互引用。目的是解决实际中比较复杂的问题,比如图论问题。
以图论为例,如果我们想写一系列图论算法,那么我们需要Edge
和Node
两种类型:
Node
类型需要实现Edges() []Edege
方法Edge
类型需要实现Nodes() (Edge, Edge)
方法
一张图可以表示为[]Node
。这就足够实现图论算法了。下面的代码比较烧脑,请对照注释仔细阅读:
// NodeConstraint 是一个简单的约束,要求被约束的类型一定要实现 Edges 方法。
// 但是 Edges 方法返回的 []Edge 类型为 Edge,没有确定,
// 需要在使用 NodeConstraint 的时候指明。
type NodeConstraint[E any] interface {
() []E
Edges}
// EdgeConstraint 也是一个简单的约束,要求被约束的类型一定要实现 Nodes 方法。
// 同样 Nodes 方法返回的 from t to 类型为 Node,没有确定,
// 需要在使用 EdgeConstraint 的时候指明。
type EdgeConstraint[N any] interface {
() (from, to N)
Nodes}
// Graph 为泛型类型,声明了两个类型变量为 Node 和 Edge。
// Node 类型必须满足 NodeConstraint 约束,并且指定了 NodeConstraint 中 E 的类型为 Edge。
// 所以 Node 类型必须实现 NodeConstraint 中规定的 Edges 方法,返回 []Edge。
// 同理,Edge 类型必须实现 EdgeConstraint 中规定的 Nodes 方法,返回 (from, to Node)。
type Graph[Node NodeConstraint[Edge], Edge EdgeConstraint[Node]] struct { ... }
// New 方法通过传入一组 []Node 构造 Graph 对象
func New[Node NodeConstraint[Edge], Edge EdgeConstraint[Node]] (nodes []Node) *Graph[Node, Edge] { ... }
// ShortestPath 查询图中两点之间的最短路径
func (g *Graph[Node, Edge]) ShortestPath(from, to Node) []Edge { ... }
以上只是声明部分,下面继续看调用部分。先定义具体的结构:
// Vertex 表示图的顶点。
type Vertex struct { ... }
// Edges 返回连接该顶点的所有边。
func (v *Vertex) Edges() []*FromTo { ... }
// FromTo 表示图的边。
type FromTo struct { ... }
// Nodes 返回边的两个端点
func (ft *FromTo) Nodes() (*Vertex, *Vertex) { ... }
然后调用图算法:
:= New[*Vertex, *FromTo]([]*Vertex{...})
g := g.ShortestPath(a, b) edges
现在分析一下类型参数的初始化过程。
首先,Graph
的类型参数Node
和Edge
分别被替换为*Vertex
和*FromTo
。然后编译器开始检查类型约束。对于Node
类型,它的约束为NodeConstraint[*FromTo]
,所以需要实现Edges() []*FromTo
方法。而*Vertex
也确实实现了Edges
方法。对于Edge
类型,它的约束为EdgeConstraint[*Vertex]
,所以需要实现Nodes() []*Vertex
方法,显然*FromTo
也实现了该方法。到此,约束检查结束。
所以,当我们调用g.ShortestPath(a, b)
的时候,edges
的类型为[]*FromTo
!
有同学可能会说这种写法太复杂,太烧脑,完全可以把NodeConstraint
和EdgeConstraint
简化成:
type NodeInterface interface { Edges() []EdgeInterface }
type EdgeInterface interface { Nodes() (NodeInterface, NodeInterface) }
但这样一来就要求修改Vertex
和FromTo
的函数定义:
func (v *Vertex) Edges() []EdgeInterface { ... }
func (ft *FromTo) Nodes() (NodeInterface, NodeInterface) { ... }
但这样做的结果就是调用Edges
函数返回的是一个抽象的[]EdgeInterface
切片,而非具体的[]*FromTo
列表。
内置约束
comparable
前面我们说Go语言仅支持对内置类型执行运算符操作。但有两个操作符例外,它们是==
和!=
。
这两个操作符允许比较用户自定义的 struct 对象,所以需要单独处理。
从这里可以看出Go语言设计的不一致性。一方面他们不希望引入运算符重载,这样会大大增加Go语言的复杂度;另一方面,他们又不得不支持 struct 类型的相等比较。为此,还是得在编译期间自动插入相等比较函数,然后「重载」
==
和!=
运算符。
为此,Go语言单独为这个两操作符引入了comparable
约束。
也就是说,如果我们希望类型支持相等比较,我们可以写成这样:
// Index 查询元素 x 在切片 s 中的位置。
func Index[T comparable](s []T, x T) int {
for i, v := range s {
if v == x {
return i
}
}
return -1
}
constraints
为方便开发者,Go内置一个constraints
包,提供常用的类型约束:
// Signed 有符号整数类型
type Signed interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
// Unsigned 无符号整数类型
type Unsigned interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}
// Integer 整数类型
type Integer interface {
| Unsigned
Signed }
// Float 浮点数类型
type Float interface {
~float32 | ~float64
}
// Complex 复数类型
type Complex interface {
~complex64 | ~complex128
}
// Ordered 支持排序的类型,即支持操作符:< <= >= >
type Ordered interface {
| Float | ~string
Integer }
以上基本上涵盖了泛型约束的大部分内容,下面我们开始讨论泛型推导问题。
泛型推导(Type inference)
我们在前面已经简单介绍过泛型推导,其目的是尽量简化泛型函数/类型的使用,减少不必要的类型参数传递。
本节详细分析泛型推导的功能和设计。在开始之前,看一下泛型推导的效果。
// Map 对入参 s 切片中的每个元素执行函数 f,将结果保存到新的切片并返回。
// 类型参数 F 和 T 需要在调用的时候指定。
func Map[F, T any](s []F, f func(F) T) []T { ... }
Go语言支持如下几种情况的类型推导:
var s []int
:= func(i int) int64 { return int64(i) }
f var r []int64
// 普通情形,指定全部类型参数,前文已经介绍过
= Map[int, int64](s, f)
r
// 仅指定第一个类型参数,自动推导后面的类型参数
= Map[int](s, f)
r
// 自动推导所有类型参数
= Map(s, f) r
如果在使用泛型函数/类型的时候未指明全部的类型参数,编译器就会尝试推导缺失的类型。如果推导失败则会报编译错误。
Go语言使用所谓的类型同化(Type unification)进行类型推导。但是原文写的比较抽象,所以本文主要从具体例子出发来说明类型推导的工作过程。
类型同化(Type unification)
所谓同化,就是比较两个类型是否等价。两个类型能否同价取决于:
- 结构是否相同,比如
[]int
跟[]T
等价(如果T
匹配int
),跟map[int]T
不等价。 - 非类型变量的类型的底层是否相同,比如
map[T]int
跟map[T]string
不等价,但[]MyInt
跟[]int
等价。
比如,T1
和T2
是类型参数,[]map[int]bool
可以跟以下类型同化:
[]map[int]bool
T1
(T1
匹配[]map[int]bool
)[]T1
(T1
匹配map[int]bool
)[]map[T1]T2
(T1
匹配int
,T2
匹配bool
)
但是[]map[int]bool
跟下面的类型不等价:
int
struct{}
[]struct{}
[]map[T1]string
参数推导(Function argument type inference)
函数参数推导分两个阶段。
第一段跳过所有实参中无类型常量匹配一遍。如果还有类型参数没有确定,则将开始第二阶段。此时需要给所有无类型常量设置为对应的默认类型,然后再匹配一遍。同一个类型参数可能被匹配多次,如果多次匹配的类型结果不一致就会报编译错误。
回到我们前面说的例子:
func Print[T any](s []T) { ...}
可以简化为Print([]int{1,2,3})
。因为没有指明T
的类型,所以编译器会执行类型推导。
编译器比较实参类型[]int
和形参类型[]T
。根据同化的定义,T
的类型只能是int
,从而得出T
的实际类型。
所以最终的函数调用为Print[int]([]int{1,2,3})
。
再分析一个更复杂的示例:
// Map 对入参 s 切片中的每个元素执行函数 f,将结果保存到新的切片并返回。
// 类型参数 F 和 T 需要在调用的时候指定。
func Map[F, T any](s []F, f func(F) T) []T { ... }
:= Map([]int{1, 2, 3}, strconv.Itoa) strs
推导过程如下:
- 比较
[]int
和[]F
,推断F
的类型为int
- 比较
strconv.Itoa(int) string
和func(F) T
,推断F
为int
且T
为string
(F
被匹配两次,但都是int
) - 最终推断调用为
Map[int,string]([]int{1,2,3}, strconv.Itoa)
以上的入参都有明确的类型,下面讨论入参中有无类型常量的情况。
// NewPair 返回 Pair 对象指针,包含两个相同类型的值。
func NewPair[F any](f1, f2 F) *Pair[F] { ... }
对于NewPair(1,2)
:
第一阶段会跳过所有无类型常量,所以没有推导出T
的类型。第二阶段给1
和2
设定默认类型为int
。所以类型参数F
对应int
,该函数调用被推断为NewPair[int](1,2)
。
对于NewPair(1,int64(2))
:
第一阶段推导忽略无类型常量1
。因为第二个参数的类型为int64
,所以推断F
的参数为int64
。所以最终的函数调用为NewPair[int64](1,2)
。
对于NewPair(1,2.5)
:
第一阶段推导忽略无类型常量。第二阶段先将1
和2.5
设定默认类型为int
和float64
。然后从左往右匹配。对于参数1
确认F
为int
,对于参数2.5
,确定F
为float64
。两次结果不相同,所以报错。
类型推导完成后,编译器依然会执行约束校验和参数类型检查。
约束推导(Constraint type inference)
我们前面说过类型参数约束也可以使用类型参数。对于这种结构化的约束,我们可以通过其他类型参数或者约束来推断其实际的约束。
推导算法也是比较啰嗦,这里先说几个例子。
元素类型约束示例
假设我们有如下函数:
// Double 返回新切片,每个元素都是 s 对应元素的两倍。
func Double[E constraints.Number](s []E) []E {
:= make([]E, len(s))
r for i, v := range s {
[i] = v + v
r}
return r
}
在上述定义下,如果像下面这样调用函数:
type MySlice []int
var v1 = Double(MySlice{1})
推导出来到的v1
的类型其实是[]int
,而不是我们想要的MySlice
。因为编译器在比较MySlice
和[]E
的时候把MySlice
换成了底层类型[]int
,所以推导出E
为int
。
为了能让Double
正常返回MySlice
类型,我们将函数改写成:
// SC 限定类型必须为元素类型为 E 的切片。
type SC[E any] interface {
[]E
}
func DoubleDefined[S SC[E], E constraints.Number](s S) S {
:= make(S, len(s))
r for i, v := range s {
[i] = v + v
r}
}
调用代码需要改成这样:
var v2 = DoubleDefined[MySlice, int](MySlice{1})
我们也可以让编译器自动推导类型:
var v3 = DoubleDefined(MySlice{1})
首先,编译器执行函数参数类型推导。此时需要对比MySlice
和S
,但S
使用结构约束,所以需要推导其实际的约束类型。
为此,编译器构建一个映射表:
{S -> MySlice}
然后,编译器展开S
约束,把SC[E]
展开成[]E
。因为我们之前记录了S
跟MySlice
的映射关系,所以可以对比[]E
和MySlice
。又因为MySlice
的底层类型为[]int
,所以推导出E
的类型为int
:
{S -> MySlice, E -> int}
然后,我们把约束中的E
都换成int
,看还有没有不确定的类型参数。没有了,所以推导结束。所以原来的调用被推导为:
var v3 = DoubleDefined[MySlice,int](MySlice{1})
返回的结果依然是MySlice
。
指针方法约束示例
假设我们希望把一组字符串转换成一组其他类型的数据:
// Setter 限制类型需要实现 Set 方法,通过 string 设置自身的值。
type Setter interface {
(string)
Set}
// FromStrings 接受字符串切片,返回类型为 T 的切片。
// 返回切片中每个元素的值通过调用其 Set 方法设置。
func FromStrings[T Setter](s []string) []T {
:= make([]T, len(s))
result for i, v := range s {
[i].Set(v)
result}
return result
}
我们看一下调用代码(有问题,不能编译):
// Settable 是可以从字符串设置自身取值的整数类型。
type Settable int
// Set 将字符串解析成整数并赋给 *p。
func (p *Settable) Set(s string) {
, _ := strconv.Atoi(s) // 实际代码不能忽略报错
i*p = Settable(i)
}
// 调用函数,无法编译
:= FromStrings[Settable]([]string{"1", "2"}) nums
上面的代码不能正常编译。因为我们指定T
的类型为Settable
,但Settable
类型并没有实现Set(string)
方法。实现该方法的是类型*Settable
。
于是我们将调用代码改成:
// 调用函数,正常编译,但运行报错
:= FromStrings[*Settable]([]string{"1", "2"}) nums
这次可以正常编译,但运行代码又会报错。这是因为在FromStrings
中,result[i]
的类型为*Settable
,值为nil
,无法执行*p = *Settable(i)
赋值。
所以说,上面定义的FromStrings
,我们既不能将T
定为Settable
,这会导致编译错误,又不能定为*Settable
,这会导致运行时错误。
为了实现FromStrings
,我们需要同时指定Settable
和*Settable
类型,这就需要结构化约束:
// Setter2 限制类型必须是 B 的指针而且要实现 Set 方法。
type Setter2[B any] interface {
(string)
Set*B
}
// FromStrings2 接受字符串切片,返回 T 切片。
//
// 这里定义了两个类型参数,所以才能在返回 T 切片的同时
// 调用 *T 也就是 PT 的方法。
// Setter2 约束可以确保 PT 是 T 的指针。
func FromStrings2[T any, PT Setter2[T]](s []string) []T {
:= make([]T, len(s))
result for i, v := range s {
// &result[i] 类型是 *T,也就是 Setter2 的类型。
// 所以可以将其强转为 PT。
:= PT(&result[i])
p // PT 实现了 Set 方法
.Set(v)
p}
return result
}
调用代码如下:
:= FromStrings2[Settable, *Settable]([]string{"1", "2"}) nums
重复写两遍Settable
感觉有点二,可以简化为:
// 因为函数入参中没有使用 T,所以无法进一步简化
:= FromStrings2[Settable]([]string{"1", "2"}) nums
整个编译器的推导过程是这样的。首先,根据已知类型构造映射表:
{T -> Settable}
然后用Settable
替换T
,展开所有结构化参数。所以PT
的类型Setter2[T]
被展开成*T
,加入到映射表:
{T -> Settable, PT -> *T}
然后把所有T
替换成Settable
,最终得到:
{T -> Settable, PT -> *Settable}
到此推导结束,实际的函数调用为FromStrings2[Settable,*Settable]([]string{"1","2"})
。
推导后约束校验
// Unsettable 也是 int,但没有实现 Set 方法。
type Unsettable int
// 错误调用
:= FromString2[Unsettable]([]string{"1", "2"}) nums
在这个例子中,编译器可以推导出PT
的类型为*Unsettable
。推导结束之后,编译器会继续检查Setter2
约束。但*Unsettable
没有实现Set
方法,所以会报编译错误。
总结
以上就是Go泛型设计的主要内容,要点如下:
- 函数可以定义类型参数:
func F[T any](p T) { ... }
。 - 可以在参数声明和函数体中使用类型参数指定类型。
- 类型定义也可以指定类型参数:
type M[T any] []T
。 - 类型参数必须指定类型约束:
func F[T Constraint](p T) { ... }
。 - 使用泛型函数或者类型需要指定类型参数。
- 通过泛型推导可以减少指定类型参数,简化泛型的使用。
因为内容太多,所以本文先到此结束。其他相关内容会在后续文章中介绍。