golang|golang泛型介绍

什么是泛型 首先来说说什么是泛型,泛型其实是一个很宽泛的概念。本文中的泛型特指计算机编程语言中的泛型, 即编程语言中的函数,方法,类定义等与特定的类型参数无关,相关的函数,方法和类的实例化是根据具体的调用参数来进行。泛型是和编译紧密相关的技术,尤其是针对 golang 这种静态类型的语言来说,更是如此。
为什么要实现泛型 作为一种提高编程效能的基本范式,目前市面上主流的编程语言都已经早早支持了泛型,比如 C++ 的 template, Java 的类型擦除技术等,其主要目的都是为了最大限度的避免重复代码,利用编译器来做类型检查,避免额外的装箱/拆箱等操作,来提高程序的性能。泛型最常用的地方就是集合类库,使得集合类针对不同的类型能够复用,并且最大化开发者的费效比,减少维护成本。
泛型可以为我们提供强大的构建块,让我们可以更轻松地共享代码和构建程序。泛型编程意味着编写函数和数据结构,其中一些类型留待以后指定。例如,我们可以编写一个对某种任意数据类型的切片进行操作的函数,其中实际数据类型仅在调用该函数时指定。或者,您可以定义存储任何类型值的数据结构,在创建数据结构的实例时指定要存储的实际类型。
Golang 泛型现状 go1.17之前,社区之前也有很多的努力和尝试,包括各种泛型提案和实现方式,但最后都被否决了。
【golang|golang泛型介绍】Golang 核心作者给出的解释是泛型并不是不可或缺的特性。属于重要但不紧急,应该把精力集中在更重要的事情上,例如 GC 的延迟优化,编译器自举等。
go1.17中接受了社区的提议,具体细节在这https://github.com/golang/go/issues/43651
go1.18beta 于 2021年12月15日发布,引入了泛型。1.18正式版本计划2022年2月发布。
go 1.18beta1是第一个包含Go对使用参数化类型的泛型代码的新支持。从Go 1 发布以来,泛型是Go最重要的变化,也是1.x版本之后给你带来的最大的单一语言更改。
golang 泛型示例 环境准备
安装go1.18beta1,下载地址: https://golang.google.cn/dl/。
安装后同步设置环境变量 GOROOT,GOBIN。
通过内置的约束来实现
使用any约束,实现一个简单的函数:

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

这里的[T any]即为类型参数,意思是该函数支持任何类型的 slice 。但是在调用该函数的时候,需要显式指定类型参数类型。类型在调用出需要显式指定,以便编译器可以推断出实际的类型。
Print([]string{"Hello, ", "World\n"})

显式指定类型调用:
Print[string]([]string{"One, ", "Two\n"})

使用 comparable 约束类型。 comparable 是一个编译器内置的特定的扩展接口类型,该类型必须支持“==“ 方法。
使用comparable约束实现Sum:
func SumIntsOrFloats[K comparable, V int | float64](m map[K]V) V { var s V for _, v := range m { s += v } return s }

对其调用:
ints := map[string]int{ "first":34, "second": 12, }floats := map[string]float64{ "first":35.98, "second": 26.99, } fmt.Printf("主动传入类型,Ints=%v Floats=%v\n", SumIntsOrFloats[string, int](ints), SumIntsOrFloats[string, float64](floats)) fmt.Printf("Ints=%v Floats=%v\n", SumIntsOrFloats(ints), SumIntsOrFloats(floats))

对于不可诊断类型的情况,必须主动传入类型。
比如以下例子:
func FooZero[T any]() T { var a T return a }

只有第二种调用合法:
// d := FooZero() //illegal d := FooZero[int]() fmt.Printf("d=%v\n", d)

多个类型的泛型
如果实现多个类型的泛型,如果在函数声明处写[V int|int32|int64|…]先得很繁琐。
go提供了一种用法:
type Number interface{ int | int32 | int64 | float64 | float32 }

泛型声明可以写成:
func SumNumbers[K comparable, V Number](m map[K]V) V { var s V for _, v := range m { s += v } return s }

调用可以传入多种类型匹配:
ints := map[string]int{ "first":34, "second": 12, }floats := map[string]float64{ "first":35.98, "second": 26.99, } fmt.Printf("Sum with Constraint Ints=%v, Floats=%v\n",SumNumbers(ints),SumNumbers(floats))

map,slice,struct的泛型支持
go泛型支持用于map,slice,struct等。可以看以下例子:
type KvMap[K comparable, V Number] map[K]V func (kv KvMap[K,V]) Set(k K, v V) (KvMap[K,V]) { kv[k] = v return kv }type Slice[V Number] []V func (s Slice[V]) Append(v V) (Slice[V]) { s = append(s, v) return s }type Kv [Vt Number] struct { K string V Vt }

