Go官方的泛型简介

2022年3月29日 173点热度 0人点赞 0条评论

这篇博客内容基于我们在 2021 Gophercon 的演讲:

https://youtu.be/Pa_e9EeCdy8


Go 1.18 增加了对泛型的支持。泛型是 Go 的第一个开源版本以来最大特性变更。在本文中,我们将介绍新的语言特性。在这里,我们不会尝试去讲解所有的细节,但是我们会把所有的重点都讲一下。关于更精准的语言改变的描述,请看 提议文档 。准确语言变化需要参考更新之后的语言规范 。(请注意,实际的 1.18 实现对提案文件允许的内容施加了一些限制;规范应该是准确的。未来的版本可能会取消一些限制。)

泛型是一种独立于使用的特定类型编写代码的方式。现在可以编写函数和类型适用于一组类型集合的任何一种。

泛型为 Go 添加了三个新的重要内容:

  1. 函数和结构体定义支持类型参数。

  2. 将 interface 定义为类型集合,也包括没有提供方法的类型。

  3. 类型推断,允许在调用函数时(在一般场景下可以)省略类型参数。

01

类型参数

现在允许函数和类型具有类型参数。类型参数列表看起来像一个普通的参数列表,除了它使用方括号而不是圆括号。

为了展示泛型的工作原理,让我们从浮点值的基本非泛型 Min 函数开始:

// 这里返回的是两个浮点数的最小值
func Min(x, y float64) float64 {
    if x < y {
        return x
    }
    return y
}

我们可以通过添加类型参数列表来使这个函数适用于不同的类型。在这个例子中,我们添加了一个带有单个类型参数 T 的类型参数列表,并将 float64 的使用替换为 T(constraints.Ordered 是参数列表类型 T 的约束)。

import "golang.org/x/exp/constraints"

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

现在可以通过编写类似的调用来使用带有类型参数的 GMin

// 类型参数 T 被明确指定为 int,
x := GMin[int](2, 3)

向 GMin 提供类型参数,在本例中为 int,称为实例化。实例化分两步进行。首先,编译器在泛型函数或类型中用所有类型参数替换它们各自的类型参数。其次,编译器验证每个类型参数是否满足各自的约束。我们很快就会明白这是什么意思,但如果第二步失败,实例化就会失败,程序就会无效。

成功实例化后,我们有一个可以像任何其他函数一样调用的非泛型函数。例如,在类似的代码中

// 使用 float64 来实例化 GMin
fmin := GMin[float64]
m := fmin(2.71, 3.14)

实例化 GMin[float64] 产生了我们原来的浮点 Min 函数,我们可以在函数调用中使用它。

Type parameters can be used with types also. 类型参数也可以与类型定义一起使用。

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 的示例。

02

类型集合

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

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

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

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

在 Go 中,类型约束必须是接口。即接口类型可以作为值类型,也可以作为元类型。接口定义了方法,因此我们可以表达需要某些方法存在的类型约束。但是 constraints.Ordered 也是接口类型,<操作符不是方法。

为了完成这项工作,我们以一种新的方式看待接口。

直到最近,Go 规范才说接口定义了枚举的方法集。所有实现这些方法的类型都实现了该接口。 图片

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

图片 这两个看法都有相同的结果:对于每组方法,我们可以想象实现这些方法的相应类型集,即接口定义的类型集。

但是,就我们的目的而言,类型集观点比方法集观点具有优势:我们可以显式地将类型添加到集合中,从而以新的方式控制类型集。

我们扩展了接口类型的语法以使其工作。例如,interface{ int|string|bool } 定义了包含 intstring 和 bool 类型的类型集合。 图片 另一种说法是,该接口仅由 intstring 或 bool 满足。

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

type Ordered interface {
    Integer|Float|~string
}

这个声明表明 Ordered 接口是所有整数、浮点和字符串类型的集合。竖线表示类型的联合(或本例中的类型集)。 Integer 和 Float 是在 constraints 包中类似定义的接口类型。请注意,Ordered 接口没有定义任何方法。

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

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

