Go 博客
为什么使用泛型?
介绍
这是我上周在 Gophercon 2019 上演讲的博客文章版本。
本文讨论了将泛型添加到 Go 中的意义,以及我认为我们为什么要这样做。我还将简要介绍一下对可能用于将泛型添加到 Go 中的设计的更新。
Go 于 2009 年 11 月 10 日发布。不到 24 小时后,我们看到了关于泛型的第一个评论。(该评论还提到了异常,我们在 2010 年初以panic
和recover
的形式将其添加到语言中。)
在三年的 Go 调查中,缺乏泛型始终被列为需要在语言中修复的前三大问题之一。
为什么使用泛型?
但是,添加泛型意味着什么,我们为什么要这样做呢?
引用Jazayeri 等人的话:泛型编程能够以泛型形式表示函数和数据结构,并将其类型分解出来。
这意味着什么?
举一个简单的例子,假设我们想反转切片中的元素。这不是很多程序需要做的,但也不是不常见。
假设这是一个 int 类型的切片。
func ReverseInts(s []int) {
first := 0
last := len(s)
for first < last {
s[first], s[last] = s[last], s[first]
first++
last--
}
}
非常简单,但即使对于这样一个简单的函数,您也需要编写一些测试用例。事实上,当我这样做时,我发现了一个错误。我相信很多读者已经发现了。
func ReverseInts(s []int) {
first := 0
last := len(s) - 1
for first < last {
s[first], s[last] = s[last], s[first]
first++
last--
}
}
在设置变量 last 时,我们需要减去 1。
现在让我们反转一个 string 类型的切片。
func ReverseStrings(s []string) {
first := 0
last := len(s) - 1
for first < last {
s[first], s[last] = s[last], s[first]
first++
last--
}
}
如果比较ReverseInts
和ReverseStrings
,您会发现这两个函数完全相同,只是参数的类型不同。我认为没有读者对此感到意外。
一些 Go 新手感到意外的是,无法编写一个适用于任何类型切片的简单Reverse
函数。
大多数其他语言都允许您编写这种函数。
在像 Python 或 JavaScript 这样的动态类型语言中,您可以简单地编写函数,而无需费心指定元素类型。这在 Go 中不起作用,因为 Go 是静态类型的,并且要求您写下切片的精确类型和切片元素的类型。
大多数其他静态类型语言,如 C++ 或 Java 或 Rust 或 Swift,都支持泛型来解决这类问题。
Go 中的泛型编程
那么人们如何在 Go 中编写这种代码呢?
在 Go 中,您可以通过使用接口类型并为要传入的切片类型定义方法来编写一个适用于不同切片类型的单个函数。这就是标准库的sort.Sort
函数的工作原理。
换句话说,Go 中的接口类型是泛型编程的一种形式。它们允许我们捕获不同类型的共同方面并将其表示为方法。然后,我们可以编写使用这些接口类型的函数,并且这些函数将适用于实现这些方法的任何类型。
但是这种方法达不到我们想要的效果。使用接口,您必须自己编写方法。仅仅为了反转一个切片而必须定义一个带有一两个方法的命名类型很麻烦。并且您编写的每个切片类型的方法都完全相同,因此从某种意义上说,我们只是移动和压缩了重复代码,并没有消除它。尽管接口是泛型的一种形式,但它们并没有给我们带来我们想要的泛型的一切。
另一种使用接口实现泛型的方法是让语言为某些类型的类型定义方法,这可以避免自己编写方法的需要。这并不是语言今天支持的东西,但是,例如,语言可以定义每个切片类型都具有一个返回元素的 Index 方法。但是为了在实践中使用该方法,它必须返回一个空接口类型,然后我们就会失去静态类型检查的所有好处。更微妙的是,将无法定义一个泛型函数,该函数接受两个具有相同元素类型的不同切片,或者接受一个元素类型的映射并返回相同元素类型的切片。Go 是一种静态类型语言,因为它使编写大型程序更容易;我们不想为了获得泛型的优势而失去静态类型检查的优势。
另一种方法是使用 reflect 包编写一个泛型Reverse
函数,但这写起来非常麻烦,运行起来也很慢,所以很少有人这样做。这种方法还需要显式类型断言,并且没有静态类型检查。
或者,您可以编写一个代码生成器,它接受一个类型并为该类型的切片生成一个Reverse
函数。有一些代码生成器可以做到这一点。但这会为每个需要Reverse
的包添加另一个步骤,它会使构建过程复杂化,因为所有不同的副本都必须编译,并且修复主源代码中的错误需要重新生成所有实例,其中一些可能位于完全不同的项目中。
所有这些方法都非常麻烦,以至于我认为大多数必须在 Go 中反转切片的人只是为他们需要的特定切片类型编写该函数。然后,他们需要为该函数编写测试用例,以确保他们没有犯像我最初犯的那么简单的错误。并且他们需要定期运行这些测试。
无论我们如何做,这都意味着仅仅为了一个除了元素类型之外看起来完全相同的函数而要付出很多额外的工作。这并不是说它不能完成。它显然可以完成,并且 Go 程序员正在这样做。只是应该有更好的方法。
对于像 Go 这样的静态类型语言来说,更好的方法是泛型。我之前写过泛型编程能够以泛型形式表示函数和数据结构,并将其类型分解出来。这正是我们在这里想要的。
泛型可以为 Go 带来的好处
我们希望 Go 中的泛型首先也是最重要的一点是能够编写像Reverse
这样的函数,而无需关心切片的元素类型。我们希望将该元素类型分解出来。然后,我们可以编写一次函数,编写一次测试,将其放入可获取的 go 包中,并在需要时调用它们。
更好的是,由于这是一个开源世界,其他人可以编写一次Reverse
,我们可以使用他们的实现。
在这一点上,我应该说“泛型”可以表示很多不同的含义。在这篇文章中,“泛型”的含义是我刚才描述的。特别是,我指的不是 C++ 语言中的模板,模板支持的功能比我这里写的内容多得多。
我详细介绍了Reverse
,但还有许多其他函数我们可以泛型地编写,例如
- 查找切片中的最小/最大元素
- 查找切片的平均值/标准差
- 计算映射的并集/交集
- 查找节点/边图中的最短路径
- 将转换函数应用于切片/映射,返回新的切片/映射
这些示例在大多数其他语言中都可用。事实上,我浏览了 C++ 标准模板库后写下了这份列表。
还有一些示例特定于 Go,因为它对并发提供了强大的支持。
- 使用超时从通道读取
- 将两个通道合并成一个通道
- 并行调用函数列表,返回结果切片
- 调用函数列表,使用 Context,返回第一个完成的函数的结果,取消并清理额外的 goroutine
我见过所有这些函数都使用不同的类型写过很多次。在 Go 中编写它们并不难。但是,能够重用一个适用于任何值类型的有效且经过调试的实现会很好。
需要明确的是,这些仅仅是示例。还有许多其他通用函数可以使用泛型更容易、更安全地编写。
此外,正如我之前写的那样,这不仅仅是函数。它也是数据结构。
Go 在语言中内置了两种通用的泛型数据结构:切片和映射。切片和映射可以保存任何数据类型的值,并对存储和检索的值进行静态类型检查。这些值本身就被存储,而不是作为接口类型存储。也就是说,当我有一个[]int
时,切片直接保存 int,而不是将 int 转换为接口类型。
切片和映射是最有用的泛型数据结构,但它们并不是唯一的。以下是一些其他示例。
- 集合
- 自平衡树,能够高效地插入和按排序顺序遍历
- 多映射,具有键的多个实例
- 并发哈希映射,支持并行插入和查找,且没有单个锁
如果我们可以编写泛型类型,我们就可以定义新的数据结构,例如这些数据结构,它们具有与切片和映射相同的类型检查优势:编译器可以静态地检查它们保存的值的类型,并且这些值可以作为自身存储,而不是作为接口类型存储。
还应该能够获取前面提到的算法并将其应用于泛型数据结构。
这些示例都应该像Reverse
一样:在包中编写一次泛型函数和数据结构,并在需要时重用。它们应该像切片和映射一样工作,即它们不应该存储空接口类型的值,而应该存储特定类型的值,并且这些类型应该在编译时进行检查。
所以这就是 Go 从泛型中可以获得的好处。泛型可以为我们提供强大的构建块,让我们更容易地共享代码和构建程序。
我希望我已经解释清楚了为什么值得研究它。
利弊
但是泛型并非来自巨大的岩石糖果山,那里阳光普照,遍布柠檬水泉。每种语言的改变都会带来成本。毫无疑问,向 Go 添加泛型会使语言变得更加复杂。与对语言的任何更改一样,我们需要讨论如何最大化收益并最小化成本。
在 Go 中,我们的目标是通过独立的、正交的语言特性来降低复杂性,这些特性可以自由组合。我们通过使各个特性变得简单来降低复杂性,并通过允许它们自由组合来最大化这些特性的益处。我们希望对泛型也这样做。
为了使这一点更加具体,我将列出一些我们应该遵循的指导原则。
最小化新概念
我们应该尽可能少地向语言添加新概念。这意味着语法上的最小化,以及关键字和其他名称的最小化。
复杂性落在泛型代码编写者身上,而不是使用者
尽可能地,复杂性应该落在编写泛型包的程序员身上。我们不希望包的用户不得不担心泛型。这意味着应该能够以自然的方式调用泛型函数,并且意味着使用泛型包的任何错误都应该以易于理解和修复的方式报告。调用泛型代码也应该易于调试。
编写者和使用者可以独立工作
同样,我们应该使泛型代码编写者和使用者之间的关注点易于分离,以便他们可以独立开发代码。他们不应该担心对方在做什么,就像不同包中普通函数的编写者和调用者不需要担心一样。这听起来很明显,但在其他每种编程语言中的泛型中却并非如此。
构建时间短,执行时间快
当然,我们希望尽可能保持 Go 今天赋予我们的构建时间短和执行时间快的特性。泛型往往会在快速构建和快速执行之间引入权衡。我们希望尽可能同时拥有两者。
保留 Go 的清晰性和简洁性
最重要的是,Go 今天是一种简单的语言。Go 程序通常清晰易懂。我们漫长的探索过程的一个主要部分是试图了解如何在保留清晰性和简洁性的同时添加泛型。我们需要找到适合现有语言的机制,而不会将其变成完全不同的东西。
这些指导原则应该适用于 Go 中的任何泛型实现。这是我今天想留给你的最重要的信息:**泛型可以为语言带来显著的益处,但只有在 Go 仍然感觉像 Go 的情况下,它们才值得去做。**
设计草案
幸运的是,我认为这是可以做到的。为了结束本文,我将从讨论我们为什么要使用泛型以及对泛型的要求转向简要讨论我们认为如何将它们添加到语言中的设计。
2022 年 1 月添加的说明:这篇博文写于 2019 年,并未描述最终采用的泛型版本。有关更新的信息,请参阅语言规范中类型参数的描述和泛型设计文档。
在今年的 Gophercon 上,Robert Griesemer 和我发布了一个添加泛型到 Go 的设计草案。有关完整详细信息,请参阅该草案。我将在这里介绍一些要点。
以下是此设计中的泛型 Reverse 函数。
func Reverse (type Element) (s []Element) {
first := 0
last := len(s) - 1
for first < last {
s[first], s[last] = s[last], s[first]
first++
last--
}
}
您会注意到函数体完全相同。只有签名发生了变化。
切片的元素类型已被提取出来。它现在命名为Element
,并且已成为我们所说的类型参数。它不再是切片参数类型的部分,而是一个单独的、额外的类型参数。
要调用具有类型参数的函数,在一般情况下,您传递一个类型参数,它与任何其他参数一样,只是它是一个类型。
func ReverseAndPrint(s []int) {
Reverse(int)(s)
fmt.Println(s)
}
这就是此示例中Reverse
之后看到的(int)
。
幸运的是,在大多数情况下,包括此示例在内,编译器可以从常规参数的类型推断出类型参数,并且您根本不需要提及类型参数。
调用泛型函数看起来就像调用任何其他函数一样。
func ReverseAndPrint(s []int) {
Reverse(s)
fmt.Println(s)
}
换句话说,尽管泛型Reverse
函数比ReverseInts
和ReverseStrings
略微复杂,但这种复杂性落在函数的编写者身上,而不是调用者身上。
契约
由于 Go 是一种静态类型语言,因此我们必须讨论类型参数的类型。此元类型告诉编译器在调用泛型函数时允许使用哪些类型的参数,以及泛型函数可以使用类型参数的值执行哪些操作。
Reverse
函数可以处理任何类型的切片。它对类型Element
的值执行的唯一操作是赋值,这适用于 Go 中的任何类型。对于这种泛型函数(非常常见的情况),我们不需要对类型参数进行任何特殊说明。
让我们快速看看另一个函数。
func IndexByte (type T Sequence) (s T, b byte) int {
for i := 0; i < len(s); i++ {
if s[i] == b {
return i
}
}
return -1
}
目前,标准库中的 bytes 包和 strings 包都具有IndexByte
函数。此函数返回b
在序列s
中的索引,其中s
要么是string
,要么是[]byte
。我们可以使用此单个泛型函数替换 bytes 和 strings 包中的这两个函数。在实践中,我们可能不会费心这样做,但这是一个有用的简单示例。
在这里,我们需要知道类型参数T
的作用类似于string
或[]byte
。我们可以对它调用len
,可以对其进行索引,并且可以将索引操作的结果与字节值进行比较。
为了使它能够编译,类型参数T
本身需要一个类型。它是一个元类型,但由于我们有时需要描述多个相关的类型,并且因为它描述了泛型函数的实现与其调用者之间关系,因此我们实际上将T
的类型称为契约。此处契约名为Sequence
。它出现在类型参数列表之后。
这就是为此示例定义 Sequence 契约的方式。
contract Sequence(T) {
T string, []byte
}
它非常简单,因为这是一个简单的示例:类型参数T
可以是string
或[]byte
。这里contract
可能是一个新关键字,或是在包范围内识别的特殊标识符;有关详细信息,请参阅设计草案。
任何记得我们在 Gophercon 2018 上提出的设计的人都会发现这种编写契约的方式要简单得多。我们收到了很多关于早期设计中契约过于复杂的反馈,我们已尝试将其考虑在内。新的契约更容易编写、阅读和理解。
它们允许您指定类型参数的基础类型和/或列出类型参数的方法。它们还允许您描述不同类型参数之间的关系。
具有方法的契约
这是一个简单的示例,它使用 String 方法返回s
中所有元素的字符串表示形式的[]string
。
func ToStrings (type E Stringer) (s []E) []string {
r := make([]string, len(s))
for i, v := range s {
r[i] = v.String()
}
return r
}
它非常简单:遍历切片,对每个元素调用String
方法,并返回结果字符串的切片。
此函数要求元素类型实现String
方法。Stringer 契约确保了这一点。
contract Stringer(T) {
T String() string
}
契约简单地说T
必须实现String
方法。
您可能会注意到此契约类似于fmt.Stringer
接口,因此值得指出的是ToStrings
函数的参数不是fmt.Stringer
的切片。它是某种元素类型的切片,其中元素类型实现了fmt.Stringer
。元素类型的切片和fmt
.Stringer的切片的内存表示通常不同,并且 Go 不支持它们之间的直接转换。因此,即使存在fmt.Stringer
,这仍然值得编写。
具有多个类型的契约
这是一个具有多个类型参数的契约示例。
type Graph (type Node, Edge G) struct { ... }
contract G(Node, Edge) {
Node Edges() []Edge
Edge Nodes() (from Node, to Node)
}
func New (type Node, Edge G) (nodes []Node) *Graph(Node, Edge) {
...
}
func (g *Graph(Node, Edge)) ShortestPath(from, to Node) []Edge {
...
}
在这里,我们正在描述一个由节点和边构建的图。我们不要求图的特定数据结构。相反,我们说Node
类型必须具有Edges
方法,该方法返回连接到Node
的边的列表。并且Edge
类型必须具有Nodes
方法,该方法返回Edge
连接的两个Nodes
。
我跳过了实现,但这显示了返回Graph
的New
函数的签名,以及Graph
上的ShortestPath
方法的签名。
这里重要的收获是,契约不仅仅与单个类型有关。它可以描述两个或多个类型之间的关系。
有序类型
关于 Go 的一个令人惊讶的常见抱怨是它没有Min
函数。或者,就此而言,没有Max
函数。这是因为有用的Min
函数应该适用于任何有序类型,这意味着它必须是泛型的。
虽然Min
很容易自己编写,但任何有用的泛型实现都应该允许我们将其添加到标准库中。这就是它在我们设计中的样子。
func Min (type T Ordered) (a, b T) T {
if a < b {
return a
}
return b
}
Ordered
契约说类型 T 必须是有序类型,这意味着它支持小于、大于等运算符。
contract Ordered(T) {
T int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64, uintptr,
float32, float64,
string
}
Ordered
合约只是一个包含语言定义的所有有序类型的列表。此合约接受任何列出的类型,或任何底层类型为这些类型之一的命名类型。基本上,任何可以使用小于运算符的类型。
事实证明,简单地枚举支持小于运算符的类型比发明适用于所有运算符的新表示法要容易得多。毕竟,在 Go 中,只有内置类型支持运算符。
同样的方法可以用于任何运算符,或者更一般地,为任何旨在与内置类型一起使用的泛型函数编写合约。它允许泛型函数的编写者清楚地指定预期函数使用的类型集。它允许泛型函数的调用者清楚地看到该函数是否适用于正在使用的类型。
在实践中,此合约可能会进入标准库,因此实际上Min
函数(也可能在标准库中的某个地方)将如下所示。这里我们只是引用 contracts 包中定义的 Ordered
合约。
func Min (type T contracts.Ordered) (a, b T) T {
if a < b {
return a
}
return b
}
泛型数据结构
最后,让我们来看一个简单的泛型数据结构,二叉树。在此示例中,树具有比较函数,因此对元素类型没有要求。
type Tree (type E) struct {
root *node(E)
compare func(E, E) int
}
type node (type E) struct {
val E
left, right *node(E)
}
以下是创建新二叉树的方法。比较函数传递给 New
函数。
func New (type E) (cmp func(E, E) int) *Tree(E) {
return &Tree(E){compare: cmp}
}
一个未导出的方法返回一个指向存储 v 的槽的指针,或指向树中它应该去的位置的指针。
func (t *Tree(E)) find(v E) **node(E) {
pn := &t.root
for *pn != nil {
switch cmp := t.compare(v, (*pn).val); {
case cmp < 0:
pn = &(*pn).left
case cmp > 0:
pn = &(*pn).right
default:
return pn
}
}
return pn
}
这里的细节并不重要,尤其是我还没有测试过这段代码。我只是想展示编写简单的泛型数据结构的样子。
这是测试树是否包含值的代码。
func (t *Tree(E)) Contains(v E) bool {
return *t.find(e) != nil
}
这是插入新值的代码。
func (t *Tree(E)) Insert(v E) bool {
pn := t.find(v)
if *pn != nil {
return false
}
*pn = &node(E){val: v}
return true
}
请注意,类型 node
有一个类型参数 E
。这就是编写泛型数据结构的样子。如您所见,它看起来像编写普通的 Go 代码,只是这里和那里散布了一些类型参数。
使用该树非常简单。
var intTree = tree.New(func(a, b int) int { return a - b })
func InsertAndCheck(v int) {
intTree.Insert(v)
if !intTree.Contains(v) {
log.Fatalf("%d not found after insertion", v)
}
}
这应该是这样。编写泛型数据结构稍微困难一些,因为您通常必须为支持类型显式编写类型参数,但尽可能地使用它与使用普通的非泛型数据结构没有区别。
后续步骤
我们正在进行实际的实现,以便我们可以用这种设计进行实验。能够在实践中尝试设计非常重要,以确保我们可以编写我们想要编写的程序类型。进展速度不如我们希望的那么快,但我们会随着这些实现的可用性发送更多关于它们的详细信息。
Robert Griesemer 编写了一个初步的 CL,修改了 go/types 包。这允许测试使用泛型和合约的代码是否可以进行类型检查。它目前还不完整,但它主要适用于单个包,我们将继续对其进行完善。
我们希望人们使用此实现和未来的实现来尝试编写和使用泛型代码,并观察会发生什么。我们希望确保人们可以编写他们需要的代码,并且他们可以按预期使用它。当然,并非所有内容一开始都能正常工作,并且随着我们探索这个领域,我们可能需要更改某些内容。并且,需要明确的是,我们对语义的反馈比对语法的细节更感兴趣。
我要感谢所有对早期设计发表评论的人,以及所有讨论 Go 中泛型可能是什么样的人。我们阅读了所有评论,并非常感谢人们为此付出的努力。如果没有这些工作,我们将无法达到今天的成就。
我们的目标是设计一个能够编写今天我讨论过的泛型代码的方案,而不会使语言过于复杂难以使用,也不会让它不再像 Go。我们希望这种设计是朝着这个目标迈出的一步,并且我们预计会随着我们从我们的经验和您的经验中了解到什么有效和什么无效而继续调整它。如果我们确实实现了这个目标,那么我们将拥有可以为 Go 的未来版本提出的内容。
下一篇文章:实验、简化、发布
上一篇文章:宣布新的 Go Store
博客索引