调用:
var kv1 = Kv[int]{K:"kv1",V:1} fmt.Printf("kv1=%v \n", kv1) var kvm1 = make(KvMap[int,float32]) kvm1[10] = 20.0 fmt.Printf("kvm1=%v \n", kvm1) var s1 = make(Slice[float64], 2) kvm1[1] = 30.0 fmt.Printf("s1=%v \n", s1) var s2 = make(Slice[int64], 1) s2 = s2.Append(2) fmt.Printf("s2=%v \n", s2)

方括号的歧义
go的泛型,对类型的传参使用方括号[],而不是<>()。对此,golang团队的提议中,赞成使用方括号的较多。方括号有更好的可读性。具体讨论在:https://groups.google.com/g/golang-nuts/c/7t-Q2vt60J8/m/65D5xBDvBgAJ
使用方括号在某些情况也会引发歧义。一般认为方括号是map或slice的索引或下标。
对于我们上面定义的Number2泛型,我们可以在函数内重复定义为新的变量,以下代码对Add的调用,和泛型方式对Add调用写法一致,且可以编译通过:
Number2 := int32(2) Add := make(map[int32]func(a, b int) int, 0) Add[2] = func(a, b int)int{return a*a+b*b} e := Add[Number2](3,4)// ok fmt.Printf("Add=%v e=%v\n", Add, e)

我们用新定义的名称,覆盖了泛型定义的类型名称。这在go语言中是允许的。对于上述歧义情况,开发者可以主动检查,避免以上情况发生。
泛型与interface 虽然 Go 重用接口的概念来实现泛型非常好,但它确实导致了一些混乱。问题是:什么时候使用泛型,什么时候使用接口?
现在还很早,所以模式仍在开发中。有一些可能会遵循的基本原则。第一个原则是什么都不做。如果您当前的代码适用于接口,请不要管它。
如果您有容器类型,请考虑在可用时切换到泛型。为需要反射的情况保留interface{}
如果您一直在编写函数的多个实现来处理不同的数字类型或切片类型,请切换到泛型。
如果要编写创建新实例的函数或方法,则需要使用泛型。
人们问的下一个问题是关于性能的。答案是:暂时不要担心。当前的原型工具正在使用一种不会在任何生产版本中使用的技术(将通用 Go 代码重写为标准 Go 代码)。有多种方法可以编译和实现泛型。一旦有了最终的工具,我们将能够看到权衡是什么。大多数程序可能不会有显着差异。
any关键字 定义泛型函数或类型时,输入类型必须有一个约束。类型约束可以是接口(例如Stringer)、类型列表(例如constraints.Ordered)或关键字comparable。但是如果你真的不想要任何约束怎么办?也就是说,实际上任何类型T?
表达这一点的合乎逻辑的方法是使用interface{}(该接口对类型的方法集完全没有说明)。但由于这是一个如此常见的约束,所以预先声明的名称any作为interface{}.
为清楚起见,any在引用类型约束以及interface{}声明普通参数或变量时使用它是一个好主意。
golang泛型实现机制 通常,把高级语言编译成机器本地可以执行的汇编代码,大致需要进行词法分析,语法分析,语义分析,生成中间代码,优化,以及最终生成目标代码等几个步骤。其中词法分析,语法分析,语义分析属于前端,而 golang 支持泛型只是前端的改动,本质上是语法糖。例如词法分析器要能正确解析泛型新引入的’[’ ‘]’ 括号,语法分析器能正确识别并判断代码是否符合泛型的语法规则,并构造正确的语法树 AST。而到了语义分析阶段,编译器需要能根据前面提到的类型参数和接口限制,来正确的推导出参数的实际类型,检查类型是否实现了相关接口定义的方法,实例化支持特定类型的函数,以及进行函数调用的类型检查等等。
幸运的是,golang 团队已经给我们提供了两种途径来预先感受下泛型新特性,一种是通过https://go2goplay.golang.org/ 网站,用户可以在上面写合法的泛型代码,并编译执行,但是可能需要,且没有太多编译细节,这里不展开。
我们重点讲下通过本地下载编译 go2go 工具来编译泛型代码。具体的 go2go 工具的编译过程,可以参考这篇文档, https://golang.org/doc/install/source。(使用go源码分支dev.go2go)
下面我们来编译一个最基本的泛型示例代码,内容如下:
import( "fmt" )func Print[T any](s []T) { for _, v := range s { fmt.Println(v) } }func main(){ Print([]string{"Hello, ", "World\n"}) }

