:::info 日期:2019 年 07 月 31 日
作者:Ian Lance Taylor
原文链接:https://go.dev/blog/why-generics :::

介绍

这是我上周在 Gophercon 2019 上演讲的博客文章版本。
点击查看【codepen】
这篇文章是关于向 Go 添加泛型意味着什么,以及为什么我认为我们应该这样做。 我还将介绍对 Go 添加泛型的可能设计的更新。

Go 于 2009 年 11 月 10 日发布。不到 24 小时后,我们看到了关于泛型的第一条评论。 (该评论还提到异常,我们在 2010 年初以恐慌和恢复的形式添加到语言中。)

在三年的 Go 调查中,缺乏泛型一直被列为在语言中需要解决的三大问题之一。

为什么使用泛型?

但是添加泛型意味着什么,我们为什么想要它?

套用 Jazayeri 等人的话:泛型编程能够以泛型形式表示函数和数据结构,并排除类型。

这意味着什么?

举个简单的例子,假设我们想要反转切片中的元素。 这不是许多程序需要做的事情,但并不是那么不寻常。

假设它是一个 int 切片。

  1. func ReverseInts(s []int) {
  2. first := 0
  3. last := len(s)
  4. for first < last {
  5. s[first], s[last] = s[last], s[first]
  6. first++
  7. last--
  8. }
  9. }

非常简单,但即使对于这样的简单函数,您也需要编写一些测试用例。 事实上,当我这样做时,我发现了一个错误。 相信很多读者已经看到了。

  1. func ReverseInts(s []int) {
  2. first := 0
  3. last := len(s) - 1
  4. for first < last {
  5. s[first], s[last] = s[last], s[first]
  6. first++
  7. last--
  8. }
  9. }

最后设置变量时需要减1。

现在让我们反转一段字符串。

  1. func ReverseStrings(s []string) {
  2. first := 0
  3. last := len(s) - 1
  4. for first < last {
  5. s[first], s[last] = s[last], s[first]
  6. first++
  7. last--
  8. }
  9. }

如果比较 ReverseInts 和 ReverseStrings,您会发现这两个函数完全相同,只是参数的类型不同。 我认为任何读者都不会对此感到惊讶。 一些刚接触 Go 的人感到惊讶的是,没有办法编写一个适用于任何类型切片的简单 Reverse 函数。 大多数其他语言确实允许您编写这种函数。 在像 Python 或 JavaScript 这样的动态类型语言中,您可以简单地编写函数,而无需费心指定元素类型。 这在 Go 中不起作用,因为 Go 是静态类型的,并且需要您写下切片的确切类型和切片元素的类型。 大多数其他静态类型语言,如 C++、Java、Rust 或 Swift,都支持泛型来解决这类问题。

Go 泛型编程的今天

那么人们如何在 Go 中编写这种代码呢?

在 Go 中,您可以通过使用接口类型编写一个适用于不同切片类型的函数,并在要传入的切片类型上定义一个方法。这就是标准库的 sort.Sort 函数的工作原理。

换句话说,Go 中的接口类型是泛型编程的一种形式。 它们让我们捕获不同类型的共同方面并将它们表达为方法。 然后我们可以编写使用这些接口类型的函数,这些函数将适用于实现这些方法的任何类型。

但是这种方法达不到我们想要的效果。 对于接口,您必须自己编写方法。 为了反转切片而必须使用几个方法定义命名类型是很尴尬的。 而且你写的方法对于每种切片类型都是完全一样的,所以从某种意义上说,我们只是移动并浓缩了重复的代码,我们并没有消除它。 尽管接口是泛型的一种形式,但它们并没有为我们提供我们想要的泛型的一切。

为泛型使用接口的另一种方法是让语言为某些类型定义方法,这可以避免自己编写方法的需要。 这不是该语言今天支持的东西,但是,例如,该语言可以定义每个切片类型都有一个返回元素的 Index 方法。 但是为了在实践中使用该方法,它必须返回一个空的接口类型,然后我们就失去了静态类型的所有好处。 更巧妙的是,没有办法定义一个通用函数,它接受两个具有相同元素类型的不同切片,或者接受一个元素类型的映射并返回一个相同元素类型的切片。 Go 是一种静态类型语言,因为它可以更轻松地编写大型程序; 我们不想为了获得泛型的好处而失去静态类型的好处。

另一种方法是使用反射包编写一个通用的 Reverse 函数,但是编写起来很笨拙而且运行很慢,很少有人这样做。 该方法还需要显式类型断言并且没有静态类型检查。

