Rob 反对修改 Go 1.18 泛型标准库

2021-10-13

Go 语言之父 Rob Pike 昨天创建 issue,反对在 Go 1.18 的标准库中引入泛型支持。Rob 此举,既在意料之外,又在情理之中。Rob 的依据有两点:一个是改动范围过大,再一个是缺乏实战经验。所以 Rob 建议先不要在 Go 1.18 修改标准库,而是先在 golang.org/x 或者 golang.org/exp 提供相关库的泛型版本。经过一到两个版本的实际使用,才能积累足够的经验,届时再考虑更新标准库。那社区原本要对标准库做哪些有争议改动呢?请容我细细道来。

泛型语法简介

在开始之前,先简单介绍一下 Go 语言泛型的语法。比如要实现一个简单的Max函数,可以这样写:

func Max[T any](a, b T) T {
  if a < b {
    return b
  }
  return a
}

这里需要在函数名Max和参数列表(a,b)之间插入[T any]。其中的T就是泛型参数,后面的any表示泛型参数的限制范围(constraint)。我们后面会细说,大家知道 Go 语言要求泛型参数必须指定范围。声明泛型参数后,就可以在参数列表、返回值列表和函数内部使用泛型参数了。

调用泛型函数的时候需要指明实际的类型:

fmt.Println(Max[int](1, 2)) // 2
fmt.Println(Max[float32](1.1, 2.2)) // 2.2
fmt.Println(Max[string]("a", "b")) // b

为了方便使用,Go 泛型还支持类型推导。也就是说,Go 可以根据函数参数的类型来推断泛型参数的类型,所以上述例子可以改写成:

fmt.Println(Max(1, 2)) // 2
fmt.Println(Max(1.1, 2.2)) // 2.2
fmt.Println(Max("a", "b")) // b

是不是看起来跟普通函数没有区别了!

这里有一个问题,我们可以执行下面的调用吗?

fmt.Println(Max([]int{1}, []int{2}))

答案是不能。因为在Max函数中有if a < b {}这样的比较判断,而 slice 是不能相互比较的(具体见comparable)。也就是说,我们虽然想用T表示不同的参数类型,但Max的实现决定了只能传入可以相互比较的类型。这就是限制范围(constraint)需要解决的问题。

我们前面说过,泛型参数都需要指定限制范围。那如何定义 constraint 呢?这就需要用到 interface 的变体。比如,支持比较的类型可以写成这样:

type Ordered interface {
  int | int8 | int16 | int32 | int64 |
  uint | uint8 | uint16 | uint32 | uint64 | uintptr |
  float32 | float64 |
  string
}

所以,前面的Max函数应该改写成Max[T ordered](a, b T) T。因为添加了 ordered 限制,所以Max([]int{1}, []int{2})在编译的时候就会报错。

定义 constraint 的时候还支持一种特殊的语法

type Float interface {
	~float32 | ~float64
}

这里的~表示底层类型为float32或者float64。也就是说,如果你定义type MyFloat float32,也是可以匹配Float的。但如果不加~,那就无法匹配。

这里用到的 interface,大家应该能猜到可以使用接口来限制泛型参数的范围(必须实现相应的接口)。那如果要支持传入所有类型(也就是不加限制),那就得使用大名鼎鼎的interface{}了。但是写成func foo[T interface{}])(a T)实在是难看,而且T后面必须加限制,多数泛型的参数限制又都是interface{},所以官方才同意引入关键词any来代替interface{}。这就回应了最前面Max[T any](a, b T) T的写法。

泛型详细介绍可以参考我的另一篇文章Go语言泛型的进化。

标准库的泛型改造

有了泛型,很多之前做不了或不优雅的事情现在都好做了。

引入新的标准库

为了方便大家写泛型代码,首先要引入的就是 constraints 标准包。

package constraints
// 有符号整数
type Signed interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64
}
// 无符号整数
type Unsigned interface {
	~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}
// 所有整数
type Integer interface {
	Signed | Unsigned
}
// 浮点数
type Float interface {
	~float32 | ~float64
}
// 复数
type Complex interface {
	~complex64 | ~complex128
}
// 支持比较的类型
type Ordered interface {
	Integer | Float | ~string
}
// 任意类型的 slice
type Slice[Elem any] interface {
	~[]Elem
}
// 任意类型的 map
type Map[Key comparable, Val any] interface {
	~map[Key]Val
}
// 任意类型的 channel
type Chan[Elem any] interface {
	~chan Elem
}

其中 comparable 是内置泛型限制,匹配所有允许相等比较的类型。但 constraints 包刚合并,fzipp 就提议简化泛型限制语法。简单来说就是让[T nonInterfaceType] 等价于 [T interface{~nonInterfaceType}]。举个例子:

匹配所有 map 的泛型参数可以写成[M interface{~map[K]V}, K comparable, V any],也可引用 constraints 包写成[M constraints.Map[K, V], K comparable, V any]。但如果支持fzipp的提案,就可以写成[M map[K]V, K comparable, V any]。同样的,如果想匹配所有 slice,可以写成[S []T, T any]。如果只是想简单匹配多个类型,可以写成[T int8|int16],是不是有点 union type 的意思。

