Golang 泛型要来了吗?

参考:【Golang】泛型要来了吗? - 知乎

1. 泛型是什么?

2. 目前有什么痛点呢?

3. Go的泛型如何使用?

4. 基于泛型实现的缓存是什么样子的?

5. 有了泛型还需要interface{}吗?

6. Go1.17的泛型为什么不能从本包导出?

1. 泛型是什么?

泛型程序设计(generic programming)是程序设计语言的一种风格或范式。泛型允许程序员在强类型程序设计语言中编写代码时使用一些以后才指定的类型,在实例化时作为参数指明这些类型。

各种程序设计语言和其编译器、运行环境对泛型的支持均不一样。Ada、Delphi、Eiffel、Java、C#、F#、Swift和Visual Basic .NET称之为泛型(generics);ML、Scala和Haskell称之为参数多态(parametric polymorphism);C++和D称之为模板。具有广泛影响的1994年版的《Design Patterns》一书称之为参数化类型(parameterized type)。

直白一点来说,就是允许我们基于一个还不确定的类型展开程序设计,我们围绕该类型编写代码逻辑时,它可能还未被定义出来。我们通常需要假设它具有某些属性,或者支持某些操作。所以泛型编程面向的是具有某些共同特性的一组类型,比起普通的面向对象编程,是一种更高层次的抽象。

Go语言虽然还没有正式支持泛型,但是最近发布的Go1.17版已经支持泛型尝鲜,我们可以一起憧憬泛型正式到来的日子了。我们为什么如此的期待泛型呢?因为我们期待泛型能够解决现阶段的一些痛点。

2. 目前有什么痛点呢?

我们的代码中经常会用到一些本地缓存组件,有的支持过期时间,有的基于LRU算法。这些都是复用性极高的基础组件,经常以mod的形式单独提供,比如比较常用的:

http://github.com/hashicorp/golang-lru

http://github.com/patrickmn/go-cache

这些组件在使用体验上都跟map差不多,都提供了Set和Get这类方法。为了支持任意类型,这些方法都使用了interface{}类型的参数。经过简化之后,其核心结构就是如下这种形式:

type Cache map[string]interface{}
​
func (c Cache) Set(k string, v interface{}) {
  c[k] = v
}
​​
func (c Cache) Get(k string) (v interface{}, ok bool) {
    v, ok = c[k]
    return
}

内部实际存储数据的就是个值类型为interface{}的map,interface{}是个万能容器,可以装载任意类型。使用Set方法的时候,一般不会觉得有什么不方便,因为从具体类型或接口类型到interface{}的赋值不需要进行额外处理。但是Get方法使用起来就不那么完美了,我们需要通过类型断言把取出来的数据转换成预期的类型。比如我想从本地缓存c里面取出来一个string,那就需要这样来写代码:

if v, ok := c.Get("key"); ok {
  if s, ok := v.(string); ok {
    // use s
  }
}

多了一步类型断言操作。如果你能够保证缓存里的值只有string这一种类型的话,也可以不使用comma ok风格的断言,这样更简单一点:

s := v.(string)

如果仅仅是多这一步操作的话,我们也就忍了,实际上可不是这么简单。在讲接口的时候我们曾经分析过,interface{}本质上是一对指针,用来装载值类型时会发生装箱,造成变量逃逸。比如我们用上面的Cache来缓存int64类型,map的bucket里存的是一个个的interface{},而实际的int64会在堆上单独分配,interface{}的数据指针指向堆上的int64,如下图所示:

而最高效的存储结构是直接把int64存储在map的bucket里,如下图所示:

对比之下,基于interface{}的存储方式凭空多出来一次堆分配,并且又多占用了两倍的内存空间。从性能方面来考虑的话,这绝对是个十足的痛点了,我们期待泛型能够解决这个问题。

3. Go的泛型如何使用?

之前关于Type Parameters的Draft,现在已经升格为Proposal,链接地址如下:https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md

Go1.17的泛型就是按照这个提案来实现的,只是目前还不完整,所以我们只选择几个比较关键的点来讲一下。

1)类型参数

类型参数就是参数化类型的那个参数,直到使用的时候才被明确出来。比如如下代码中的类型T,就是个类型参数:

func printAll[T any](s []T) {
  for _, v := range s {
    fmt.Println(v)
  }
}

实际调用printAll函数的时候,可以明确指定类型参数:

printAll[int]([]int{1, 2, 3})

也可以省略掉类型参数,编译器可以根据上下文进行推导:

printAll([]int{1, 2, 3})

2)约束

前面我们说过,泛型编程是抽象的,通常是面向具有某些相同属性、或者支持某些相同操作的一组类型。在Go这种强类型语言中,我们希望可以根据这些来对类型参数进行约束。之前就已经有了接口这种组件,用来定义类型支持的一组操作,所以泛型这里就直接复用了接口来描述约束条件。上面printAll函数的类型参数T后面的any就是约束,但是any比较特殊,可以把它理解成不进行任何约束。

还是针对上面的printAll函数,假如我们希望传入的类型T实现String()方法,可以这样进行约束:

func printAll[T fmt.Stringer](s []T) {
  for _, v := range s {
    fmt.Println(v.String())
  }
}

这里的fmt.Stringer接口只是被用作约束条件,编译阶段供编译器进行检查,并不会用到接口的动态派发等运行时特性。

3)类型集

有时候只通过方法来约束是不够的,比如对于语言内置的各种整型类型,它们是没有任何方法的,但是它们都支持整数运算。所以泛型实现的时候,又对接口的语法进行了扩展,可以使用如下语法根据已有类型来定义一个类型集:

type Integer interface {
  type int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, uintptr
}

上述代码根据Go里面所有的整型类型定义了一个Integer类型集,该类型集也可以被用作约束:

func add[T Integer](a, b T) T {
  return a + b
}

编译器就可以根据该类型集支持的操作,对函数代码的合法性进行检查。

4)泛型类型

所谓泛型类型,指的就是包含类型参数的复合类型。比如我们可以用切片加上类型参数,来模拟C++标准库中的vector类型:

type vector[T any] []T

还可以为它定义方法,比如实现一个向vector中追加元素的pushBack方法:

func (v *vector[T]) pushBack(e T) {
  *v = append(*v, e)
}

关于泛型的基本用法就介绍到这里,大家感兴趣的话可以自行阅读Proposal。

4. 基于泛型实现的缓存是什么样子的?

我们基于Go1.17中的泛型实现来改造一下上面的缓存类,新的实现如下:

package main

type cache[T any] map[string]T

func (c cache[T]) Set(k string, v T) {
  c[k] = v
}

func (c cache[T]) Get(k string) (v T, ok bool) {
  v, ok = c[k]
  return
}

我们把缓存类型的首字母改成了小写,因为目前Go1.17的泛型实现还不支持导出,泛型相关的类型和函数只能在当前包中使用。

而且Go1.17中的泛型支持默认是关闭的,构建可执行文件的时候,需要指定-gcflags='-G=3'参数来显式的开启。

根据笔者的观察,build命令只有在编译main包的时候才会透传-G=3参数,编译其他包时不会传递该参数,所以这又进一步限制了我们只能在main包中使用泛型,不过对于尝鲜来讲也已经足够了。

我们可以像如下代码这样使用改造过的泛型缓存类:

var c cache[int64]
func main() {
  c = make(cache[int64])
  c.Set("nine", 9)
  if n, ok := c.Get("nine"); ok {
    println(n)
  }
}

为了方便进一步分析研究,我们构建可执行文件的时候关闭编译器的内联优化,使用如下命令:

$ go build -gcflags='-G=3 -l'

然后使用nm命令来分析可执行文件,可以看到编译器为cache[int64]类型生成的两个方法:

$ go tool nm generic.exe | grep main\.cache
  45b980 T main.cache[int64].Get
  45b900 T main.cache[int64].Set

是的,cache[int64]才是一个真正可用的完整的类型,未指定类型参数的泛型类型cache是抽象的。这样看来,C++中把泛型称为模板也是有道理的,泛型类或函数就是个模板,编译器根据实际用到的类型参数进行代码生成。

使用泛型以后,cache底层map的元素类型直接就是我们指定的类型参数,上例中就是int64类型,这一点可以很容易的用反射来验证:

t := reflect.TypeOf(c)
println(t.Elem().String())

这段代码会输出字符串“int64”,所以这就达成了我们前面所期望的最高效的存储结构,泛型真的解决了我们前面的痛点,提高了程序的效率。但是使用泛型也不是没有代价的,最直接的就是可执行文件的大小可能会有所增加,因为编译器会为使用同一套模板的每个类型参数都生成一套代码,比起interface{}的那一套代码,自然会占用更多的空间。

5. 有了泛型还需要interface{}吗?

泛型并不会替代interface{},其实两者的适用场景根本不同。泛型本质上是编译阶段的代码生成,而interface{}主要用来实现语言的动态特性,这个我们在讲接口的时候已经深入的分析过了。即使不考虑这些,还是针对上面的cache类型来讲,如果你希望在一个缓存对象里存放多种不同类型的值,你还是需要interface{},只是直接这样写目前编译不通过:

var c cache[interface{}]

你需要基于interface{}自定义一个类型就可以了:

type T interface{}
var c cache[T]

这样你就会得到一个cache[T]类型,本质上还是cache[interface{}]。

6. Go1.17的泛型为什么不能从本包导出?

笔者认为应该是构建流程方面的问题,之前的构建流程是各个包先分别编译,期间编译器把需要自动生成的wrapper都生成出来,各个包之间的代码生成互不影响,编译完成之后再由链接器链接生成可执行文件。

但是有了泛型以后就不是这么简单了,假如说泛型类型X定义在包a中,而包b和c用到了该类型。在编译包a的时候,X只是个抽象类型,不能生成代码。直到编译包b和c的时候才有了基于X的具体类型被定义出来,但是生成的代码应该被放在哪里呢?以及如何保证唯一性等问题,都要经过重新设计,而这一切现在还没有完成。而只在本包中使用就没有这些问题了,所以现阶段还不能允许导出泛型相关的类型和函数。

发布于 2021-09-18 08:29

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值