当用作类型约束时,接口定义的类型集准确地指定了参数类型。在泛型函数体内,如果操作数的类型是类型参数 P,约束为 C,则在 C 类型集中的所有类型都允许的情况下允许操作(目前有一些实现限制在这里,但普通代码不太可能遇到它们)。

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

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

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

因为这是一种常见的情况,所以对于处于约束位置的接口,可以省略封闭的 interface{},我们可以简单地写:

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

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

[S ~[]E, E any]

类型集的接口是一种强大的新机制,在 Go 类型约束中起关键作用。目前,使用新语法形式的接口只能用作约束。明确类型约束的接口一般来说是非常有用的。

03

类型推断

最后一个主要的新功能是类型推断。在某些方面,这是对语言最复杂的更改,但它很重要,因为它让人们更加自然地编写调用泛型函数的代码。

函数参数类型推断

使用类型参数需要传递类型参数,这可能会产生冗长的代码。回到我们的泛型的 GMin

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

类型参数 T 用于指定普通非类型参数 x 和 y 的类型。正如我们之前看到的,这可以使用显式类型参数调用

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

这通过将参数 a 和 b 的类型与参数 x 和 y 的类型相匹配来工作。

这种从函数实际参数的参数类型推断类型参数的推断称为函数参数类型推断。

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

约束类型推断

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

// 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.
}

有时我们想 Scale 一个 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.
// 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 函数有两个类型参数,S 和 E。在调用 Scale 时不传递任何类型参数,函数参数类型推断,如上所述,让编译器推断 S 的类型参数是 Point。但是该函数还有一个类型参数 E,它是乘法因子 c 的类型。对应的函数参数是2,因为2是一个无类型常量,所以函数参数类型推断不能推断 E 的正确类型(充其量它可能推断 2 的默认类型是 int 这将是不正确的)。相反,编译器推断 E 的类型参数是切片的元素类型的过程称为约束类型推断。

约束类型推断从类型参数约束中推导出类型参数。当一个类型参数具有根据另一个类型参数定义的约束时使用它。当其中一个类型参数的类型参数已知时,约束用于推断另一个类型参数的类型参数。

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

这只是对约束类型推断的介绍。完整的详细信息请参见提案文件 或语言规范 。

类型推断实践

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

在向 Go 添加类型推断功能时,我们试图在推断能力和复杂性之间取得平衡。我们希望确保当编译器推断类型时,这些类型永远不会令人惊讶。我们试图小心地在未能推断出类型方面犯错,而不是在推断错误类型方面犯错。我们可能还没有完全正确,我们可能会在未来的版本中继续完善它。结果将是可以编写更多程序而无需显式类型参数。今天不需要类型参数的程序明天也不需要它们。

04

结论

泛型是 1.18 中一个很大的新语言特性。这些新语言功能产生了大量没有在生产环境上进行重大测试的代码。泛型功能只会发生在使用和编写代码中。我们相信这个功能实现得很好并且质量很高。然而,与 Go 的大多数方面不同,我们不能用现实世界的经验来支持这种信念。因此,虽然我们鼓励在有意义的地方使用泛型,但在生产环境中部署泛型代码时请谨慎行事。

除了这个谨慎之外,我们很高兴能有泛型可用,我们希望它们能让 Go 程序员更有效率。

原文信息

原文地址:https://golang.google.cn/blog/intro-generics

原文作者:Robert Griesemer and Ian Lance Taylor

本文永久链接:https://github.com/gocn/translator/blob/master/2022/w14_An_Introduction_To_Generics.md

译者:xkk

校对:zxmfke

想要了解更多 Golang 相关的内容,欢迎扫描下方? 关注 公众号,回复关键词 [实战群]  ,就有机会进群和我们进行交流~

图片

80170Go官方的泛型简介

这个人很懒,什么都没留下

文章评论