或者,您可以编写一个代码生成器,它接受一个类型并为该类型的切片生成一个 Reverse 函数。 有几个代码生成器可以做到这一点。 但这为每个需要 Reverse 的包添加了另一个步骤,它使构建复杂化,因为必须编译所有不同的副本,并且修复主源中的错误需要重新生成所有实例,其中一些可能在不同的项目中 完全。

所有这些方法都很笨拙,我认为大多数必须在 Go 中反转切片的人只需为他们需要的特定切片类型编写函数。 然后他们需要为函数编写测试用例,以确保他们不会犯像我最初犯的那样的简单错误。 他们需要定期运行这些测试。

不管我们怎么做,这意味着很多额外的工作只是为了一个看起来完全相同的函数,除了元素类型。 并不是做不到。 显然可以做到,而且 Go 程序员正在这样做。 只是应该有更好的方法。

对于像 Go 这样的静态类型语言,更好的方法是泛型。 我之前写的是泛型编程能够以泛型形式表示函数和数据结构,并将类型分解。 这正是我们想要的。

泛型可以为 Go 带来什么

我们希望在 Go 中使用泛型的第一个也是最重要的事情是能够编写像 Reverse 这样的函数,而无需关心切片的元素类型。 我们想分解出那个元素类型。 然后我们可以编写一次函数,编写一次测试,将它们放入 go-gettable 包中,并在需要时随时调用它们。

更好的是,由于这是一个开源世界,其他人可以编写 Reverse 一次,我们可以使用他们的实现。

在这一点上,我应该说“泛型”可能意味着很多不同的东西。 在本文中,我所说的“泛型”就是我刚刚描述的。 特别是,我指的不是 C++ 语言中的模板,它支持的内容比我在这里写的要多得多。

我详细介绍了 Reverse,但还有许多其他函数可以通用编写,例如:

  • 在切片中查找最小/最大元素
  • 查找切片的平均值/标准偏差
  • 计算联盟/地图的交集
  • 在节点/边图中找到最短路径
  • 将转换函数应用于切片/地图,返回新的切片/地图

这些示例以大多数其他语言提供。实际上,我是通过浏览 C++ 标准模板库来编写此列表的

还有一些特定于 Go 的例子,它对并发的强大支持

  • 从超时的通道读取
  • 将两个通道合并为一个通道
  • 并行调用函数列表,返回结果切片
  • 调用一个函数列表,使用一个Context,返回第一个函数完成的结果,取消和清理额外的goroutines

我已经看到所有这些函数用不同的类型写了很多次。用 Go 编写它们并不难。但是,如果能够重用适用于任何值类型的高效且经过调试的实现,那就太好了。

需要明确的是,这些只是示例。使用泛型可以更轻松、更安全地编写更多通用函数。

此外,正如我之前写的,它不仅仅是函数。它也是数据结构。

Go 语言中内置了两种通用的通用数据结构:切片和映射。切片和映射可以保存任何数据类型的值,并对存储和检索的值进行静态类型检查。值存储为它们本身,而不是接口类型。也就是说,当我有一个 []int 时,切片直接保存 int,而不是转换为接口类型的 int。

切片和映射是最有用的通用数据结构,但它们不是唯一的。以下是一些其他示例。

  • 集合
  • 自平衡树,按排序顺序高效插入和遍历
  • Multimaps,一个键的多个实例
  • 并发散列映射,支持无单锁的并行插入和查找

如果我们可以编写泛型类型,我们就可以定义新的数据结构,像这样,它们具有与切片和映射相同的类型检查优势:编译器可以静态地对它们持有的值的类型进行类型检查,并且这些值可以存储为它们自己,而不是接口类型。

也应该可以采用前面提到的算法并将它们应用于通用数据结构。

这些示例都应该像 Reverse 一样:通用函数和数据结构在一个包中编写一次,并在需要时重用。它们应该像切片和映射一样工作,因为它们不应该存储空接口类型的值,而应该存储特定类型,并且应该在编译时检查这些类型。

这就是 Go 可以从泛型中获得的东西。泛型可以为我们提供强大的构建块,让我们更轻松地共享代码和构建程序。

我希望我已经解释了为什么这值得研究。

收益和成本

但仿制药并非来自大冰糖山,那里每天都有阳光照耀在柠檬水泉上。每一次语言的改变都是有代价的。毫无疑问,在 Go 中添加泛型会使语言变得更加复杂。与语言的任何更改一样,我们需要讨论最大化收益和最小化成本。

