介绍#
这篇 blog 基于我们在 GopherCon 2021 上的 talk 发表:
GopherCon 2021 Robert Griesemer & Ian Lance Taylor - Generics!_哔哩哔哩_bilibili
Go 1.18 release 添加了对泛型的支持。泛型是我们从 Go 开源以来做的最大的一次变更。在这篇文章中,我们将介绍这个新语言特性。我们不会尝试涵盖所有细节,但是我们会囊括所有重要的点。要了解更多细节、更详细的描述、例子,请参见提议文档。要了解对该语言变更的更精确描述,请参见更新后的语言规范。(注意,1.18 的实现中有一些在提议文档中允许但实际没实现的限制;规范应该是准确的。未来的 release 可能会解除这些限制。)
泛型是一种独立于所使用的特定类型的编写代码的方法。函数和类型现在可以为一组类型编写。
泛型向 Go 添加了 3 个东西:
- 函数和类型的类型参数
- 定义接口类型为类型集合,包括没有类型的方法。
- 类型推理,它允许在调用函数时在许多情况下省略类型参数。
类型参数#
函数和类型现在可以有类型参数。类型参数列表除了用方括号[]而非圆括号()括起来外,看起来像普通的参数列表一样。
为了演示如何工作,让我们从一个基本的用于浮点值的非泛型 Min函数开始:
func Min(x, y float64) float64 {
if x < y {
return x
}
return y
}
我们可以通过添加一个类型参数列表使该函数泛型化,从而适用于不同的类型。在本例中,我们添加了一个带有单个类型参数 T 的类型参数列表,并将 float64 的用法替换为 T 。
import "golang.org/x/exp/constraints"
func GMin[T constraints.Ordered](x, y T) T {
if x < y {
return x
}
return y
}
现在可以带类型参数调用此函数,方法如下
x := GMin[int](2, 3)
将类型参数提供给 GMin(在本例中为 int)称为实例化。实例化分两步进行。第一步,编译器将所有类型参数替换为泛型函数或类型中各自的类型参数。第二步,编译器验证每个类型参数是否满足各自的约束。我们将很快了解这意味着什么,但如果第二步失败,实例化将失败,那么程序也是非法的。
在成功实例化之后,我们有一个像其他函数那样调用的非泛型函数。例如下面的代码:
fmin := GMin[float64]
m := fmin(2.71, 3.14)
实例化 GMin [float64] 产生了最初那个有效的浮点 Min 函数,我们可以在函数调用中使用它。
类型参数也可以和类型一起用。
type Tree[T interface{}] struct {
left, right *Tree[T]
value T
}
func (t *Tree[T]) Lookup(x T) *Tree[T] { ... }
var stringTree Tree[string]
这里泛型类型 Tree 存储类型参数 T 的值,泛型可以有方法,例如例子中的 Lookup。为了使用泛型类型,它必须被实例化;Tree [string] 是用类型参数 string 实例化 Tree 的例子。
类型集合#
让我们深入研究一下可以用于实例化类型参数的类型参数。
普通函数的每个值参数都有一个类型;该类型定义了一组值。例如,如果我们有一个 float64 类型,如上面的非泛型函数 Min,那么允许的参数值集是可以由 float64 型表示的浮点值集。
同样的,类型参数列表的每个类型参数都有一个类型。因为类型参数本身也是一个类型,类型参数的类型定义了类型集合。meta-type 称为类型约束。
在泛型 GMin 中,类型约束从约束包中导入。Ordered 约束描述了具有可排序值(可与 < 运算符(或 <= 、> 等)进行比较的)的所有类型的集合。约束确保只有带有可排序值的类型才可以传递给 GMin。这也意味着在 GMin 函数体中,可以使用该类型参数的值与 < 运算符进行比较。
在 Go 中,类型约束必须是接口。也就是接口类型可以作为值类型用,也可以作为一个 meta-type。接口定义方法,因此很明显,我们可以表示需要某些方法存在的类型约束。但是 constraints.Ordered 也是一种接口类型,而 < 运算符不是一种方法。
为了使这生效,我们已一种新方式来看接口。
直到最近,Go 规范称接口定义了一个方法集,它大致是接口中枚举的一组方法。实现所有这些方法的任何类型都实现该接口。

但另一种看待这一点的方法是,接口定义了一组类型,即实现这些方法的类型。从这个角度来看,作为接口类型集合中元素的任何类型都实现了接口。

