在Golang的v1.17版本中,已经悄悄的加入了对泛型的支持;
在此之前,我们需要在.go2中尝试泛型,现在我们可以直接编写.go格式的文件,然后通过指定-gcflags=-G=3来编译含有泛型语法的源文件了;
同时,为了防止在体验时污染我们本地开发环境中的Go,采用了在Docker中运行的方式进行实验;
源代码:
在Docker中体验Go1.17中的泛型
关于Go泛型的开发进度
根据网络谣传的消息 Go 将在1.18 中正式支持泛型,而依照Go每年发布两个版本的节奏,Go 1.18会在2022年春,也就是二月份左右发布,但是我们目前已经可以在Go 1.17中尝鲜;
Go的泛型近一年来一直由Ian Lance Taylor(编译器大牛、Go核心成员)和Robert Griesemer(Go创始三巨头之一,Google V8、Chubby和HotSpot JVM的主要贡献者)等人开发,一直在dev.typeparams分支上做预研和开发工作;
但是由于泛型会对Go的核心代码做巨大的修改,会影响很多文件,因此将来dev.typeparams在merge回master分支的时候如何管理冲突是一个很困难的事;
目前,Go开发者采用定期把master的commit merge到dev.typeparams分支方式,尽早解决冲突:因为尽早解决冲突的方式每次解决冲突的量比较少,还是可控的;
但是由于master分支的开发者无需顾虑dev.typeparams分支的情况,导致最近冲突有些大,解决起来很困难,所以#43931提议将dev.typeparams分支merge到master分支,以后泛型的开发直接在master分支;
泛型特性可以通过feature flag的方式控制特性的启用和禁用,所以即使merge到master分支,也可以控制在Go 1.18再把特性启用;如果泛型的提案#43651被拒绝掉的话,相关的无用代码也可以被清除掉;
这也类似引入go module的方式:
go module历经几个版本,并通过GO111MODULE开关控制这个特性的启用;
比如引入-G开关:
-G=0启用类型检查;-G=1启用types2 w/o generics支持;-G=2使用types2 w/ generics支持;
而-G=1在Go 1.17中默认启用,-G=2在Go 1.18中启用;
这也是很好的一种渐进式引入特性的方法;
而现在我们已经可以在Go v1.17中通过指定-gcflags=-G=3来编译含有泛型语法的源文件了!
Go中的泛型
2019年中旬,在Go 1.13版本发布前夕的GopherCon 2019大会上,Ian Lance Taylor代表Go核心团队做了有关Go泛型进展的介绍;
自那以后,Go团队对原先的Go Generics技术草案做了进一步精化,并编写了相关工具让社区gopher体验满足这份设计的Go generics语法,返回建议和意见;
经过一年多的思考、讨论、反馈与实践,Go核心团队决定在这份旧设计的基础上另起炉灶,撰写了一份Go Generics的新技术提案:“Type Parameters”;
与上一份提案最大的不同在于:使用扩展的interface类型替代“Contract”用于对类型参数的约束;
Parametric Polymorphism((形式)参数多态)是Go此版泛型设计的基本思想:
和Go设计思想一致,这种参数多态并不是通过像面向对象语言那种子类型的层次体系实现的,而是通过显式定义结构化的约束实现的;基于这种设计思想,该设计不支持模板元编程(template metaprogramming)和编译期运算;
注意:虽然都称为泛型(generics),但是Go中的泛型(generics)仅是用于狭义地表达带有类型参数(type parameter)的函数或类型,这与其他编程语言中的泛型(generics)在含义上有相似性,但不完全相同;
本文主要采用的是go1.17beta1版本进行讲述,而最终加入Go的泛型可能与目前的实现有所差异;
下面,首先让我们创建一个go1.17beta1环境的容器;
随后,通过几个泛型的例子来学习Go中的泛型!
Docker中创建Go环境容器
为了不影响本地的Go环境,我选择了在Docker中创建一个go1.17beta1环境的容器来体验泛型;
go1.17beta1是目前官方提供的最新的Go环境;可能你在阅读本文的时候,还有更新的版本可以选择,但是原理都是类似的!
一键创建Go环境容器:
create-container.sh
1 | docker run -dit \ |
然后你的go1.17beta1环境就有了;
通过下面的命令即可进入容器:
create-container.sh
1 | [root@localhost go-v1.17-code]# docker exec -it go-v.17 bash |
注:这里主要是将本地的
/root/workspace/go-v1.17-code作为工作目录,并映射到容器的/code目录中,你可以根据自己的需求修改路径;
下面我们重点来看Go中的泛型;
几个泛型例子
Print泛型输出 - 范型基本语法
我们先通过一个范型版的Print来看一看Go目前范型的语法吧;
1_print/main.go
1 | package main |
运行文件:
1 | go run -gcflags=-G=3 1_print/main.go |
可以看到成功输出了不同类型的列表;
代码中的[T any]即为类型参数,意思是该函数支持任何T类型;
在调用该范型函数时,可以显式指定类型参数类型,如:printSlice[int]([]int{1, 2, 3, 4, 5}),以帮助编译器实行类型推导;
不过在编译器完全可以实现类型推导时,也可以省略显式类型,如:printSlice([]int64{5, 4, 3, 2, 1});
目前Go中实现的范型声明采用
[]表示,这一点既不同于传统范型的<>符号,又不同于Go最初制定的()运算符声明;关于Go为什么不用
()运算符声明,我觉得可能是因为再使用()运算符的话,小括号真的是太多了!
范型切片 - 声明范型类型
带有类型参数(type parameters)的类型被称为泛型类型;
比如我们定义一个底层类型为切片类型的新类型 Vector:
2_vector/main.go
1 | package main |
首先,我们声明了一个可以存放任何类型的切片 vector:
1 | type vector[T any] []T |
该vector(切片)类型中的元素类型为T;
和泛型函数一样,使用泛型类型时,我们首先要对其进行实例化,即显式为类型参数赋一个实参值(一个类型名),因此在main函数中,我们初始化vector是需要带上实际类型!
泛型类型的实例化是必须显式为类型参数传参的,编译器无法自行做类型推导;
如果将上面例子中main函数改为如下实现方式:
1 vs0 := vector{1,2,3,4,5}Go编译器会报如下错误:
1
2 // Compiling Error
// Cannot use generic type vector[T interface{}] without instantiation即:未实例化(instantiation)的泛型类型Vector(type T)无法使用;
范型版Add函数 - 类型约束
对于一个范型的Add函数而言,下面的实现一定是报错的:
1 | func add[T any] (a, b T) T { |
因为在Go中,我们没有为任意类型都定义+运算!
在此版Go泛型设计中,泛型函数只能使用类型参数所能实例化出的任意类型都能支持的操作;
比如上述Add函数的类型参数T没有任何约束,它可以被实例化为任何类型;那么这些实例化后的类型是否都支持“+”操作符运算呢?显然不是;因此,编译器针对示例代码中的第六行报了错!
对于像上面Add函数那样的没有任何约束的类型参数实例,Go允许对其进行的操作包括:
- 声明这些类型的变量;
- 使用相同类型的值为这些变量赋值;
- 将这些类型的变量以实参形式传给函数或从作为函数返回值;
- 取这些变量的地址;
- 将这些类型的值转换或赋值给interface{}类型变量;
- 通过类型断言将一个接口值赋值给这类类型的变量;
- 在type switch块中作为一个case分支;
- 定义和使用由该类型组成的复合类型,比如:元素类型为该类型的切片;
- 将该类型传递给一些内置函数,比如new;
那么,我们要让上面的Add函数通过编译器的检查,我们就需要限制其类型参数所能实例化出的类型的范围;
比如:仅允许实例化为底层类型(underlying type)为整型类型的类型;
上一版Go泛型设计中使用Contract来定义对类型参数的约束,不过由于Contract与interface在概念范畴上有交集,让Gopher们十分困惑;
于是在新版泛型设计中,Contract这个关键字被移除了,取而代之的是语法扩展了的interface,即我们使用interface类型来修饰类型参数以实现对其可实例化出的类型集合的约束;
我们来看下面例子:
3_add/main.go
1 | package main |
运行代码输出:
1 | bash-5.0# go run -gcflags=-G=3 3_add/main.go |
该提案扩展了interface语法,新增了类型列表(type list)表达方式,专用于对类型参数进行约束;
以该示例为例,如果编译器通过类型推导得到的类型在Addable这个接口定义的类型列表(type list)中,那么编译器将允许这个类型参数实例化;否则就像类型参数实例化将报错!
例如,我们删去Addable中的string类型,重新运行代码,将会报错:
1 | 3_add/main.go:23:17: string does not satisfy Addable (string not found in int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, uintptr, float32, float64, complex64, complex128) |
此外,需要注意的是:定义中带有类型列表的接口将无法用作接口变量类型!
比如下面这个示例:
1 | package main |
运行后报错:
1 | 3_add/main.go:22:8: interface contains type constraints (int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, uintptr, float32, float64, complex64, complex128, string) |
Stringer - 使用interface本身约束范型
除了在interface中显式声明类型约束之外,还可以在函数的入参中使用接口对范型进行类型约束,这一点是很自然的!
如下:
4_interface_constraint/main.go
1 | package main |
代码中我们声明了MyStringer接口,并且使用StringInt类型实现了此接口;
在范型方法中,我们声明了范型的类型为:任意实现了MyStringer接口的类型;
运行该示例输出:
1 | [1 2 3 4 5] |
如果我们在main函数中写下如下代码:
1 | func main() { |
那么我们将得到下面的编译器错误输出:
1 | 4_interface_constraint/main.go:28:23: int does not satisfy MyStringer (missing method String) |
我们看到:只有实现了Stringer接口的类型才会被允许作为实参传递给Stringify泛型函数的类型参数并成功实例化!
MyStringer2.0 - 类型列表和方法列表双重约束
我们还可以结合interface的类型列表(type list)和方法列表一起对类型参数进行约束,看下面示例:
5_signed_interface_constraint/main.go
1 | package main |
在该示例中,用于对泛型函数的类型参数进行约束的MySignedStringer接口既包含了类型列表,也包含方法列表,这样类型参数的实参类型既要在MySignedStringer的类型列表中,也要实现了MySignedStringer的String方法!
如果我们将上面的StringInt的底层类型改为uint:
1 | type StringInt uint |
那么我们将得到下面的编译器错误输出:
1 | StringInt does not satisfy MySignedStringer (uint not found in int, int8, int16, int32, int64) type StringInt uint |
查找index函数 - comparable内置比较类型
由于Go泛型设计选择了不支持运算操作符重载,因此,我们即便对interface做了语法扩展,依然无法表达类型是否支持==和!=;
为了解决这个表达问题,新设计提案中引入了一个新的预定义类型约束:comparable;
我们看下面例子:
6_comparable/main.go
1 | package main |
运行该示例:
1 | 2 |
我们看到Go的原生支持比较的类型,诸如整型、字符串以及由这些类型组成的复合类型(如结构体)均可以直接作为实参传给由comparable约束的类型参数;
comparable可以看成一个由Go编译器特殊处理的、包含由所有内置可比较类型组成的type list的interface类型;
因此我们可以将其嵌入到其他作为约束的接口类型定义中:
1 | type ComparableStringer interface { |
此时,只有支持比较的类型且实现了String方法,才能满足ComparableStringer的约束;
实现范型Set类型 - 声明范型方法
和对泛型函数中类型参数的约束方法一样,我们也可以对泛型类型的方法以同样方法做同样的约束;
下面我们使用范型实现一个Set集合:
7_generic_method/main.go
1 | // Package set implements sets of any comparable type. |
在这个示例中我们定义了一个数据结构:Set;
该Set中的元素是有约束的:必须支持可比较!对应到代码中,我们用comparable作为泛型类型Set的类型参数的约束;
随后,我们定义了几个常用方法:
- add
- contains
- len
- delete
- iterate
运行该示例:
1 | map[1:{} 11:{} 111:{} 1111:{} 11111:{}] |
范型队列 - 声明范型引用方法
下面是一个队列,我们通过应用实现了这个队列的方法:
8_generic_reference_method/main.go
1 | package main |
可以看到,实现范型方法和通常Go的方法基本一致!
一些其他范型例子
值生成器
代码如下:
9_others/2_generator/main.go
1 | package main |
输出如下:
1 | 0 |
实现函数式编程map、reduce、filter
9_others/map/main.go
1 | package main |
运行输出:
1 | [<1> <2> <3> <4> <5> <6>] |
9_others/reduce/main.go
1 | package main |
运行输出:
1 | 56 |
9_others/filter/main.go
1 | package main |
运行输出:
1 | [1 2 3] |
更多例子,见:
Golang 泛型的实现机制
通常,把高级语言编译成机器本地可以执行的汇编代码,大致需要进行词法分析,语法分析,语义分析,生成中间代码,优化,以及最终生成目标代码等几个步骤;
其中词法分析,语法分析,语义分析属于前端,而 golang 支持的泛型只是前端的改动,本质上是语法糖;
例如词法分析器要能正确解析泛型新引入的[]括号,语法分析器能正确识别并判断代码是否符合泛型的语法规则,并构造正确的语法树 AST;
而到了语义分析阶段,编译器需要能根据前面提到的类型参数和接口限制,来正确的推导出参数的实际类型,检查类型是否实现了相关接口定义的方法,实例化支持特定类型的函数,以及进行函数调用的类型检查等等;
我们可以使用编译 go2go 工具来编译泛型代码;
具体的 go2go 工具的编译过程,可以参考这篇文档:
下面我们来编译一个最基本的泛型示例代码,内容如下:
1 | import( |
编译完成后,我们看代码长这个样子:
1 | package main |
可以看到编译后的代码实例化了一个支持 string slice 类型的函数,且为了避免和已有代码中的其它函数重名,造成错误,工具引入了两个不常用的 Unicode 字符,并插入到实例化的函数名称中,最后工具把生成的代码,重新命名为.go 后缀的文件,并写到文件系统;
接下来我们就可以正常的编译执行生成的.go 代码!
进一步的,我们可以通过编译 debug go2go 的源码,来看看究竟工具如何做这些做事情的;
通过 debug go2go 工具,我们发现,其实 go2go 帮我们把使用泛型的 golang 代码,通过重写 AST 的方式,转换成 go 1.x 版本的代码, 如下所示:
1 | // rewriteAST rewrites the AST for a file. |
上面的 AST 转换工具相关的代码和思路应该会被正式的 golang 编译器实现所借鉴;
性能影响
根据这份技术提案中关于泛型函数和泛型类型实现的说明,Go会使用基于接口的方法来编译泛型函数(generic function),这将优化编译时间,因为该函数仅会被编译一次,但是会有一些运行时代价;
对于每个类型参数集,泛型类型(generic type)可能会进行多次编译,这将延长编译时间,但是不会产生任何运行时代价,编译器还可以选择使用类似于接口类型的方法来实现泛型类型,使用专用方法访问依赖于类型参数的每个元素;
小结
Go泛型方案的即将定型即好也不好。Go向来以简洁著称,增加泛型,无论采用什么技术方案,都会增加Go的复杂性,提升其学习门槛,代码可读性也会下降;
但在某些场合(比如实现container数据结构及对应算法库等),使用泛型却又能简化实现;
在这份提案中,Go核心团队也给出如下期望:
1 | We expect that most packages will not define generic types or functions, but many packages are likely to use generic types or functions defined elsewhere |
并且提案提到了会在Go标准库中增加一些新包,已实现基于泛型的标准数据结构(slice、map、chan、math、list/ring等)、算法(sort、interator)等,gopher们只需调用这些包提供的API即可;
另外该提案的一大优点就是与Go1兼容,因此我们可能永远不会使用Go2这个版本号了;
由于 golang 泛型的实现涉及到编译器前端的诸多技术细节和语言的历史背景,本人不可能也没有能力通过短短一篇文章把所有的方面讲解清楚,目前社区通过多个分支并行开发来提供支持,感兴趣的读者可以自行下载源码阅读研究,本文主要是抛砖引玉,期望有更多的读者参与到开源技术的研究和推广中来并与大家分享;
泛型代码适用的范围主要在集合,数学库,以及一些通用的算法和框架类库中, 滥用泛型会增加代码的编写和维护成本,得不偿失;
最后,用 golang 编译器核心作者之一 Robert Griesemer 的话来总结本文: “泛型是带类型检查的宏指令,使用宏指令前请三思”。
附录
源代码:
文章参考:
- Go 言語にやってくる Generics は我々に何をもたらすのか
- https://github.com/mattn/go-generics-example
- 利好!极大可能在go 1.17中就能尝试泛型
- Go泛型新方案详解!Go泛型真的要来了,最早在Go 1.17版本支持