【译】 Go 泛型编程介绍

2022-03-24 / Go Generic Programming

原文: https://go.dev/blog/intro-generics

作者: Robert Griesemer,Ian Lance Taylor

简介

本文基于我们在 GopherCon 2021 上的讲话(视频地址:https://www.youtube.com/watch?v=Pa_e9EeCdy8 , B站: https://www.bilibili.com/video/BV1V34y1q7wP):

Go 1.18版本增加了对泛型的支持。泛型是我们自 Go 第一个开源版本以来做出的最大改变。在本文中,我们将介绍新的语言特性。这将不会试图涵盖所有的细节,但我们会点出所有重要的点。更详细的内容,以及许多例子,请参见提案文件。关于语言变化的详细描述,请看更新的语言规范。(请注意,实际上 Go 1.18 的实现对提案文件所允许的内容施加了一些限制,该规范应该是描述准确的。未来的版本可能会取消这部分限制)。

泛型是一种编写独立于正在使用的特定类型的代码的方式。那么,现在可以编写用于任何类型的函数或类型。

泛型为语言增加了三个新的重要内容:

  1. 函数和类型的类型参数。
  2. 将接口类型定义为类型的集合,包括没有方法的类型。
  3. 类型推导,允许在许多情况下调用函数时省略类型参数。

类型参数

目前,函数和类型已支持类型参数。一个参数类型列表看起来就像一个普通的参数列表,只是它用方括号代替了小括号。

为了方便说明这一点,让我们从一个基本的非泛型的浮点值 Min 函数开始吧。

func Min(x, y float64) float64 {
    if x < y {
        return x
    }
    return y
}

我们可以通过添加一个类型参数列表来使这个函数泛型化 —— 使其适用于不同的类型。在这个例子中,我们增加了一个由一个类型参数 T 构成的参数列表,并用 T 替换 float64 的使用。

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 类型表示的浮点值集合。

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

在泛型函数 GMin 中,类型约束是从约束包中导入的。Ordered 约束描述了所有具有可排序值的类型的集合。换句话说,也就是用 < 操作符(或 <= , > , 等)进行比较。该约束确保只有具有可排序值的类型才能被传递给 GMin 。这也意味着在 GMin 函数体中,该类型参数的值可以被用于比较操作符 < 的运算。

在 Go 中,类型约束必须是接口。也就是说,一个接口类型可以作为一个值类型,也可以作为一个元类型。接口定义了方法,所以显然我们可以描述方法存在的类型约束。但是 constraints.Ordered 也是一个接口类型,而且 < 操作符不是一个方法。

为了实现这个,我们以一种新的方式来看待接口。

直到最近,Go 规范描述:一个接口定义了一个方法集,大概就是接口中列举的方法集。任何实现了所有这些方法的类型都实现了该接口。

img

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

img

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

不过对于我们的目的来说,类型集视角比方法集视角有一个优势:我们可以明确地将类型添加到集合中,从而以新的方式控制类型集。

为了发挥作用,我们对接口类型的语法进行了扩展。例如,interface{ int|string|bool } 定义了包含 intstringbool 的类型集。

img

另一种说法是,这个接口只被 intstringbool 所满足。

我们来看一下 contraints.Ordered 的实际定义:

type Ordered interface {
    Integer|Float|~string
}

这个声明表示,Ordered 接口是所有整数、浮点数和字符串类型的集合。| 表示的是类型的联合(或者是类型的集合)。 IntegerFloat 是接口类型,在 constraints 中也有类似的定义。注意,Ordered 接口没有定义任何方法。

对于类型约束,我们通常不关心一个特定的类型,比如 string ;我们对所有的字符串类型感兴趣。这就是 ~ 标记的作用。表达式 ~string 意味着所有基础类型为 string 的类型的集合 —— 这包括 string 类型本身,以及所有用定义声明的类型,如 type MyString string

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

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

用作约束条件的接口可以被赋予名称(比如 Ordered ),或者它们可以是类型参数列表中的字面接口。比如说:

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

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

因为这是一种常见的情况,对于处于约束位置的 interface{} ,可以省略。可以简单如下编写:

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

因为空接口在类型参数列表中很常见,在普通 Go 代码中也是如此,Go 1.18 引入了一个新的预先声明的标识符 any 作为空接口类型的别名。这样一来,我们就得到了这个惯用代码:

[S ~[]E, E any]

接口用于类型集是一个强大的新机制,也是使类型约束在 Go 中发挥作用的关键。目前,使用新语法形式的接口只能作为约束使用。但不难想象,具有明确的类型约束的接口将会非常有用。

类型推导

最后一个新的主要语言特性是类型推导。在某些方面,这是最复杂的变化,但它很重要,因为它让人们可以通过一种自然的风格来编写调用泛型函数的代码。

函数参数类型推导

有了类型参数,就需要传递类型参数,这就使代码变得冗长。回到我们的泛型函数 GMin

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

类型参数 T 用于指定普通泛型参数 xy 的类型。正如我们前面所看到的,可以用一个显式类型参数来调用它

var a, b, m float64

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

在许多情况下,编译器可以从普通参数中推断出 T 的类型参数。这使得代码更简短。

var a, b, m float64

m = GMin(a, b) // 没有 类型参数

其工作原理是将参数 ab 的类型与参数 xy 的类型相匹配。

这种从函数的参数类型中推断出参数类型的推导,被称为函数参数类型推导

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

约束类型推导

Go 还支持另一种类型推导,即约束类型推理。为了描述这个,让我们从这个缩放整数切片的例子开始:

// Scale 返回s中每个元素都乘以c的副本。
// 这种实现方式有一个问题,后面将会看到。
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 将一个 Point 翻倍,并且打印。
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 类型的值调用 Scale 时,其底层类型是 []int32 ,我们得到的是 []int32 类型的值,而不是 Point 类型。这是由泛型编程的写法决定的,但这不是我们想要的。

为了解决这个问题,我们必须改变 Scale 函数,使用一个类型参数来表示分片类型。

// Scale 返回s中每个元素都乘以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 函数有两个类型参数, SE 。在调用 Scale 时没有传递任何类型参数,上面描述的函数参数类型推理让编译器推断出 S 的类型参数是 Point 。但是该函数也有一个类型参数 E 。编译器推断出 E 的类型参数是切片的元素类型的过程被称为约束类型推理

约束类型推理从类型参数约束中推断出类型参数。当一个类型参数有一个定义在另一个类型参数上的约束时,它就会被使用。当这些类型参数中的一个的类型参数是已知的,该约束被用来推断另一个的类型参数。

通常适用的情况是,当一个约束对某些类型使用 ~ type 的形式时,该类型是用其他类型参数写的。我们在 Scale 这个例子中看到了这一点。S~[]E ,它是 ~ 后面有一个用另一个类型参数写的 []E 类型。如果我们知道 S 的类型参数,我们可以推断出 E 的类型参数。S 是一个切片类型,E 是该切片的元素类型。

这只是对约束类型推导的一个介绍。完整的细节请参见提案文档文件或语言规范

类型推导实践

类型推理的详细过程很复杂,但使用并不复杂:类型推理要么成功要么失败。如果它成功了,类型参数可以被省略,调用泛型函数看起来与调用普通函数没有什么不同。如果类型推理失败,编译器则会给出一个错误信息,在这种情况下,我们直接提供必要的类型参数就可以。

在向语言添加类型推理时,我们在试图平衡推理能力和复杂性。我们希望确保当编译器推断出类型时,这些类型永远不会令人困惑。我们试图小心翼翼地站在未能推断出类型的一边,而不是站在推断出错误类型的一边。我们可能没有完全做到这一点,可能会在后续版本中继续完善它。其效果是,更多的代码可以在没有显式类型参数的情况下编写。当然,今天不需要类型参数的代码,明天也不会需要。

总结

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

以外,我们也也很高兴能提供泛型,并希望能使 Go 程序员的工作更高效。