Why generics in Go?How?
Contents
以下全文大部分翻译自go官方blog: Why Generics?。但是由于原文的泛型示例已经过时,所以译者在 最新的泛型示例 章节中尽量使用新的泛型语法重写了原作的例子,对不合适的示例进行了替换。
Why generics?
添加泛型意味着什么,我们为什么需要它呢?
套用Jazayeri等人的话:泛型编程支持以泛型形式表示函数和数据结构,并考虑类型。
什么意思?我们举一个简单的例子:反转slice中的元素。这不是很多程序需要做的事情,但也不是那么不寻常。
下面是int slice反转:
|
|
然后是string slice反转:
|
|
可以看到ReverseInts和ReverseStrings几乎一摸一样,除了参数类型。对于这种情况gopher们肯定已经习以为常了。
然而对于gopher新人,会发现没有办法为各种类型的slice写一个Reverse方法。这在大部分类型的编程语言是可以做到的。
在动态类型语言如python和javascript中,实现这个方法很简单,根本不用关心参数类型。这样在go中是行不通的,因为go是静态类型语言,必须指定参数的类型。
大部分静态类型语言,如c++或者java、rust、swift等,支持泛型来解决这类问题。
当前go编程中的泛型
所以大家在go中如何编写这类代码?
我们可以编写一个接受接口类型参数的方法,然后为各种类型的slice添加接口约定的方法。标准库中的sort.Sort就是这么做的。换句话说,go中的接口也是泛型编程的一种方式,它允许我们提取不同类型的公共点并使用方法表达。我们可以编写使用那些接口类型作为参数的方法,任何实现了该接口的类型都可以使用这个方法。
但是这种方法没有达到我们的要求。使用接口,我们必须为各种类型编写接口约定的方法。为了一个简单的Reverse方法,我们需要为各种类型的slice编写接口约定的方法,而且这些方法内基本都是一样的代码。所以我们并没有完全解决重复代码的问题。尽管接口是泛型编程的一种方式,但是还不是完整的泛型。
另外一种使用接口实现泛型的方法,可以解决需要我们自己编写接口方法的问题,但是需要语言内置了相应的方法。目前go并不支持这些内置方法,我们假设go为各种类型的slice内置了Index方法,Index接受一个下标参数,返回slice中的元素。如果想要使用这个内置方法,这个内置方法就必须返回一个interface{}类型,这又使得go失去了静态语言的优势。
使用反射也能够写一个通用的Reverse方法,但是反射不但写起来繁琐,而且还会影响运行性能。
另外我们也可以通过代码生成,为不同类型的slice生成各自的Reverse方法。但是这使得构建工作变得复杂了,每个引用Reverse的包都需要对生成代码进行编译,生成代码的bug修复,需要重新编译所有引用方,这些引用方可能存在于不同的项目中。
对于Reverse这种简单的方法,以上那些方式还不如我们开头写的那样,为不同类型slice单独编写一个Reverse。但是我们需要为这些不同的Reverse编写单独的单元测试,然后挨个运行这些测试。
那么就没有办法解决了吗?当然不是!我们需要真正的泛型。
泛型可以为go带来些什么
首要的事情就是泛型可以让我们编写类似Reverse这样的方法,而且我们不需要关心slice中元素的类型。这样我们就可以解决重复代码、重复单元测试的问题,“write once, use anywhere”。
go中的泛型与C++的模版不完全相同,c++的模版支持的东西更多一些。
除了上面一直用作例子的Reverse,还有其他很多方法可以泛化,如下:
- 查找slice中最大、最小元素
- 计算slice中元素的平均值和标准差
- 计算map的交并集
- 查找图中的最短路径
- slice、map的转换方法
以上这些都是其他支持泛型语言常见的列子,还有一些与go并发编程相关的用途:
- 编写带有超时读取channel的方法
- 将两个channel合并为一个
- 并发调用函数列表,返回一个结果slice
- 使用Context调用函数列表,返回完成的第一个函数的结果,取消并清除其他的goroutine
像以上这些方法需要为不同的数据类型编写不同的方法。虽然并不难编写,但是如果可以复用同一个高效的方法就更好了。
上面说到的都是方法,数据结构也可以使用泛型。
Go在语言中内置了两种通用的泛型数据结构:slice和map。slice和map可以保存任何数据类型的value,这些value在保存和检索的时候使用静态类型检测。这些value存储为自身,并非interface类型。也就是[]int,这个slice中存储的就是int值,而不是转换为interface后的值。
slice和map是最有用的泛型数据结构,但他们不是唯一的。下面是一些其他的例子:
- Sets
- 平衡树
- Multimaps
- 支持并发读写的hashmap
如果我们可以使用泛型,我们就可以定义上面这些数据结构的泛型结构,具有和slice、map同样优势的类型检测:编译器可以静态地对这些结构中的value进行类型检测,value可以保存为自身,而非interface类型。
也可以将前面提到的算法,使用到泛型数据结构中。
这些示例都应该像Reverse:泛型方法和数据结构在一个包中编写一次,并在需要时重用。它们应该像slice和map一样工作,它们不应该存储接口类型的值,而应该存储特定类型,并且应该在编译时检查这些类型。
这就是go可以从泛型中获得的。泛型可以为我们提供强大的构建块,让我们更容易地共享代码和构建程序。
最新的泛型示例
以下示例使用go1.17运行,运行方法:在run或者build时增加-G=3编译参数,例如go run -gcflags=-G=3 main.go
首先我们看以下Reverse的泛型方法:
|
|
可以看到方法体内是完全一样的,只是方法的签名不同了。切片的元素类型已经从具体的数据类型变为了T,也就是在泛型中所说的类型参数,它不再是slice参数类型的一部分,而是一个单独的、附加的类型参数。调用时只需要将slice类型的变量直接传入reverse方法即可,编译器可以自动识别slice中的元素类型。
除了slice参数的元素类型变为了T,方法名之后还增加了[T any],any是对类型参数的约束,any代表T可以是任意数据类型。
类型参数约束(constraints)
由于go是静态类型语言,我们不得不讨论类型参数的类型。这个元类型告诉编译器泛型方法允许什么样的参数,以及泛型方法可以对类型参数的值执行哪些操作。
reverse方法允许任何类型的数据,因为它只是进行赋值操作,任何类型的数据都支持。我们看一个有些不同的方法:
|
|
当我们运行时会发现编译报错:nvalid operation: cannot compare v == e (operator == not defined on T)
,这是由于find方法中的类型参数约束是any,但是并不是所有数据类型都支持相等运算。将any改为comparable,万事大吉。comparable是go的内置约束类型,代表了可以做相等运算的数据类型(这与map中可以做key的数据类型相似,key也需要做相等运算)。
除了使用comparable等内置约束类型,我们也可以自定义约束类型,如下定义了Addable来约束可以做+运算的类型参数:
|
|
在go源码master分支上已经创建了内置的constraints包,包括Signed、Unsigned、Integer、Float、Complex、Ordered等约束。
类型参数的方法约束
我们除了可以使用数据类型来定义约束,也可以使用方法来定义约束,如下面的例子:
|
|
运行编译报错:v.String undefined (type bound for T has no method String)
,编译器告诉我们类型参数T没有String方法。我们定义一个需要String方法的约定Stringer,然后将类型参数的约定改为Stringer:
|
|
运行编译报错:int does not satisfy Stringer (missing method String)
,编译器告诉我们int没有String方法。我们为int定义一个新的类型并实现String方法:
|
|
这和接口类型很像,虽然是否实现了接口也是在编译期确定,但是接口类型调用哪个方法是在运行期,而泛型则是在编译器就确定了。
泛型数据结构
最后我们看一个泛型数据结构的例子:二叉树。本示例中二叉树有一个比较方法属性,所以对二叉树中的元素数据类型没有要求:
|
|
下面是如何创建这个二叉树。new方法需要一个比较方法:
|
|
find方法返回一个指针,指向包含v的位置,或者指向树中它应该放置的位置。
|
|
下面是检测树中是否包含指定元素的方法:
|
|
以下代码是插入一个元素的方法:
|
|
可以看到,除了类型参数T需要出现在它要出现的位置,其他部分几乎与些普通的go代码一样。下面代码是使用这个二叉树:
|
|
写泛型数据结构比写泛型方法要困难一些,因为我们需要在各处显示地写出类型参数。不过这也只是一种特别的语法,多练习,熟悉后就好了。
Author 刘玮
LastMod 2022-02-12