输入命令:
go tool go2go translate typeparam_basic.go2

注意 go2go 工具目前只支持.go2 后缀的源码文件。
编译完成后,我们看代码长这个样子:
// Code generated by go2go; DO NOT EDIT.//line /Users/abc/work/go_generics_demo/typeparam_basic.go2:1 package main//line /Users/abc/work/go_generics_demo/typeparam_basic.go2:1 import "fmt"//line /Users/abc/work/go_generics_demo/typeparam_basic.go2:13 func main() { //line /Users/abc/work/go_generics_demo/typeparam_basic.go2:13 instantiate??Print?string([]string{"Hello, ", "World\n"}) //line /Users/abc/work/go_generics_demo/typeparam_basic.go2:15 } //line /Users/abc/work/go_generics_demo/typeparam_basic.go2:7 func instantiate??Print?string(s []string,) { for _, v := range s { fmt.Println(v) } }//line /Users/abc/work/go_generics_demo/typeparam_basic.go2:11 type Importable? int//line /Users/abc/work/go_generics_demo/typeparam_basic.go2:11 var _ = fmt.Errorf

可以看到工具已经自动为我们插入注释,并且实例化了一个支持 string slice 类型的函数,且为了避免和已有代码中的其它函数重名,造成错误,工具引入了两个不常用的 Unicode 字符,并插入到实例化的函数名称中,最后工具把生成的代码,重新命名为.go 后缀的文件,并写到文件系统。接下来我们就可以正常的编译执行生成的.go 代码。
进一步的,我们可以通过编译 debug go2go 的源码,来看看究竟工具如何做这些做事情的,通过 debug go2go 工具,我们发现,其实 go2go 帮我们把使用泛型的 golang 代码,通过重写 AST 的方式,转换成 go 1.x 版本的代码, 如下所示:
// rewriteAST rewrites the AST for a file. func rewriteAST(fset *token.FileSet, importer *Importer, importPath string, tpkg *types.Package, file *ast.File, addImportableName bool) (err error) { t := translator{ fset:fset, importer:importer, tpkg:tpkg, types:make(map[ast.Expr]types.Type), typePackages: make(map[*types.Package]bool), } t.translate(file) // Add all the transitive imports. This is more than we need, // but we're not trying to be elegant here. imps := make(map[string]bool) for _, p := range importer.transitiveImports(importPath) { imps[p] = true } for pkg := range t.typePackages { ......

上面的 AST 转换工具相关的代码和思路应该会被正式的 golang 编译器实现所借鉴。
泛型实现比较
说明 C++ C# java Go
实现 使用宏生成对应类型的类/函数代码 生成中间语言IL,运行时创建类型的专用类 编译擦拭法,泛型当作object处理,只有一个类型 编译为代码中所有类型的具体函数
实际类型数量 编译后,所有代码引用的类型 所有的引用类型的泛型实例共享一个模板,而为一个不同的值类型,产生独立的代码。 只有一个类型 代码中引用的所有类型
类型支持范围 类,虚拟类,接口,虚拟接口,函数参数 类,接口,委托,结构以及方法成员; 类,函数,接口 支持函数,结构体,map,slice
优点 无运行时负担,运行效率快。C++模板基于签名的隐式约束,灵活性高。 1不会导致C++中代码膨胀的问题;2因为是JIT编译时实例化,可以应用于反射;3可以使用泛型参数约束来实现对类型参数的显式约束;4类型安全,不用向下转换,尤其是装箱拆箱操作。 不会导致代码膨胀 不影响运行效率。类型安全,编译期检查。
缺点 会导致代码膨胀。 无相关资料 只能使用参数Object的接口,对泛型支持比较弱;运行时生成类,效率较低。 会导致代码膨胀
总结: 由于 golang 泛型的实现涉及到编译器端的诸多技术细节和语言的历史背景,本人不可能也没有能力通过短短一篇文章把所有的方面讲解清楚,目前社区通过多个分支并行开发来提供支持,感兴趣的读者可以自行下载源码阅读研究。
泛型代码适用的范围主要在集合,数学库,以及一些通用的算法和框架类库中, 滥用泛型会增加代码的编写和维护成本,得不偿失。最后,我们用 golang 编译器核心作者之一 Robert Griesemer 的话来总结本文: “泛型是带类型检查的宏指令,使用宏指令前请三思”。
参考 infoQ go泛型原理
go doc
go 泛型start
go 泛型原理
go 官方slice使用泛型
capitalone go generics

    推荐阅读