在 Go 中,我们的目标是通过可以自由组合的独立、正交的语言功能来降低复杂性。我们通过简化单个特征来降低复杂性,并通过允许它们自由组合来最大化特征的好处。我们想对泛型做同样的事情。

为了使这更具体,我将列出一些我们应该遵循的准则。

尽量减少新概念

我们应该尽可能少地向语言添加新概念。这意味着最少的新语法和最少的新关键字和其他名称。

复杂性落在通用代码的编写者身上,而不是用户身上

尽可能多的复杂性应该落在编写通用包的程序员身上。我们不希望包的用户不必担心泛型。这意味着应该可以以自然的方式调用泛型函数,并且这意味着应该以易于理解和修复的方式报告使用泛型包中的任何错误。将调用调试为通用代码也应该很容易。

作家和用户可以独立工作

同样,我们应该让通用代码的作者和用户的关注点容易分离,以便他们可以独立开发代码。他们不必担心对方在做什么,就像不同包中普通函数的编写者和调用者不必担心一样。这听起来很明显,但对于其他所有编程语言中的泛型却并非如此。

构建时间短,执行时间快

自然地,我们希望尽可能保持 Go 今天给我们提供的较短的构建时间和快速的执行时间。泛型倾向于在快速构建和快速执行之间进行权衡。尽可能地,我们两者都想要。

保持 Go 的清晰性和简单性

最重要的是,今天的 Go 是一种简单的语言。 Go 程序通常清晰易懂。我们探索这个领域的漫长过程的一个主要部分是试图理解如何在保持清晰和简单的同时添加泛型。我们需要找到适合现有语言的机制,而不是把它变成完全不同的东西。

这些指南应该适用于 Go 中的任何泛型实现。这是我今天想留给你的最重要的信息:泛型可以给语言带来显着的好处,但只有当 Go 仍然感觉像 Go 时,它们才值得去做。

设计稿

幸运的是,我认为这是可以做到的。为了结束这篇文章,我将不再讨论我们为什么需要泛型,以及对它们的要求是什么,而是简要讨论我们认为如何将它们添加到语言中的设计。

在今年的 Gophercon 大会上,Robert Griesemer 和我发布了一份为 Go 添加泛型的设计草案。请参阅草案以获取完整详细信息。我将在这里讨论一些要点。

这是此设计中的通用 Reverse 函数。

  1. func Reverse (type Element) (s []Element) {
  2. first := 0
  3. last := len(s) - 1
  4. for first < last {
  5. s[first], s[last] = s[last], s[first]
  6. first++
  7. last--
  8. }
  9. }

你会注意到函数的主体是完全一样的。只是签名变了。

切片的元素类型已被分解。它现在被命名为 Element 并且已经成为我们所说的类型参数。它现在不再是 slice 参数类型的一部分,而是一个单独的附加类型参数。

要调用带有类型参数的函数,在一般情况下,您传递一个类型参数,它与任何其他参数一样,只是它是一个类型。

  1. func ReverseAndPrint(s []int) {
  2. Reverse(int)(s)
  3. fmt.Println(s)
  4. }

这就是在这个例子中 Reverse 之后看到的 (int)。

幸运的是,在大多数情况下,包括这种情况,编译器可以从常规参数的类型中推导出类型参数,而您根本不需要提及类型参数。

调用通用函数看起来就像调用任何其他函数。

  1. func ReverseAndPrint(s []int) {
  2. Reverse(s)
  3. fmt.Println(s)
  4. }
  1. 换句话说,虽然通用 Reverse 函数比 ReverseInts ReverseStrings 稍微复杂一些,但复杂性落在函数的编写者身上,而不是调用者身上。

合同

由于 Go 是一种静态类型语言,我们不得不讨论类型参数的类型。这个元类型告诉编译器在调用泛型函数时允许哪些类型的类型参数,以及泛型函数可以对类型参数的值进行哪些类型的操作。

Reverse 函数可以处理任何类型的切片。它对 Element 类型的值所做的唯一事情是赋值,它适用于 Go 中的任何类型。对于这种泛型函数,这是一个很常见的情况,我们不需要说任何关于类型参数的特别之处

让我们快速看一下不同的功能。

  1. func IndexByte (type T Sequence) (s T, b byte) int {
  2. for i := 0; i < len(s); i++ {
  3. if s[i] == b {
  4. return i
  5. }
  6. }
  7. return -1
  8. }

