泛型的介绍

突然想起来这部分其实一直没系统的看下。
今天的每日一题又很简单,整理下。原文

介绍

Go 1.18增加了泛型的支持。泛型是Go开源发布依赖的最大变化。这篇文章会介绍新的语言特性。不会覆盖到所有细节,但会触及到所有重要的点。对于更细节,更长描述,包含很多示例的文章,参见推荐文档。对于语言变化更精确的描述,见更新后的语言规范。(注意,实际的1.18实现对提案文件所允许的内容施加了一些限制;规范应准确无误,未来的版本可能会取消部分限制。(现在都1.21.1了,直接看新的就行。)

泛型是一种编写独立于所使用的特定类型的代码的方法。现在可以写函数和类型来使用一组类型中的任何一个。

泛型在语言中增加了三个新的大事:

  • 函数和类型的类型参数
  • 定义接口类型作为类型集,包括没有方法的类型
  • 类型推断,在很多情况下允许调用函数时省略类型参数

类型参数

函数和类型现在可以有类型参数。一个类型参数列表就像普通的参数列表,只不过使用方括号包含[].

为了更清楚,让我们从一个无泛型的计算浮点数值最小值的Min函数开始:

1
2
3
4
5
6
func Min(x, y float64) float64 {
if x < y {
return x
}
return y
}

我们可以通过添加一个类型参数列表使这个函数通用–适用于不同的类型。这里,我们添加具有单个类型参数T的类型参数列表,并将float64的使用替换为T。

1
2
3
4
5
6
7
8
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)称为实例化。实例化分两步进行。首先,编译器会在整个泛型函数或类型中将所有类型参数替换为各自的类型参数。然后,编译器会验证每个类型参数是否满足相应的约束条件。我们很快就会知道这意味着什么,但如果第二步失败,实例化就会失败,程序就会失效。

成功实例化后,我们就会有一个非泛型函数,可以像其他函数一样调用。如下:

1
2
fmin := GMin[float64]
m := fmin(2.42, 3.17)

实例化GMin[float64]生成的实际上是我们原始的浮点数Min函数,我们可以在函数调用中使用。

类型参数也可以与type一起使用。

1
2
3
4
5
6
7
8
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类型表示的浮点数集。

同样,类型参数列表中的每个类型参数都有一个类型。因为类型参数本身就是一种类型,所以类型参数的类型定义了类型集。这种元类型称为类型约束

在泛型函数GMin中,类型约束使用constraints pkg(约束包)中导入的。Ordered约束描述了所有类型的集合,这些类型的值可以有序排列,换句话说,可以用<, <=, >等操作符进行比较。该约束确保只有满足可排列性质的值才能传递给GMin。这也意味着,在GMin函数内,该类型参数的值可以用<操作符进行比较。

在Go中,类型约束必须是接口。也就是说,接口类型可以用作值类型,也可以用作元类型。接口定义了方法,因此我们显然可以表达约束类型,要求某些方法必须存在。但是constraints.Ordered也是一种接口类型,而且<操作符不是方法。

为了理解,我们以一种新的方式来看待接口。

直到最近,Go规范还说接口定义了一个方法集,大致就是接口中枚举的方法集。任何实现了这些方法的类型都实现了该接口。

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

这两种观点的结果是一样的:对于每一组方法,我们都可以想像出实现这些方法的相应类型集,这就是接口定义的类型集。

不过,就我们的目的来说,类型集视角比方法集视角更有优势:我们可以显式的将类型添加到集合中,从而以新的方式控制类型集。

我们扩展了接口类型的语法来实现这一点。例如,interface{ int | string | bool }定义了包含int, string, bool类型的类型集。

另一种说法是,该接口仅由int, string, bool满足。

现在来看看constraints.Ordered约束的实际定义:

1
2
3
type Ordered interface {
Integer | Float | ~string
}

这个声明表示Ordered接口是所有整数,浮点数和字符串类型的集合。|表示或,也就是联合。

IntegerFloat也是接口类型,在约束包中有类似的定义。请注意,constraints.Ordered没有定义方法。

对于类型约束,我们通常不关心特定的类型,如字符串;我们感兴趣的是所有的字符串类型。这就是~符号的作用。表达式~string表示底层类型为字符串的所有类型。这包括字符串类型本身以及所有用定义声明的类型,例如type MyString string

当然,我们仍然希望在接口中指定方法,而且我们希望向后兼容。在Go 1.18中,接口可以像以前一样包含方法和嵌入接口,但也可以嵌入非接口类型,联合(unions),以及底层类型集。

当用作类型约束时,接口定义的类型集准确的指定了允许作为相应类型参数的类型参数的类型。在泛型函数体中,如果操作数的类型是带有约束C的类型参数P,那么如果C的类型集中的所有类型都允许操作,那么操作就是被允许的(目前这里有一些实现限制,但普通代码不太可能碰到这些限制)。

作为约束使用的接口可以被赋予名称(如Ordered),也可以是在类型参数列表中内联的子面接口。例如:

[S interface{~[]E}, E interface{}]

这里的S必须是一个slice类型,其元素类型可以是任何类型。

由于这种情况很常见,对于处于约束位置的接口,可以省略外层的interface{},可以简写为:

[S ~[]E, E interface{}]

由于空接口在类型参数列表和普通代码中很常见,Go 1.18引入了一个新的预定义标识符any作为空接口类型的别名。这样,上面的代码还可以简写:

[S ~[]E, E any]

接口作为类型集是一种强大的新机制,也是在Go中实现类型约束的关键。目前,使用新语法形式的接口只能用作约束。但不难想像显式类型约束接口在一般情况下会有多大用处。

类型推断

最后的主要语言特性就是类型推断。从某种程度上说,这是对语言最复杂的改动,但非常重要,因为它让人们在编写调用泛型函数的代码时,可以使用一种自然的风格。

函数参数类型推断

有了类型参数,就需要传递类型参数,这可能会导致代码冗长,回到我们的通用GMin函数:

func GMin[T constraints.Ordered](x, y T) { ... }

类型参数T用于指定普通非类型参数x和y的类型:

1
2
3
var a, b, m float64

m = GMin[float64](a, b) // 显式参数

在很多情况下,编译器可以从普通参数推断出T的类型参数。这使得代码更短,同时保持清晰。

1
2
var a, b, m float64
m = GMin(a, b) // 没有类型参数

其工作原理是将参数a,b的类型与参数x,y的类型相匹配。

这种从函数参数类型推断出参数类型的推理,称为函数参数类型推理

函数参数类型推断只适用于在函数参数中使用的类型参数,不适用于仅在函数结果中或仅在函数体中使用的类型参数。例如,不适用MakeT[T any]() T这样只在结果中使用T的函数。

约束类型推断

语言支持另一种类型推断,即约束类型推断。为了描述这一点,让我们从缩放整数slice的示例开始:

1
2
3
4
5
6
7
8
9
// 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
}

这是一个适用于任何整数类型slice的泛型函数。

现在,假设我们有一个多维点类型,其中每个点都是一个给出点坐标的整数列表。这种类型自然会有一些方法。

1
2
3
4
5
type Point []int32

func (p Point) String() string {
// ...
}

有时我们想要缩放一个点。由于Point只是整数切片,因此我们可以使用之前写的Scale函数:

1
2
3
4
5
// ScaleAndPrint doubles a Point and prints it.
func ScaleAndPrint(p Point) {
r := Scale(p, 2)
fmt.Println(r.String()) // 编译不通过
}

这会编译失败,出现类似r.String() undefined (type []int32 has no field or method String)的错误。

问题就是Scale函数返回的是[]E类型的值,而E是参数切片的元素类型。当我们使用Point类型的值(基础类型为[]int32)调用Scale时,返回的是[]int32类型的值,而不是Point类型。这是泛型代码的编写方式所遵循的,但不是我们想要的。

为了解决这个问题,我们必须修改Scale函数以使用切片类型的类型参数。

1
2
3
4
5
6
7
8
// 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, 2)呢?我们新的Scale函数有两个类型参数:S和E。在调用Scale时,如果不传递任何类型参数,上述的函数类型推断会让编译器推断出S的类型参数是Point。但该函数还有一个类型参数E,它是乘法因子C的类型。相应的函数参数是2,由于2是一个未类型化的常量,函数参数类型推断无法为E推断出正确的类型(最多只能推断出2的默认类型是int,而这是不对的)。相反,编译器推断E的类型参数是切片的元素类型的过程称为约束类型推断

约束类型推断从类型参数约束中推导出类型实参。当一个类型参数具有根据另一类型参数定义的约束时使用它。当已知其中一个类型参数的类型参数时,就可以利用该约束条件来推断另一个类型参数的类型参数。

通常的情况是,当一个约束对某个类型使用~type形式时,该类型使用其他类型参数来编写。我们可以在Scale中看到这种情况。S的类型是~[]E,也就是~后根据另一个类型参数编写的类型[]E。如果我们知道S的类型参数,我们就可以推断出E的类型参数。S是切片类型,E是该切片的元素类型。

这只是类型约束推断的介绍。要了解更多参阅提案文档语言规范

实践中的类型推断

类型推断工作原理的具体细节很复杂,但使用起来并不复杂:类型推断要么成功,要么失败。如果类型推断成功,则可以省略类型参数,调用泛型函数看起来与调用普通函数没什么区别。如果类型推断失败,编译器会给出错误信息,在这种情况下,我们只需提供必要的类型参数即可。

在为语言添加类型推论时,我们试图在推论能力和复杂性之间取得平衡。我们希望确保当编译器推断类型时,这些类型永远不会出人意料。我们尽量避免推断失败,而不是推断错误的类型。我们可能没有完全做到这一点,在未来的版本中我们可能会继续改进。这样做的结果是,可以编写更多不需要显式类型参数的程序。今天不需要类型参数的程序,明天也不需要了。

总结

泛型是 1.18 中的一大新语言特性。这些新的语言变化需要大量的新代码,而这些代码还没有在生产环境中进行过大量测试。只有随着越来越多的人编写和使用泛型代码,这种情况才会发生。我们认为该功能实现得很好,而且质量很高。然而,与 Go 的大多数方面不同,我们无法用现实世界的经验来支持这一信念。因此,虽然我们鼓励在合理的情况下使用泛型,但在生产环境中部署泛型代码时,请适当谨慎。

抛开这种警告不谈,我们很高兴能有泛型可用,我们希望它们能让 Go 程序员更有效率。