本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。
“Don’t Repeat Yourself”是常见的软件工程建议。与其重新创建一个数据结构或函数,不如重用它,因为对重复的代码保持更改同步非常困难。在像 Go 这样的强类型语言中,每个函数参数及每个结构体字段的类型必须在编译时确定。这种严格性使编译器能够帮助验证代码是否正确,但有时会希望重用不同类型的函数的逻辑或在重用不同类型的结构体字段。Go 通过类型参数提供了这个功能,俗称为泛型。本章中,读者将了解为什么需要泛型,Go 的泛型实现可以做什么,不能做什么,以及如何正确使用泛型。
泛型减少重复代码并增强类型安全
Go 是一种静态类型语言,这意味着在编译代码时会检查变量和参数的类型。内置类型(字典、切片、通道)和函数(如 len
、cap
或 make
)可接受并返回不同具体类型的值,但直到 Go 1.18,用户自定义的 Go 类型和函数都无法做到这一点。
如果读者熟悉动态类型语言,这类语言中类型在代码运行时才会进行确定,你可能不理解为什么要用泛型,以及它有什么用。如果将其视为“类型参数”,可能会有助于理解。截至目前,我们按函数指定的参数来赋值调用。在以下代码中,我们指定 Min
接受两个 float64
类型的参数并返回一个float64
:
类似地,我们按声明结构体时所指定的字段类型创建结构体。这里Node
中有一个类型为int
的字段和一个类型为*Node
的字段。
但有些情况下编写函数或结构体时,在使用之前不指定参数或字段的具体类型会很有用。
泛型类型的场景很容易理解。在前面,我们学习了一个int类型的二叉树。如果需要一个用于字符串或 float64 的二叉树,并保证类型安全,有几种选择。第一种是为每种类型编写一个自定义树,但是这么多重复的代码既冗长又容易出错。
在没有泛型的情况下,避免重复代码的唯一方法是修改树实现,使用接口来指定如何排序。接口类似这样:
有了Orderable
,我们就可以修改Tree
的实现来提供支持:
对于OrderableInt
类型,可以插入int
值:
这段代码虽可正常运行,但无法让编译器验证插入数据结构的相同值。若有OrderableString
类型:
以下代码可正常编译:
Order
函数使用any
表示传入的值。这会使Go的一个主要优势产生短路,即编译时类型安全检查。在编译代码深度对已包含OrderableInt
的Tree
插入OrderableString
时,编译器接受了该代码。但在运行时程序会panic:
可以测试第8章的GitHub代码库sample_code/non_generic_tree目录中的这段代码。
现在,由于Go引入了泛型,可以一次性为多个类型实现数据结构并在编译时检测出不兼容的数据。很快就会讲到如何正确使用。
虽然没有泛型的数据结构不太方便,但真正的局限在于函数编写。Go标准库中的多个实现是因为最初未包含泛型而做出的决策。例如,Go中没有编写多个处理不同数值类型的函数,而是使用带有足够大范围以精确表示几乎每种其他数值类型的float64
参数来实现诸如math.Max
、math.Min
和math.Mod
这样的函数。 (不影响具有大于253 – 1 或小于-253 – 1的int
、int64
或uint
。)
还有有一功能没有泛型就无法实现。不能创建一个由接口指定变量的新实例,也不能指定两个具有相同接口类型的参数也具有同样的实体类型。没有泛型,就无法不借助反向就编写一个处理所有类型切片的函数,而那又会牺牲一些性能并带来编译时类型安全问题(sort.Slice
就是如此)。也就是说在Go引入泛型之前,处理切片的函数(如map, reduce, and filter) 需要针对不同类型切片进行反复实现。虽然简单的算法很容易拷贝,但很多(也许不是大多数)软件工程师会觉得因为编译器无法智能地自动实现出现重复代码很让人抓狂。
在Go语言中引入泛型
自Go首发以来,一直有呼声要求将泛型添加到该语言中。Go的开发负责人Russ Cox于2009年写了一篇博客文章,解释了为什么最初未包含泛型。Go着重快速编译器、可读性代码和良好的执行时间,而他们所了解的泛型实现都无法同时满足这三个条件。经过十年的研究,Go团队已经找到了一种可行的方法,详见类型参数提案。
可以通过栈来了解Go中的泛型是如何运作的。如果读者没有计算机科学背景,栈是一种数据类型,其中的值以后进先出(LIFO)的顺序添加和删除。这就像一堆等待清洗的盘子;一开始的放在底部,只有先处理后添加的那些盘子才能够拿到它们。我们来看如何使用泛型创建栈:
有几个需要注意的地方。首先,类型声明后使用[T any]
。类型参数放在了方括号内。书写方式与变量参数相同,首先是类型名称,然后是类型约束。可为类型参数选择任意名称,但通常习惯使用大写字母。Go使用接口来指定可以使用哪些类型。如可使用任何类型,使用全局标识符any
来指定。在Stack
声明内部,我们声明vals
的类型为[]T
。
接下来,看一下方法声明。就像我们在vals
声明中使用了T
,此处也是一样的。在接收器部分,我们还使用Stack[T]
换Stack
来引用类型。
最后,泛型使零值处理产生了变化。在Pop
中,我们不能只返回nil
,因为对于值类型(如int
),这不是一个有效值。获取泛型的零值的最简单方法是使用var
声明一个变量并返回,因为根据定义,如果未赋值,var
会将其变量初始化为零值。
使用泛型类型与使用非泛型类型非常相似:
唯一的不同是在声明变量时对Stack
指定了希望包含的类型,本例中为int
。如尝试将字符串压入栈,编译器会捕获到。添加如下行:
会得到编译错误:
可在The Go Playground中测试我们的泛型栈可查看第8章的GitHub代码库sample_code/stack目录中的代码。
下面对该栈添加一个是否包含某值的方法:
可惜无法编译。报错如下:
就像interface{}
没表明什么,any
也一样。只能存储any
类型的值和提取。需要对其它类型才能使用==
。因几乎所有Go类型都可以使用==
和!=
进行比较,在全局代码块中新定义了一个名为comparable
的接口。可comparable
修改的Stack
定义:
然后就可以使用这个新方法了:
输出的结果为:
可测试第8章的GitHub代码库sample_code/comparable_stack目录中我们所更新的栈。
稍后我们会学习如何创建泛型二叉树。在此之前,先讲解一些概念:泛型函数、接口如何使用泛型以及类型名。
泛型函数抽象算法
我们也可以编写函数。前面提到没有泛型会很难编写适用所有类型的映射、归约(reduce)和过滤实现。泛型使其变得简单。以下是类型参数提案中的一些实现:
函数将类型参数放在函数名和变量参数之间。Map
和Reduce
有两个类型参数,都是any
类型,而Filter
为一个参数。运行如下代码时:
会得到如下输出:
读者可自行使用Go Playground或第8章的GitHub代码库sample_code/map_filter_reduce目录中的代码进行测试。
泛型和接口
可以使用任意接口来进行类型约束,不只是有any
和comparable
。例如希望创建一个存储任意实现了fmt.Stringer
的同类型两个值的类型。泛型使得我们可以在编译时进行这一强制:
也可以创建带类型参数的接口。例如,下面有一个包含指定类型值比较方法并返回float64
的接口。还内嵌了fmt.Stringer
:
我们会使用这两个类型创建对比函数。该函数接口两个包含Differ
类型字段的Pair
实例,返回带更接近值的Pair
:
FindCloser
接收包含实现了Differ
接口的字段的Pair
实例。Pair
要求两个字段的类型相同,并且该类型实现fmt.Stringer
接口,该函数要求更高。如果Pair
实例中的字段未实现Differ
,编译器会不允许使用带FindCloser
的Pair
实例。
下面定义几个实现Differ
接口的类型:
该代码的使用如下:
可在The Go Playground中运行或查看第8章的GitHub代码库sample_code/generic_interface目录中的代码。
使用类型名指定运算符
泛型还需要体现另外一点:运算符。divAndRemainder
函数可正常操作int
,而应用于其它类型则需要进行类型转换,并且uint
可存储的值远大于int
。如果要为divAndRemainder
编写一个泛型版本,需要一种方式来指定可使用/
和%
。Go泛型通过类型元素来实现,由接口内的一种或多种类型名指定:
在使用内嵌实现组合一节中,我们学习过嵌套接口表明所包含的接口的方法接包括内嵌接口的方法。类型元素指定类型参数可赋哪些类型,以及支持哪些运算符。通过|
来分隔具体类型。允许的运算符为对所有列出类型有效的那些。模运算符(%
) 仅对整型有效,所有我们列举了所有的整型。(可以不加byte
和rune
,因为它们分别是uint8
和int32
的类型别名。)
注意带类型元素的接口仅对类型约束有效。将它们用作变量、字段、返回值或参数类似会报编译时错误。
现在可以编写divAndRemainder
的泛型版本,通过uint
内置类型使用该函数(或其它Integer
中所列的类型):
默认,类型名完全匹配。如对divAndRemainder
使用底层为Integer
所列类型的自定义类型,会出现错误。以下代码:
会报如下错误:
错误文本提示了如何解决这一问题。如果希望类型名对那些以这些类型为底层类型的类型也有效,在类型名前加~
。那么我们的Integer
定义就变成了:
可在The Go Playground 或第8章的GitHub代码库的sample_code/type_terms目录下查看divAndRemainder
的泛型版本。
类型名让我们可以定义用于编写泛型比较函数的类型:
Ordered
接口列举了所有支持==
, !=
, <
, >
, <=
和>=
运算符的类型。因为指定一种可进行排序变量的方式非常有用,所以在Go 1.21的cmp
包中定义了这个Ordered
接口。该包还定义了两个比较函数。Compare
函数根据第一个参数是否小于、等于或大于第二个参数返回-1, 0或1,而Less
函数在第一个参数小于第二个参数时返回true
。
将同时具有类型元素和方法元素的接口用作类型参数完全合法。例如,可以指定一种类型的底层类型必须为int
并且具备String() string
方法:
注意Go会允许我们声明其实无法实例化的类型参数接口。如果在PrintableInt
中把~int
换成了int
,就不会有满足的有效类型,因为int
不带方法。这样不好,但编译器会进行补救。如果声明了带这种类型参数的类型或函数,企图使用时会导致编译错误。假设声明了这些类型:
虽然无法实例化ImpossibleStruct
,编译器对这些声明不会报错。不过在使用ImpossibleStruct
时,编译器就会报错了。以下代码:
会报编译时错误:
可在The Go Playground 或第8章的GitHub代码库的sample_code/impossible目录下测试这段代码。
除了内置的原生类型外,类型名也可以是切片、字典、数组、通道、结构体甚至函数。它最大的用处是用于保证类型参数具有指定底层类型或一到多个方法。
类型推导和泛型
就像在使用:=
时支持类型推导一样,在调用泛型函数时Go同样支持类型推导。可在上面对Map
、Filter
和Reduce
调用中看出。有些场景无法进行类型推导(如类型参数仅用作返回值)。这时,必须指定所有的参数类型。下面的代码演示了无法进行类型推导的场景:
可在The Go Playground 或第8章的GitHub代码库的sample_code/type_inference目录下测试这段代码。
类型元素限定常量
类型元素也可指定哪些常量可赋值给泛型变量。和运算符一样,常需要对类型元素中的所有类型名有效。没有常量可同时赋值给Ordered
中列出的所有类型,因此无法将一个常量赋值给该泛型类型的变量。如果使用Integer
接口,以下代码无法编译通过,因为不能将1,000赋值给8位的整型:
但下面的就是有效的:
组合泛型函数和泛型数据结构
回到二叉树示例,来看如何使用所学的知识生成适用所有实体类型的树。
核心在于理解该树需要一个泛型函数,可比较两个值给出排序:
有了OrderableFunc
,我们就可以稍稍修改树的实现。首先将其分成两种类型,Tree
和Node
:
通过构造函数构造一个新Tree
:
Tree
的方法非常简单,因为它调用Node
来完成任务:
Node
的Add
和Contains
方法与之前的非常类似。唯一的区别是传递了用于排序元素的函数:
现在我们需要匹配OrderedFunc
定义的函数。所幸我们已经见过一个:cmp
包中的Compare
。在对Tree
使用它时是这样:
对于结构体,有两种选项。可以编写一个函数:
然后在创建树进传递该函数:
不使用函数,我们了可以为NewTree
提供一个方法。在方法也是函数中我们讨论过,可以使用方法表达式来将方法看作函数。下面上手操作。首先编写方法:
然后使用该方法:
可在The Go Playground 或第8章的GitHub代码库的sample_code/generic_tree目录下测试这段代码。
再谈可比较类型
在接口可比较一节中我们学到,接口中也是Go中一种可比较类型。这也就表示在对接口类型变量使用==
和!=
时要小心。如果接口的底层类型不可比较,代码会在运行时panic。
这个坑在使用带泛型的可比较接口时依然存在。假设我们定义了一个接口以及一些实现:
还需要定义一个泛型函数仅接收可比较的值:
调用带类型为int
或ThingerInt
的变量的函数完全合法:
编译器不允许我们调用变量类型为ThingerSlice
(或[]int
)的函数:
但所调用的变量类型为Thinger
时完全合法。如果使用ThingerInt
,代码可正常编译、运行:
但也可以将ThingerSlice
赋值给Thinger
类型的变量。这时会出问题:
编译器允许我们构建这段代码,但运行后程序会panic(参见panic和recover一节了解更多信息),消息为panic: runtime error: comparing uncomparable type main.ThingerSlice
。可在The Go Playground 或第8章的GitHub代码库的sample_code/more_comparable目录下测试这段代码。
在关可比较类型和泛型交互以及为何做出这种设计决策的更多技术细节,请阅读Go团队Robert Griesemer的博客文章All your comparable types。
未实现的功能
Go仍是一种小型且聚焦的编程语言,Go对泛型的实现并未包含部分在其它语言泛型中存在的特性。下面是一些Go泛型尚未实现的特性。
虽然我们可以构建一个同时能处理自定义和内置类型的树,但在Python、Ruby和C++中处理的方法却不同。它们有运算符重载,允许用户自定义类型指定运算符的实现。 Go没有添加这种特性。也就意味着我们不能使用range
遍历自定义容器类型,也不能对其使用[]
进行索引。
没添加运算符重载有一些原因。其一是Go语言中有极其大量的运算符。Go也不支持函数或方法重载,那就需要为不同的类型指定不同的运算函数。此外,重载的代码会不易理解,因为开发人员会为符号巧立各种含义(在C++中,<<
对一些类型表示按位左移,而对另一些类型则在左侧值的右侧写值)。Go努力避免这类易读性问题。
另一个未实现的有用特性是,Go的泛型实现对方法没有附加类型参数。回看Map/Reduce/Filter
函数,你可能觉得它们可像方法那样使用,如:
你以为可以这样用:
可惜对于函数式编程的拥趸们,并不能这样用。我们不能做链式方法调用,而要嵌套函数调用或使用更易读一次调用一次函数的方式,将中间值赋给变量。类型参数提案中详细讨论了未支持参数化方法的原因。
没有可变类型参数。在可变参数和切片一节中讨论到,要实现接收可变数量参数的函数,需要指定最后一个参数,其类型以...
开头。比如,无法对可变参数指定某种类型模式,像可交替的string
和int
。所有的可变变量必须为同一种声明类型,是不是泛型皆可。
Go泛型未实现的其它特性就更加晦涩些了。有:
- 特化(Specialization)
- 函数或方法可通过泛型版本外的一个或多个指定类型版本进行重载。因Go语言没有重载,这一特性不在考虑范围内。
- 柯里化(Currying)
- 允许我们通过指定某些类型参数根据另一个泛型函数或类型部分实例化函数。
- 元编程
- 允许我们指定在编译时运行的代码并生成运行时运行的代码。
地道的Go和泛型
添加泛型显然会改变一些地道使用Go的建议。使用float64
来表示所有的数值类型的时代结束了。应当使用any
来代替interface{}
表示数据结构或函数参数中未指定的类型。可以用一个点函数处理不同的切片类型。但不要觉得要马上使用类型参数切换掉所有的代码。在新设计模式发明和深化的同时老代码依然正常可用。
现在判断泛型对性能的长期影响还为时尚早。在写本文时,它对编译时间并没有影响。Go 1.18的编译器要慢于之前的版本,但Go 1.20的编译器解决了这一问题。
有一些关于泛型对运行时间影响的影响。Vicent Marti写了一篇深入的文章,探讨了一些导致代码变慢的泛型案例并详细讲解了产生这一问题的实现细节。相反,Eli Bendersky写了一篇博客文章说明泛型让排序算法变快了。
一般来说,不要期望将带接口参数的函数修改为泛型类型参数的函数能提升性能。比如,将下面的小函数:
转化为:
会使得该函数在Go 1.20变慢约30%。(对于大型函数,没有显著的性能区别)。可以使用第8章的GitHub代码库的sample_code/perf directory目录下代码进行基准测试。
使用过其它语言泛型的开发者可能会感到意外。比如在C++中,编译器使用抽象数据类型的泛型来将运行时运算(确定所使用的实体类型)转化为编译时运算,为每种实体类型生成独立的函数。这会让二进制变大,但也让其变快。Vicent在博客文章中提到,当前的Go编译器仅为不同的底层类型生成独立函数。此外,所有指针类型共享同一个生成函数。为区分传递给共享生成函数的类型,编译器添加了额外的运行时查询。这会减慢性能。
随着Go未来版本中泛型实现渐趋成熟,运行时性能也会提升。目标并没有改变,还是要编写满足需求且易维护的快速运行代码。使用基准测试一节中讨论的基准测试和性能测试工具来度量和提升你的代码。
向标准库添加泛型
Go 1.18刚发布泛型时是很保守的。在全局添加了any
和comparable
接口,但并未在标准库中做出支持泛型的API调整。只做出了样式变化,将大部分标准库中的interface{}
改成了any
。
现在Go社区更适应了泛型,我们也看到了更多的变化。从Go 1.21起,标准库中包含了一些函数,使用泛型实现切片、字典和并发的常用算法。在复合类型一文中我们讲到了slices
和maps
包中的Equal
和EqualFunc
函数。这些包中的其它函数简化了切片和字典操作。slices
包中的Insert
、Delete
和DeleteFunc
函数让开发展不必构建极其复杂的切片处理代码。maps.Clone
函数利用Go Runtime来提供更快速的方式,来创建字典的浅拷贝。在代码精确地只运行一次一节中,我们学到sync.OnceValue
和sync.OnceValues
,它们使用泛型来构建只运行一次并返回一到两个值的函数。推荐使用这些包中的函数,而不要自己去实现。未来版本的标准库还会包含更多用到泛型的函数和类型。
解锁未来特性
泛型可能是其它未来特性的基础。一个可能是sum types。就像类型元素用于指定可替换类型参数的类型一样,和类型可用于变量参数中的接口。这会出现一些有趣的特性。如今Go在JSON的常见场景存在问题:其字段可以是单个值也可是值列表。即使是有泛型,处理这种情况的唯一方式是装饰字段类型设为any
。添加和类型可让我们创建指定字段可为字符串、字符串切片及其它类型的接口。然后类型switch可以枚举每种有效类型,提升类型案例。指定类型边界集的能力可以让现代语言(包括Rust和Swift)使用和类型替代枚举。而Go当前在枚举特性上存在不足,这会成为一种有吸引力的解决方案,但需要时间来评估和探讨这些想法 。