目前标准库中的bytes包和strings包都有IndexByte函数。此函数返回序列 s 中 b 的索引,其中 s 是字符串或 [] 字节。我们可以使用这个单一的通用函数来替换 bytes 和 strings 包中的两个函数。在实践中,我们可能不会打扰这样做,但这是一个有用的简单示例。

这里我们需要知道类型参数 T 的作用类似于字符串或 []byte。我们可以对它调用 len ,我们可以对其进行索引,我们可以将索引操作的结果与一个字节值进行比较。

为了让这个编译,类型参数 T 本身需要一个类型。它是一个元类型,但是因为我们有时需要描述多个相关的类型,并且因为它描述了泛型函数的实现与其调用者之间的关系,所以我们实际上将 T 的类型称为契约。这里的合约被命名为 Sequence。它出现在类型参数列表之后。

这就是为这个例子定义 Sequence 合约的方式。

  1. contract Sequence(T) {
  2. T string, []byte
  3. }

这很简单,因为这是一个简单的例子:类型参数 T 可以是字符串或 []byte。这里的 contract 可能是一个新的关键字,也可能是一个包范围内识别的特殊标识符;详见设计稿。

任何记得我们在 Gophercon 2018 上展示的设计的人都会发现,这种编写合同的方式要简单得多。我们收到了很多关于合约过于复杂的早期设计的反馈,我们已经尝试将其考虑在内。新合约的编写、阅读和理解要简单得多。

  1. 它们让您指定类型参数的基础类型,和/或列出类型参数的方法。它们还让您描述不同类型参数之间的关系。

有方法的契约

这是另一个简单示例,该函数使用 String 方法返回 s 中所有元素的字符串表示形式的 []string。

  1. func ToStrings (type E Stringer) (s []E) []string {
  2. r := make([]string, len(s))
  3. for i, v := range s {
  4. r[i] = v.String()
  5. }
  6. return r
  7. }

非常简单:遍历切片,对每个元素调用 String 方法,并返回结果字符串的切片。

此函数要求元素类型实现 String 方法。斯金格合同确保了这一点。

  1. contract Stringer(T) {
  2. T String() string
  3. }

契约只是说 T 必须实现 String 方法。

你可能会注意到这个合约看起来像 fmt.Stringer 接口,所以值得指出的是 ToStrings 函数的参数不是 fmt.Stringer 的切片。它是某种元素类型的切片,其中元素类型实现了 fmt.Stringer。元素类型的切片和 fmt.Stringer 的切片的内存表示通常是不同的,Go 不支持它们之间的直接转换。所以这值得写,即使 fmt.Stringer 存在。

多种类型的合约

这是具有多个类型参数的合约示例。

  1. type Graph (type Node, Edge G) struct { ... }
  2. contract G(Node, Edge) {
  3. Node Edges() []Edge
  4. Edge Nodes() (from Node, to Node)
  5. }
  6. func New (type Node, Edge G) (nodes []Node) *Graph(Node, Edge) {
  7. ...
  8. }
  9. func (g *Graph(Node, Edge)) ShortestPath(from, to Node) []Edge {
  10. ...
  11. }

在这里,我们描述了一个由节点和边构建的图。我们不需要图形的特定数据结构。相反,我们说 Node 类型必须有一个 Edges 方法,该方法返回连接到 Node 的边列表。并且 Edge 类型必须有一个 Nodes 方法,该方法返回 Edge 连接的两个节点。

我跳过了实现,但这显示了返回 Graph 的 New 函数的签名,以及 Graph 上 ShortestPath 方法的签名。

这里重要的一点是,合同不仅仅是一种类型。它可以描述两种或多种类型之间的关系。

有序类型

关于 Go 的一个令人惊讶的常见抱怨是它没有 Min 函数。或者,就此而言,Max 函数。这是因为有用的 Min 函数应该适用于任何有序类型,这意味着它必须是泛型的。

虽然 Min 自己编写非常简单,但任何有用的泛型实现都应该让我们将其添加到标准库中。这就是我们设计的样子。

  1. func Min (type T Ordered) (a, b T) T {
  2. if a < b {
  3. return a
  4. }
  5. return b
  6. }

Ordered 契约规定类型 T 必须是有序类型,这意味着它支持小于、大于等运算符。

  1. contract Ordered(T) {
  2. T int, int8, int16, int32, int64,
  3. uint, uint8, uint16, uint32, uint64, uintptr,
  4. float32, float64,
  5. string
  6. }

有序契约只是语言定义的所有有序类型的列表。此协定接受任何列出的类型,或任何其基础类型是这些类型之一的命名类型。基本上,您可以使用小于运算符的任何类型。