我们可以不关心 fzipp 提案的细节,但有一点需要明确,如果接受了 fzipp 的提案,那 constraints 包中的 Slice/Map/Chan 就没太大的必要了。这是就 Rob 担心的地方。我们需要现实的经验来检验当前泛型的设计。

同样引入的还有 slice 操作包:

package slices
import "constraints"
// 相等
func Equal[T comparable](s1, s2 []T) bool {}
func EqualFunc[T1, T2 any](s1 []T1, s2 []T2, eq func(T1, T2) bool) bool {}
// 比较
func Compare[T constraints.Ordered](s1, s2 []T) int {}
func CompareFunc[T1, T2 any](s1 []T1, s2 []T2, cmp func(T1, T2) int) int {}
// 查找
func Index[T comparable](s []T, v T) int {}
func IndexFunc[T any](s []T, f func(T) bool) int {}
func Contains[T comparable](s []T, v T) bool {}
// 插入
func Insert[S ~[]T, T any](s S, i int, v ...T) S {}
// 删除
func Delete[S ~[]T, T any](s S, i, j int) S {}
// 复制
func Clone[S ~[]T, T any](s S) S {}
// 去重
func Compact[S ~[]T, T comparable](s S) S {}
func CompactFunc[S ~[]T, T any](s S, eq func(T, T) bool) S {}
// 扩容
func Grow[S ~[]T, T any](s S, n int) S {}
// 缩容
func Clip[S ~[]T, T any](s S) S {}

除了Index/Contains/Insert比较常用外,其他方法都非常小众。像Grow的代码只有下面的一行:

func Grow[S ~[]T, T any](s S, n int) S {
	return append(s, make(S, n)...)[:len(s)]
}

它们具体在实践中的效果也不得而知,现在就集成到标准库确实为时尚早。

同样引入的map操作:

package maps
// 所有 key
func Keys[M constraints.Map[K, V], K comparable, V any](m M) []K
// 所有值
func Values[M constraints.Map[K, V], K comparable, V any](m M) []V
// 判断相等
func Equal[M1, M2 constraints.Map[K, V], K, V comparable](m1 M1, m2 M2) bool
func EqualFunc[M1 constraints.Map[K, V1], M2 constraints.Map[K, V2], K comparable, V1, V2 any](m1 m1, m2 M2, cmp func(V1, V2) bool) bool
// 清空
func Clear[M constraints.Map[K, V], K comparable, V any](m M)
// 复制
func Clone[M constraints.Map[K, V], K comparable, V any](m M) M
// 把一个 map 的所有 k-v 复制到另一个
func Copy[M constraints.Map[K, V], K comparable, V any](dst, src M)
// 删除指定元素
func DeleteFunc[M constraints.Map[K, V], K comparable, V any](m M, del func(K, V) bool)

这里面比较常用的就是Keys/Values了,其他函数需要通过实践进一步验证。

以上都是引入新标准库的部分,不会对已有的代码产生兼容性问题。还有一部分需要改造已有的标准库,这可能会产生兼容性问题。

改造旧的标准库

这一部分主要可以参考 Russ Cox 发起的讨论how to update APIs for generics。问题的核心是很多包已经把好的名字占用了,没法直接改造成支持泛型的版本。比如:

很可惜,这些类型或者函数都被强关联了interface{}。

以 sync.Pool 为例,如果强行改成sync.Pool[T],那所有声明 Pool 的地方都需要改成var p sync.Pool[interface{}]才能正常工作。

而对于 math.Max 就种,如果直接改为泛型,则又会因为类型推导产生问题。比如:

var i int
Min(i, 5) // 对应 Min[int]
Min(3, 5) // 对应 Min[float64]

所以 Russ 提议给相关的类型或者函数加一个带 Of 后缀的版本!比如,sync.Pool对应sync.PoolOf[T any]。sync 包的提案在这里。这种容器类的加 Of 后后缀也就罢了,math.Min 这种也要写成 math.MinOf 感觉有点怪。

当然了,还有人提议给标准库添加 v2 后缀或者前缀,甚至还为了用 v2/math 还是 math/v2 吵起来。

我个人更倾向于 DeedleFake 的方案,不改名字,而是根据 go.mod 的 go 版本决定是否使用泛型代码。如果有人想升级到 go 1.18,只要运行一遍 go fix 就可以把所有代码升级到泛型版本。我认为这是最清真的方案。

总结

内容够多了,回到 Rob 的提案。经过前文的分析,我们很容易得出这样的结论:我们缺少泛型相关的实践经验,现在仓促修改标准库只会给社区带来更多的维护负担。所以 Rob 的提案虽然略显保守,却也非常科学。我支持 Rob。最后以引用 Rob 的评论结束本文:I strongly believe it is best to take it slow for now. Use, learn, study, and move cautiously.

「taoshu」微信公众号