这两个视角的结果是相同的:对于每一组方法,我们可以想象实现了这些方法的相应类型集,这就是接口定义的类型集。
从目的考虑,类型集视图比方法集视图有一个优势:我们可以显式地将类型添加到集合中,从而以新的方式控制类型集。
我们已经扩展了接口类型的语法以实现此功能。例如,interface{int | string | bool}定义了包含类型 int、string 和 bool 的类型集。

另一种说法是,这个接口只满足 int、string 或 bool。
现在让我们看看 constraints.Ordered 的具体定义:
type Ordered interface {
Integer|Float|~string
}
此声明表示 Ordered 接口是所有整数、浮点和字符串类型的集合。垂直条|表示类型的并(在本例中为类型集)。 Integer 和 Float 是约束包中类似定义的接口类型。请注意,Ordered 接口没有定义任何方法。
对于类型约束,我们通常不关心特定类型,例如字符串;我们对所有字符串类型都感兴趣。这就是 ~ token 的用途。表达式 ~string 表示基础类型为 string 的所有类型的集合。这包括字符串类型本身以及用像type MyString string这张定义声明的所有类型。
当然,我们仍然想在接口中指定方法,我们想向后兼容。在 Go 1.18 中,接口可以像以前一样包含方法和嵌入接口,但也可以嵌入非接口类型、并和底层类型集。
当用作类型约束时,类型集由接口定义,该接口精确地指定了允许作为相应类型形参的类型实参的那些类型。
在泛型函数体内,如果操作数的类型是具有约束 C 的类型参数 P,仅当 C 类型集中所有类型都允许该操作,才允许 P 进行该操作(这里目前有一些实现限制,但一般的代码不太可能遇到这些限制)。
用作约束的接口可以有名字(如 Ordered),也可以是内联在类型参数列表中的 literal 接口。例如:
[S interface{~[]E}, E interface{}]
这里的 S 必须是切片类型,不过元素类型不做限定。
因为这是一种常见的情况,对于处于约束位置的接口,可以省略封闭 interface {},我们可以简单地写:
[S ~[]E, E interface{}]
因为空接口在类型参数列表和普通 Go 代码中很常见,Go 1.18 引入新预定义标识符 any 走位空接口的别名。这样,我们就得到了这个惯用的代码:
[S ~[]E, E any]
作为类型集的接口是一种强大的新机制,是使类型约束在 Go 中工作的关键。目前,使用新语法形式的接口只能用作约束。但不难想象显式类型约束接口在一般情况下有多有用。
类型推断#
最后一个新的重要语言特性是类型推断。在某些方面,这是对语言最复杂的更改,但它很重要,因为它允许人们使用自然风格(与往常一样)编写调用泛型函数的代码。
函数参数类型推断#
对于类型 parameter,需要传递类型 argument,这可能会导致冗长的代码。回到我们的通用 GMin 函数:
func GMin[T constraints.Ordered](x, y T) T { ... }
类型参数 T 用于指定普通非类型参数 x 和 y 的类型。如我们先前所见,可以显式带着类型参数调用:
var a, b, m float64 m = GMin[float64](a, b) // explicit type argument
在许多情况下,编译器可以从普通参数推断 T 的类型参数。这使得代码更短的同时还能保持清晰。
var a, b, m float64 m = GMin(a, b) // no type argument
这是通过将参数 a 和 b 的类型与参数 x 和 y 的类型匹配来实现的。
这种从函数的参数类型推断类型参数的推断称为函数参数类型推断。
函数参数类型推断仅适用于函数参数中使用的类型参数,而不适用于仅用于函数结果或函数体中的类型参数。例如,它不适用于像MakeT[T any]() T这样仅使用 T 作为结果的函数。
约束类型推断#
语言还支持另外一种类型推断,约束类型推断。为了描述这个,让我们从扩展整数切片的例子开始:
// Scale returns a copy of s with each element multiplied by c.
// This implementation has a problem, as we will see.
func Scale[E constraints.Integer](s []E, c E) []E {
r := make([]E, len(s))
for i, v := range s {
r[i] = v * c
}
return r
}
这是一个对任何整数类型的切片有效的泛型函数。
现在假设我们有一个多维 Point 类型,其中每个 Point 只是给出该点坐标的整数列表。当然,这种类型会有一些方法。
type Point []int32
func (p Point) String() string {
// Details not important.
}
有时我们想扩展 Point。因为 Point 只是一个整数切片,我们可以用我们先前写的 Scale 函数:
// ScaleAndPrint doubles a Point and prints it.
func ScaleAndPrint(p Point) {
r := Scale(p, 2)
fmt.Println(r.String()) // DOES NOT COMPILE
}
不幸的是。这不可编译,会失败并报r.String undefined (type []int32 has no field or method String)这样的错误。
问题在于 Scale 函数返回一个 [] E 类型的值,其中 E 是参数切片的元素类型。当我们使用 Point 类型的值(其基本类型为 [] int32 )调用 Scale 时,我们返回的值类型为 [] int32,而不是 Point 类型。这源于通用代码的编写方式,但这不是我们想要的。
为了修复这个,我们不得不改变 Scale 函数以使用切片类型的类型参数。
// Scale returns a copy of s with each element multiplied by c.
func Scale[S ~[]E, E constraints.Integer](s S, c E) S {
r := make(S, len(s))
for i, v := range s {
r[i] = v * c
}
return r
}
我们引入了一个新的类型参数 S,它是切片参数的类型。我们对其进行了约束,使得基础类型是 S 而不是 []E,结果类型现在是 S。由于 E 被约束为整数,所以效果与之前相同:第一个参数必须是某个整数类型的一个切片。函数体的唯一变化是,现在我们在调用 make 时传递 S,而不是 []E。
如果我们用一个普通切片调用新函数,它会像之前一样起作用,但是如果我们用 Point 类型调用它,现在我们会返回 Point 类型的值。这就是我们想要的。使用此版本的 Scale,之前的 ScaleAndPrint 函数将按我们的预期编译和运行。
但要问:为什么在不传递显式类型参数的情况下编写对 Scale 的调用是可以的?也就是说,为什么我们可以在没有类型参数的情况下调用Scale(p, 2),而不必是 Scale[Point,int32](p,1)?我们的新 Scale 函数有两个类型参数,S 和 E。在不传递任何类型参数的 Scale 调用中,如上所述,函数参数类型推断让编译器推断 S 的类型参数是 Point。但是这个函数还有一个类型参数 E,它是乘法因子 c 的类型。相应的函数参数是 2,因为 2 是一个非类型化常量,函数参数类型推断无法推断 E 的正确类型(最多可以推断出 2 的默认类型,该类型为 int,但并不正确)。相反,编译器推断 E 的类型参数是切片的元素类型的过程称为约束类型推断。
约束类型推断从类型参数约束推导类型参数。当一个类型参数具有根据另一类型参数定义的约束时,将使用约束类型推断。当其中一个类型参数的类型已知时,该约束可用于推断另一个类型参数的类型。
当一个约束是某个类型type的~type形式,通常会使用约束类型推断。其中类型type是使用其他类型参数定义的。我们在 Scale 示例中看到了这一点。S 是 ~[]E,~后面是根据另一个类型参数编写的类型 []E。如果我们知道 S 的类型,我们可以就推断 E 的类型。S 是一个切片类型,E 是该切片的元素类型。
这只是对约束类型推断的简单介绍。要了解详情信息,请参见提议文档和语言规范。
实践中的类型推断#
类型推断如何工作的确切细节很复杂,但使用它并不复杂:类型推断要么成功,要么失败。如果成功,可以省略类型参数,并且调用泛型函数看起来与调用普通函数没有区别。如果类型推断失败,编译器将给出错误消息,在这些情况下,我们可以提供必要的类型参数。
在语言中添加类型推断时,我们试图在推理能力和复杂性之间取得平衡。我们希望确保当编译器推断类型时,这些类型不会令人惊讶。我们试图关注推断类型失败的错误而不是推断错误的类型的错误。我们可能还没有让其完全正确,我们会在未来的版本中继续完善它。其结果是,可以编写更多不需要显式类型参数的程序。现在不需要类型参数的程序往后也不需要它们。
总结#
泛型是 1.18 中一个新的语言特性。这些新的语言变化需要大量的新代码,而这些代码在生产环境中没有进行过大量测试。这只能靠更多人编写和使用泛型代码进行。我们相信这一功能得到了很好的实现,质量也很高。然而,与 Go 的大多数方面不同,我们无法用现实世界的经验来支持这种信念。因此,尽管我们鼓励在有意义的地方使用泛型,但在生产中部署泛型代码时,请谨慎使用。
撇开这一点不谈,我们很高兴有泛型可用,我们希望它们能让 Go 程序员更有效率。