Go 博客

泛型简介

Robert Griesemer 和 Ian Lance Taylor
2022 年 3 月 22 日

简介

这篇博文基于我们在 GopherCon 2021 上的演讲

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 的类型参数列表,并将 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 的一个示例。

类型集

让我们更深入地看看可用于实例化类型参数的类型参数。

普通函数为每个值参数都有一个类型;该类型定义了一组值。例如,如果我们在上面的非泛型函数 Min 中有一个 float64 类型,则允许的参数值集合是可由 float64 类型表示的浮点值集合。

类似地,类型参数列表为每个类型参数都有一个类型。由于类型参数本身就是一个类型,因此类型参数的类型定义了类型集合。这种元类型称为 类型约束

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

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

为了使其工作,我们以一种新的方式看待接口。

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

但另一种看待这个问题的方式是说接口定义了一组类型,即实现这些方法的类型。从这个角度来看,任何是接口类型集元素的类型都实现了该接口。

这两种观点导致相同的结果:对于每组方法,我们可以想象实现这些方法的相应类型集合,这就是接口定义的类型集合。

然而,就我们的目的而言,类型集视图相对于方法集视图有一个优势:我们可以显式地向集合中添加类型,从而以新的方式控制类型集。

我们扩展了接口类型的语法以使其工作。例如,interface{ int|string|bool } 定义了包含 intstringbool 类型的类型集。

换句话说,只有 intstringbool 才能满足此接口。

现在让我们看看 constraints.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) // explicit type argument

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

var a, b, m float64

m = GMin(a, b) // no type argument

这是通过将参数 ab 的类型与参数 xy 的类型进行匹配来实现的。

这种从函数参数类型推断类型参数的推断方式,称为 函数参数类型推断

函数参数类型推断仅适用于函数参数中使用的类型参数,不适用于仅在函数结果或函数体中使用的类型参数。例如,它不适用于像 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, 2)?我们的新 Scale 函数有两个类型参数,SE。在调用 Scale 时不传递任何类型参数的情况下,上面描述的函数参数类型推断允许编译器推断 S 的类型参数是 Point。但该函数还有一个类型参数 E,它是乘法因子 c 的类型。对应的函数参数是 2,并且由于 2 是一个 无类型 常量,函数参数类型推断无法推断出 E 的正确类型(充其量它可能会推断出 2 的默认类型 int,这是不正确的)。相反,编译器推断 E 的类型参数是切片元素类型的过程称为 约束类型推断

约束类型推断从类型参数约束中推导类型参数。当一个类型参数的约束是根据另一个类型参数定义的时,就会使用它。当其中一个类型参数的类型参数已知时,该约束将用于推断另一个类型参数的类型参数。

应用这种情况的常见情况是,当一个约束使用 ~type 的形式表示某个类型,而该类型是使用其他类型参数编写的。我们在 Scale 示例中看到了这一点。S~[]E,即 ~ 后跟一个以另一个类型参数表示的类型 []E。如果我们知道 S 的类型参数,我们就可以推断出 E 的类型参数。S 是一个切片类型,而 E 是该切片的元素类型。

这只是对约束类型推断的介绍。更多详细信息请参阅提案文档语言规范

实际中的类型推断

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

在为语言添加类型推断时,我们试图在推断能力和复杂性之间取得平衡。我们希望确保编译器推断出的类型永远不会令人意外。我们力求谨慎,宁可推断失败,也不要推断出错误的类型。我们可能还没有做到完全正确,未来版本中可能会继续完善。其效果将是更多程序无需显式类型参数即可编写。今天不需要类型参数的程序,明天也同样不需要。

结论

泛型是 1.18 版本中的一项重要新语言特性。这些新的语言变化需要大量新代码,而这些代码尚未在生产环境中得到充分测试。只有当更多人编写和使用泛型代码时,才会发生这种情况。我们相信此功能实现得很好,质量也很高。然而,与 Go 的大多数方面不同,我们无法用实际世界经验来支持这一信念。因此,虽然我们鼓励在有意义的地方使用泛型,但在生产环境中部署泛型代码时请务必谨慎。

抛开谨慎,我们很高兴泛型已可用,并且希望它们能使 Go 程序员更具生产力。

下一篇文章:Go 如何减轻供应链攻击
上一篇文章:Go 1.18 发布了!
博客索引