事实证明,简单地枚举支持小于运算符的类型比发明一种适用于所有运算符的新符号要容易得多。毕竟,在 Go 中,只有内置类型支持运算符。

这种相同的方法可以用于任何运算符,或者更普遍地为任何打算使用内置类型的泛型函数编写契约。它让泛型函数的作者清楚地指定函数预期使用的类型集。它让泛型函数的调用者清楚地看到该函数是否适用于正在使用的类型。

在实践中,这个合约可能会进入标准库,所以 Min 函数(它可能也会在标准库的某个地方)看起来像这样。这里我们只是指包合同中定义的合同 Ordered。

  1. func Min (type T contracts.Ordered) (a, b T) T {
  2. if a < b {
  3. return a
  4. }
  5. return b
  6. }

通用数据结构

最后,让我们看一个简单的通用数据结构,一棵二叉树。在本例中,树具有比较功能,因此对元素类型没有要求。

  1. type Tree (type E) struct {
  2. root *node(E)
  3. compare func(E, E) int
  4. }
  5. type node (type E) struct {
  6. val E
  7. left, right *node(E)
  8. }

下面是如何创建一个新的二叉树。比较函数被传递给 New 函数

  1. func New (type E) (cmp func(E, E) int) *Tree(E) {
  2. return &Tree(E){compare: cmp}
  3. }

未导出的方法返回一个指针,指向包含 v 的插槽,或者指向它应该去的树中的位置。

  1. func (t *Tree(E)) find(v E) **node(E) {
  2. pn := &t.root
  3. for *pn != nil {
  4. switch cmp := t.compare(v, (*pn).val); {
  5. case cmp < 0:
  6. pn = &(*pn).left
  7. case cmp > 0:
  8. pn = &(*pn).right
  9. default:
  10. return pn
  11. }
  12. }
  13. return pn
  14. }

这里的细节并不重要,尤其是因为我还没有测试过这段代码。我只是想展示编写一个简单的通用数据结构是什么样的。

这是测试树是否包含值的代码。

  1. func (t *Tree(E)) Contains(v E) bool {
  2. return *t.find(e) != nil
  3. }

这是插入新值的代码。

  1. func (t *Tree(E)) Insert(v E) bool {
  2. pn := t.find(v)
  3. if *pn != nil {
  4. return false
  5. }
  6. *pn = &node(E){val: v}
  7. return true
  8. }

注意类型节点的类型参数 E。这就是编写通用数据结构的样子。如您所见,它看起来像编写普通的 Go 代码,只是这里和那里散布了一些类型参数。

使用树非常简单。

  1. var intTree = tree.New(func(a, b int) int { return a - b })
  2. func InsertAndCheck(v int) {
  3. intTree.Insert(v)
  4. if !intTree.Contains(v) {
  5. log.Fatalf("%d not found after insertion", v)
  6. }
  7. }

这是应该的。编写泛型数据结构有点困难,因为你经常需要显式地写出支持类型的类型参数,但尽可能多地使用一个与使用普通的非泛型数据结构没有什么不同。

下一步

我们正在研究实际的实现,以允许我们对这种设计进行试验。能够在实践中尝试设计很重要,以确保我们可以编写我们想要编写的程序类型。它并没有像我们希望的那样快,但我们会在这些实现可用时发送更多细节。

Robert Griesemer 编写了一个修改 go/types 包的初步 CL。这允许测试使用泛型和契约的代码是否可以进行类型检查。它现在不完整,但它主要适用于单个包,我们将继续努力。

我们希望人们对这个和未来的实现做的是尝试编写和使用通用代码,看看会发生什么。我们希望确保人们可以编写他们需要的代码,并且可以按预期使用它。当然,一开始并不是所有的事情都会奏效,当我们探索这个空间时,我们可能不得不改变一些事情。而且,要明确的是,我们对语义的反馈比对语法的细节更感兴趣。

我要感谢所有对早期设计发表评论的人,以及所有讨论过泛型在 Go 中的样子的人。我们已经阅读了所有评论,我们非常感谢人们为此付出的努力。如果没有这项工作,我们就不会走到今天。

我们的目标是设计出一种设计,可以编写我今天讨论过的那种通用代码,而不会使语言过于复杂而无法使用或让它不再像 Go 那样。我们希望这个设计是朝着这个目标迈出的一步,我们希望随着我们从我们的经验和你的经验中学习什么有效,什么无效,继续调整它。如果我们确实达到了这个目标,那么我们就会为 Go 的未来版本提出一些建议。