Go 博客

泛型简介

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

引言

本文基于我们在 GopherCon 2021 上的演讲

Go 1.18 版本增加了对泛型的支持。泛型是我们自首次开源以来对 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 规范都说接口定义了一个方法集,它大致是接口中列出的方法集。任何实现所有这些方法的类型都实现了该接口。

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

这两种观点 leads to the same outcome: 对于每组方法,我们都可以想象出实现这些方法的相应类型集,这就是接口定义的类型集。

然而,对我们来说,类型集观点比方法集观点有优势:我们可以显式地向集合中添加类型,从而以新的方式控制类型集。

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

换句话说,这个接口只能由 intstringbool 满足。

现在让我们看看 constraints.Ordered 的实际定义

type Ordered interface {
    Integer|Float|~string
}

此声明表示 Ordered 接口是所有整数、浮点数和字符串类型的集合。竖线表示类型的并集(在此情况下是类型集的并集)。IntegerFloatconstraints 包中类似定义的接口类型。请注意,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 类型的值调用 Scale 时,其底层类型为 []int32,我们得到一个 []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* 形式的约束,其中 type 是用其他类型参数书写的。我们在 Scale 示例中看到了这一点。S~[]E,即 ~ 后跟一个用其他类型参数书写的类型 []E。如果我们知道 S 的类型参数,我们就可以推断出 E 的类型参数。S 是一个切片类型,而 E 是该切片的元素类型。

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

类型推断的实际应用

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

在向语言添加类型推断时,我们试图在推断能力和复杂性之间取得平衡。我们希望确保编译器推断类型时,这些类型绝不会令人惊讶。我们试图谨慎地宁愿失败而不能推断出类型,而不是错误地推断出类型。我们可能还没有完全做到这一点,并且可能会在未来的版本中继续改进它。其效果是,更多的程序可以编写而无需显式类型参数。今天不需要类型参数的程序明天也一样不需要。

结论

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

撇开这些谨慎,我们很高兴能够使用泛型,并希望它们能提高 Go 程序员的生产力。

下一篇文章: Go 如何抵御供应链攻击
上一篇文章: Go 1.18 已发布!
博客索引