Go语言的泛型

时间过得飞快,不知不觉间春节假期已经过完了,我们又返回到工作岗位上。就在除夕当晚,Go1.18的第二个beta版发布了,正式版推迟到了3月份发布。之前我们曾经简单介绍过go1.17的泛型尝鲜,但是由于实现不完整,所以有太多的限制,也就没有太深入的去分析。这次的beta版到正式发布应该不会有重大变化了,所以我们赶在正式版发布之前再来聊聊泛型。

所谓的泛型,实际上是一种抽象,在Go语言里被称为类型参数type parameters,这种叫法更易于理解。类型参数,通俗来讲就是变量的类型是参数化的,是可以通过参数来指定的。我们在定义一个函数或者数据类型的时候,可以不指定某些参数和变量的具体类型,而是预留数据类型占位符。等到实际用到这些函数或自定义数据类型时,再为这些占位符指定实际的数据类型。通过下面的例子很容易理解:

1
2
3
func Print[T any](v T) {
fmt.Print(v)
}

其中T就是类型参数。与C++的模板以及Java的泛型相比,Go语言的泛型看起来有着诸多不同。首先Go没有像C++和Java那样,使用一对尖括号<>来装载类型参数,而是选择了中括号[],这是为了避免和小于号之间造成歧义。这样设计可以避免编译器实现方面的不必要的复杂性,从而保证编译速度,这一点是Go团队非常看重的。其次就是类型参数T后面的这个any,这在C++、Java等语言的泛型实现中也是没有的,any在这里被称为“约束条件”。

约束条件

使用过C++模板的同学应该或多或少有过一些痛苦经历,那就是解决模板相关的编译错误。尤其是在C++11标准之前,经过几层模板类和模板函数的嵌套之后,编译器输出的错误信息往往会让你不知所云。就是因为C++模板在设计之初没有提供一种简单易用的声明式约束,编译器必须遍历所有与之相关的语句之后,才能知道实际传入的类型参数是否满足要求。在经过多层嵌套之后,报错的位置竟然时常出现在标准库及其他一些第三方库里,着实让人头疼不已,甚至怀疑人生。

Go语言引入了用于类型参数的约束条件constraints,很好的解决了这一问题。约束条件在语法层面通过接口interface来实现,这是一个被扩展过的接口,我们在后文中会介绍具体是如何扩展的,这里先重点关注约束条件。约束条件会作用于两个方面,首先是泛型函数或自定义类型内部,只能使用约束条件所允许的操作。另一方面是函数的调用者、以及自定义类型被使用的地方,只能传入满足约束条件的类型参数。比如以下示例:

1
2
3
func ToString[T fmt.Stringer](v T) string {
return v.String()
}

这个函数没什么实际价值,fmt.Stringer就是约束条件,所以ToString函数内部可以调用v.String方法,如果使用了fmt.Stringer接口不包含的方法就会报错,无法通过编译。同理,在调用者方面,传入的类型参数必须实现了fmt.Stringer接口,否则无法通过编译。得益于约束条件,编译器能够非常高效的检查类型参数的合法性,输出的错误提示也更加直观。

any

对约束条件的概念有了基本的理解之后,我们再回过头来看这个约束条件any。它其实就是空接口interface{}的别名,go1.18标准库中所有的interface{}都被替换成了any,有兴趣的同学可以看看fmt.Print系列函数的参数类型。我们之前说过,interface{}是万能容器类型,从接口的角度来理解,不包含任何方法也就是对于具体类型没有任何要求,所以支持任意类型。用作约束条件时,也就相当于没有任何约束,所以支持任意类型。

根据Type Parameters Proposal的相关描述,Go语言的泛型在设计上,对于任意类型支持以下操作:

  1. 声明该类型的变量
  2. 相同数据类型的值和变量间的赋值操作
  3. 作为函数的参数和返回值
  4. 取该类型变量的地址
  5. 把该类型的值转换为或赋值给interface{}
  6. T类型的值转换为T类型,允许但没什么用
  7. 用作类型断言的目标类型
  8. 用在type switch的某个case分支中
  9. 定义复合类型时作为复合类型的组成部分,比如作为切片的元素类型
  10. 把该类型传递给某些预定义的函数,比如new

以上这些操作是在语言层面对任意类型都支持的,任何约束条件都适用。对于特定的约束条件,除了以上这些操作外,还可以使用约束条件允许的操作,比如上述ToString函数中fmt.Stringer接口所支持的String方法。关于泛型和约束条件的基本概念就讲这些,我们在下一篇文章中详细介绍约束条件的用法。