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 规范还表示接口定义了一个方法集,该方法集大致是接口中枚举的方法集。实现所有这些方法的任何类型都实现了该接口。

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

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

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

我们扩展了接口类型的语法以使其工作。例如,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 未定义(类型 []int32 没有字段或方法 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的形式表示某个类型时,其中该类型使用其他类型参数编写,就会用到这种方法。我们在Scale示例中看到了这一点。S~[]E,即~后跟一个类型[]E,该类型用另一个类型参数编写。如果我们知道S的类型参数,我们就可以推断出E的类型参数。S是一个切片类型,E是该切片的元素类型。

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

实践中的类型推断

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

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

结论

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

除了这种谨慎之外,我们很高兴能够使用泛型,并且希望它们能够提高 Go 程序员的生产力。

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