Go语言泛型前传

2022-02-13 ⏳4.3分钟(1.7千字) g

Go语言泛型马上就要随1.18版本发布了,大快人心。Go语言最早于2009年11月11日发布。还没过24小时,就有人提出能否支持泛型(海外链接)。至今,已经过去了13年。为什么Go语言到现在才加入泛型特性呢?那是因为Go语言有很多特性可以在定程序上实现泛型的效果。因为这些特性都是在泛型之前引入的,所以文章取名为「前传」。

基础容器对象

1.18之前的Go语言虽然不支持泛型,却支持三种基础的容器对象,它们是 array/slice/map。我们可以声明任意类型的容器对象,比如:

var a [3]int // 长度为 3 的 int 数组
var b []float32 // 单精度浮点数切片
var c map[string]int32 // 字符串到32位整数的映射表

编译器会保证容器的类型安全,也就是说存入容器在数据必须符合声明的类型才行。其实这就是最基本的泛型对象了。声明所用的 int/float32/string 就是泛型的类型参数。不同的是老版本的编译器只支持数组、切片和字典这三种最基础的数据结构。但是,虽然只有三种,却也满足了最常用的使用场景。这也是Go语言团队多方权衡的结果,一方面尽量满足使用需求,另一方面尽量保持编译器实现简单。

这种处理方式优点是简单,短平快。缺点也很明显,不能自定义泛型对象。为了解决这个问题,我们需要用到接口。

接口

Go语言支持接口(interface),每个接口支持定义多个方法。这样就可以用抽象的接口限制参数的类型,从而支持处理不同类型的参数。

比如我们在内部框架中需要统一处理database/sql的DB和Tx对象,我就定义了如下接口:

type unionDB interface {
  ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
  QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
  QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row
}

因为DB和Tx都实现了上面的三个方法,所以我们就可以这样使用:

func exec(db unionDB, query string, args []interface{}) (sql.Result, error) {
  // ...
}

var tx *sql.Tx
var db *sql.DB

exec(tx, query, args)
exec(db, query, args)

再比如,Go语言sort包中定义了sort.Interface接口:

type Interface interface {
  Len() int
  Less(i, j int) bool
  Swap(i, j int)
}

任何容器对象只要实现了这三个方法,就可以通过sort.Sort函数进行排序。

Go语言的接口不需要显式声明,只要实现了对应的方法,就是对应的接口类型。这是一种纯粹的鸭子类型。另外,Go语言的接口在调用的时候没有间接寻址开销,性能接近普通函数调用。基于此,接口在Go语言里使用非常广泛。

但是接口要求不同的对象实现相同的方法。如果对象没有公共的接口可以实现该怎么处理呢?这就得用到类型断言了。

类型断言

我们上一节说到接口可以声明一个或多个函数。一个对象,如果需要匹配某个接口类型,就需要实现所有定义的函数。换句话说,如果一个接口没有定义函数,那就可以匹配所有的数据类型。没有函数的接口就是空接口,写作interface{}。很多资料都把它比作C语言里的void*指针,但它们有本质的区别。

因为空接口没有定义函数,所以,对于任意对象,它们不需要实现任何函数就能符合空接口的定义。所以我们可以把任意对象赋值给interface{}类型的变量。

var a interface{} = 1
var b interface{} = 1.1
var c interface{} = []string{"foo"}

上一节也说过,定义接口是为了调用其函数。但空接口没有对应的函数,那还有什么用呢?我们有办法获取保存在空接口变量的具体类型信息呢?有!这就得用到类型断言。

对于空接口变量,我们可以通过代码动态获取其保存对象的类型信息,比如:

i, ok := a.(int)
if ok {
  // ...
}

空接口变量可以通过a.(type)语法转换成具体的类型。如果变量中的对象不是对应的类型,则ok的值为false。如果需要处理多种类型,则可以配合switch使用:

func foo(i interface{}) {
  switch i.(type) {
    case int:
    // ...
    case float32,float64:
    // ...
    case string:
    // ...
  }
}

使用空接口(interface{})可以同时处理种类型的数据,但代价则是放弃了编译期类型检查。所有的类型信息只能在程序运行的时候通过断言动态确定,很容易出BUG。

在运行时确定对象类型等信息的技术又叫反射,类型断言就是反射中的一小部分。Go语言本身也提供反射机制。通过反射,我们可以动态地创建对象、调用函数。这在某些特定场景可以代理使用泛型。

不过是类型断言还是反射,都放弃了编译期间的类型简单,而且还引入了运行时成本,有一定的运行开销。最重要的是反射代码通常都比较晦涩,所以除非万不得以,大家还是不要使用它为好。

那有没有不引入运行时开销的方案呢?当然有,那就是最最朴实无华的复制粘贴了。

复制粘贴

以max(a, b int) int为例,核心逻辑为:

func max(a, b int) int {
  if a > b {
    return a
  }
  return b
}

如果想支持int32,那就复制一份:

func max32(a, b int32) int32 {
  if a > b {
    return a
  }
  return b
}

虽然有点傻,但非常有效,没有一点运行时开销。如果要支持的类型非常多,Go语言还提供了代码生成工具go generate,帮助大家快速生成代码。

这种方法运行时性能好,但维护起来比较麻烦。

总结

以上就是前传的主要内容。内置的容器最接近泛型,但不支持程序员自定义。接口方便调用公共函数,但无法支持任意对象。类型断言支持任意对象,却放弃了编译器的类型检查。复制粘贴没有运行时开销,代码却又难以维护。总之,Go语言没有泛型也可以用,却老是有这样那样的问题。经过将近13年的等待,以上问题都会因为泛型的引入而得以圆满解决。期待😚

如果对Go语言泛型感兴趣,也欢迎阅读我的系列文章。

参考文章: