Go语言泛型的进化

2020-08-23 ⏳6.9分钟(2.8千字) 🕸️

本文所介绍的内容已经于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 {
	r := make([]T2, len(s))
	for i, v := range s {
		r[i] = f(v)
	}
	return r
}

有别于常见的 c++/java 泛型,Go没有使用尖括号(<>)表示泛型列表,而是使用了方括号([],最开始使用圆括号)。

Map后面的[T1, T2 any]表示参数类型。Map函数需要两个参数,对参数类型没有任任限制,所以参数类型的类型是any。

下面是一个使用示例

s := []int{1, 2, 3}

floats := Map[int, float64](s, func(i int) float64 { return float64(i) })
// 现在 floats 的值是 []float64{1.0, 2.0, 3.0}.

参数的类型同样使用方括号指定,Map[int, float64] 等价于 func Map(s []int, f func(int) float64) []float64,其功能则是将 []int 转化成 []float64

为了简化调用,Go还支持泛型推导,也就是根据实际调用参数确定泛型的具体类型。前面的例子还可以简化成

floats := Map(s, func(i int) float64 { return float64(i) })

T1和T2的类型可以分别通过 s 和 f 的实际入参推导出来。这样的设计可以尽量减少泛型函数和普通函使用上的差异,让Go代码看起来更加一致。

好了,让我们总结一下 Go 泛型的特点

  1. 使用[]而非<>
  2. 泛型都有类型
  3. 支持泛型推导

这基本上是目前最好的设计了。下面我们说说Go语言的泛型是如何演化成如今这个样子的。

最开始,设计者希望使用圆括号表示泛型。可是圆括号在Go语言里用途极广,函数的参数列表就是用圆括号表示的,怎么区分参数列表跟泛型列表呢?

最简单的办法就是插入关键字做区分。于是设计者选用了 type 关键字。一个典型的泛型函长这样

func Print(type T)(t T) {
	fmt.Println(t)
}

请注意,泛型列表的左圆括号之后紧跟着type,而函数参数列表的左括号之后直接跟参数名,这样就可以把两者分开。So far so good。

我们再考虑另一个问题,泛型有没有类型?泛型不是代表所有类型吗?怎么泛型还需要类型呢?让我们看一段伪代码

// Stringify 将 []T 的所有成员拼接成一整个字符串
func Stringify(type T)(s []T) (ret []string) {
	for _, v := range s {
		ret = append(ret, v.String())
	}
	return ret
}

请大家注意这里的v.String(),v的类型为T,我们在函数Stringfy中要调用v的String()方法,T又是不确定的,我们怎么保证 v 一定实现了String()方法呢?显然,这种写法是不能的。我们需要对T的取值(也就是 v 的类型)作一下限制(constraint),这种限制在最初设计中叫 contract3。如果你想限制T一定要实现String()方法,你可以定义如下 contract

contract stringer(T) {
	T String() 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

contract Ordered(T) {
	T int, int8, int16, int32, int64,
	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]很值得一读,里面记录了各种设计上的取舍,很有启发意义。


  1. https://groups.google.com/g/golang-nuts/c/iAD0NBz3DYw↩︎

  2. https://go.googlesource.com/proposal/+/refs/heads/master/design/go2draft-type-parameters.md↩︎

  3. https://go.googlesource.com/proposal/+/master/design/go2draft-contracts.md↩︎

  4. https://go.googlesource.com/proposal/+/refs/heads/master/design/go2draft-type-parameters.md#examples↩︎

  5. https://go.googlesource.com/proposal/+/refs/heads/master/design/go2draft-type-parameters.md#discarded-ideas↩︎

  6. https://go2goplay.golang.org/↩︎