Go语言泛型前传
涛叔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 {
(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
ExecContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
QueryContext(ctx context.Context, query string, args ...interface{}) *sql.Row
QueryRowContext}
因为DB
和Tx
都实现了上面的三个方法,所以我们就可以这样使用:
func exec(db unionDB, query string, args []interface{}) (sql.Result, error) {
// ...
}
var tx *sql.Tx
var db *sql.DB
(tx, query, args)
exec(db, query, args) exec
再比如,Go语言sort
包中定义了sort.Interface
接口:
type Interface interface {
() int
Len(i, j int) bool
Less(i, j int)
Swap}
任何容器对象只要实现了这三个方法,就可以通过sort.Sort
函数进行排序。
Go语言的接口不需要显式声明,只要实现了对应的方法,就是对应的接口类型。这是一种纯粹的鸭子类型。另外,Go语言的接口在调用的时候没有间接寻址开销,性能接近普通函数调用。基于此,接口在Go语言里使用非常广泛。
但是接口要求不同的对象实现相同的方法。如果对象没有公共的接口可以实现该怎么处理呢?这就得用到类型断言了。
类型断言
我们上一节说到接口可以声明一个或多个函数。一个对象,如果需要匹配某个接口类型,就需要实现所有定义的函数。换句话说,如果一个接口没有定义函数,那就可以匹配所有的数据类型。没有函数的接口就是空接口,写作interface{}
。很多资料都把它比作C语言里的void*
指针,但它们有本质的区别。
因为空接口没有定义函数,所以,对于任意对象,它们不需要实现任何函数就能符合空接口的定义。所以我们可以把任意对象赋值给interface{}
类型的变量。
var a interface{} = 1
var b interface{} = 1.1
var c interface{} = []string{"foo"}
上一节也说过,定义接口是为了调用其函数。但空接口没有对应的函数,那还有什么用呢?我们有办法获取保存在空接口变量的具体类型信息呢?有!这就得用到类型断言。
对于空接口变量,我们可以通过代码动态获取其保存对象的类型信息,比如:
, ok := a.(int)
iif 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语言泛型感兴趣,也欢迎阅读我的系列文章。
参考文章: