Go语言泛型的进化
涛叔本文所介绍的内容已经于2020年8月24日更新到最新的设计草案。
最近看到 Ian Lance Taylor 在 golang-nuts 论坛发布了 Go 语言泛型的最新改动,去掉了 type 关键字1。为了弄明白他讲的内容,我周末又研究了一下Go语言泛型设计2。今天介绍一下泛型语法的设计演进过程。
在开始之前,先写一个最新的泛型例子,好让大家有一个感性的认识。
// Map 对 []T1 的每个元素执行函数 f 得到新的 []T2
func Map[T1, T2 any](s []T1, f func(T1) T2) []T2 {
:= make([]T2, len(s))
r for i, v := range s {
[i] = f(v)
r}
return r
}
有别于常见的 c++/java 泛型,Go没有使用尖括号(<>)表示泛型列表,而是使用了方括号([],最开始使用圆括号)。
Map后面的[T1, T2 any]表示参数类型。Map函数需要两个参数,对参数类型没有任任限制,所以参数类型的类型是any。
下面是一个使用示例
:= []int{1, 2, 3}
s
:= Map[int, float64](s, func(i int) float64 { return float64(i) })
floats // 现在 floats 的值是 []float64{1.0, 2.0, 3.0}.
参数的类型同样使用方括号指定,Map[int, float64]
等价于 func Map(s []int, f func(int) float64) []float64
,其功能则是将 []int
转化成 []float64
。
为了简化调用,Go还支持泛型推导,也就是根据实际调用参数确定泛型的具体类型。前面的例子还可以简化成
:= Map(s, func(i int) float64 { return float64(i) }) floats
T1和T2的类型可以分别通过 s 和 f 的实际入参推导出来。这样的设计可以尽量减少泛型函数和普通函使用上的差异,让Go代码看起来更加一致。
好了,让我们总结一下 Go 泛型的特点
- 使用[]而非<>
- 泛型都有类型
- 支持泛型推导
这基本上是目前最好的设计了。下面我们说说Go语言的泛型是如何演化成如今这个样子的。
最开始,设计者希望使用圆括号表示泛型。可是圆括号在Go语言里用途极广,函数的参数列表就是用圆括号表示的,怎么区分参数列表跟泛型列表呢?
最简单的办法就是插入关键字做区分。于是设计者选用了 type 关键字。一个典型的泛型函长这样
func Print(type T)(t T) {
.Println(t)
fmt}
请注意,泛型列表的左圆括号之后紧跟着type,而函数参数列表的左括号之后直接跟参数名,这样就可以把两者分开。So far so good。
我们再考虑另一个问题,泛型有没有类型?泛型不是代表所有类型吗?怎么泛型还需要类型呢?让我们看一段伪代码
// Stringify 将 []T 的所有成员拼接成一整个字符串
func Stringify(type T)(s []T) (ret []string) {
for _, v := range s {
= append(ret, v.String())
ret }
return ret
}
请大家注意这里的v.String()
,v的类型为T,我们在函数Stringfy中要调用v的String()
方法,T又是不确定的,我们怎么保证 v 一定实现了String()
方法呢?显然,这种写法是不能的。我们需要对T的取值(也就是 v 的类型)作一下限制(constraint),这种限制在最初设计中叫 contract3。如果你想限制T一定要实现String()
方法,你可以定义如下 contract
(T) {
contract stringer() string
T String}
然后将Stringify
定义为func Stringify(type T stringer)(s []T) (ret []string)
,这样编译器就能确保所有 v 都实现 String()
方法了。
大家有没有觉得 contract 跟 interface 有点像呢?确实,当contract设计方案发布的时候大家都说很容易跟interface混淆。可为什么设计者一开始没有使用 interface 呢?那是interface只能规定实现的方法,无法限制运算符、len() 函数等 go 语言内部的操作。我们举一个例子。
// Max 返回两个参数中较大一个
func Max(type T)(a, b T) T {
if a > b {
return a
}
return b
}
这里需要比较a和b的大小。问题来了,在Go语言里只有少部分内建类型才能比较大小,你没法直接比较两个 struct 或者 map,Go语言本身又不支持运算符重载,所以你没法使用 interface 来确保 T 是支持比较运算符的。据此,设计者才引入了 contract 的概念。对于这一类类型,你可以声明如下 contract
(T) {
contract Orderedint, int8, int16, int32, int64,
T uint, uint8, uint16, uint32, uint64, uintptr,
float32, float64, string
}
说白了就是把 go 语言中支持 >
运算符的类型都列出来,虽然看起来有点 Low,但有效。这个时候你可以以把 Max 函数改写成
func Max(type T Ordered)(a, b T) T
当你尝试调用 Max([]int{1}, []int{2})
的时候,编译器就能确定 []int{}
不符合 Ordered
规定,进而给出具体的报错信息。
到现在一切都好~唯一的问题就是需要引入 contract 这个新概念。概念越多越不容易学习,社区也普遍表示了对 contract 的反对。大约一年后,设计者又给一个新的设计[2],这次移除了 contract。但正如我们前面所讲,原来的 interface 并不能满足泛型的需要,必需对 interface 做一下扩展。
于是,新的草案给 interface 引入了 type list 支持,说白了就是把 contract 的功能合并到了 interface 中。你可以在定义 interface 的时候通过 type 指定一组类型列表。前面的 Ordered contract 可以定义为
type Ordered interface{
type int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64, uintptr,
float32, float64, string
}
这样你可以像使用 contract 那样使用 Ordered interface
作为泛型限制了。如此,便不再需要引入新的 contract 概念。
到现在,Go泛型的主要设计就讲完了。除了写起来括号有点多之外,没有什么大毛病。
但大毛病没有,几个小毛病却又影响到了泛型的语法设计。
我们前面说过,为了跟函数列表相区分,设计者给泛型列表引入了 type 关键字。除此之外,泛型在编译的时候还有几处二义性需要处理。
如果T
是泛型,则T(int)
表示将T
具体化(instantiating)为int
。单纯这样看是没有歧义的。但是如果跟其他语法写到一起就不一样定了。
比如 func f(T(int))
是表示 func f(T int)
呢(在这里T是参数名,其类型为int)还是表示 func ((T(int)))
呢(在这里省去了函数的参数名,T为泛型类型,并具体化为int)?
再比如 struct{ T(int) }
是表示 struct { T int }
呢(在这里 T 是属性名,其类型为int)还是表示 struct { (T(int)) }
呢(在这里T为泛型类型,具体化为int后嵌入struct)?
再比如 interface{ T(int) }
是表示 interface{ T(int) }
呢(在这里T为函数名,入参为int)还是表示 interface{ (T(int)) }
呢(在这里T为泛型类型,具体化为int后也可能是一个 interface,并嵌入原interface)?
最后比如 []T(int){}
是表示 ([]T)(int){}
呢(在这里表示初始化slice,值的类型为int)还是表示[](T(int)){}
呢(在这里表示初始化为slice,值的类型是泛型T具体化为int后的类型,不一定是int)?
如何消除这种二义性呢?加括号!所以,为了能正常使用泛型,你不得不写成这个样子
func ((T(int)))
struct { (T(int)) }
interface{ (T(int)) }
[](T(int)){}
括号太多了。为了少写点括号,设计者绞尽脑汁,最终发现只有方括号可堪此重任。
最开始不选用方括号是因为会带来更多的二义性问题。
一个歧义就是type A [T] int
是表示 type A[T] int
呢(A是泛型类型,泛型T没有用到)还是 type A [T]int
(A是长度为T的[]int)。不过这个问题可以能运引入 type 消除。
另一个就是编译器在分析func f(A[T]int)
和func f(A[T], int)
两种定义的时候需要适当向后看(lookahead),会增加分析器的复杂度。
最开始设计者没有想到前面所讲的T(int)
二义性问题,于是选用了圆括号。现在回头看,发现与其写那么多括号,不如稍稍扩展一下分析器,于是就可以消除T(int)
的二义性问题了,你可以这样写
func (T[int]))
struct { T[int] }
interface{ T[int] }
[]T[int]{}
一下子少好多括号,棒棒的!就是他了!原来的Print
方法写成这个样子
func Print[type T](t T)
设计者还是觉得这个 type 关建字不清真,没有办法将 type 也省掉呢(真是得陇望蜀)?设计者想到了一个妙招,强行规定所有泛型都必须写 constraint(这样可以跟函数参数列表保持统一,所有参数都有类型)。分析器碰到左方括号之后会继续向前看,如果发现有 constraint 就能确定是泛型列表。所以,Print
方法需要改写为
func Print[T interface{}](t T)
这下好了,不用写 type。可是慢着,不写 type 省4个字符,强制写 constraint 需要写 interface{},这是11个字符呀,得不尝失!设计者又开动大脑,想了一个绝招,我们给 interface{} 设一个别名吧,就叫 any,这样比写 type 还少写一个字符呢,于是 Print
方法最终变成了
func Print[T any](t T)
不管你服不服,反正我是服了!
对于 any,社区还有一些争议,但感觉问题不大。期待 Go 泛型正式上线。
最后提一下,Go语言的设计草案[2]很值得一读,里面记录了各种设计上的取舍,很有启发意义。
https://go.googlesource.com/proposal/+/refs/heads/master/design/go2draft-type-parameters.md↩︎
https://go.googlesource.com/proposal/+/master/design/go2draft-contracts.md↩︎
https://go.googlesource.com/proposal/+/refs/heads/master/design/go2draft-type-parameters.md#examples↩︎
https://go.googlesource.com/proposal/+/refs/heads/master/design/go2draft-type-parameters.md#discarded-ideas↩︎