1. 泛型原理
泛型是从函数的形参和实参说起,假设我们有两个计算两数之和的函数
1 | func Add(a int, b int) int { |
这个函数很简单, 但是它有个问题——无法计算 int 类型之外的和。如果我们想计算浮点或者字符串的和该怎么办?解决方法之一就是像下面这样为不同类型定义不同的函数。
1 | func AddFloat32(a float32, b float32) float32 { |
可是除此之外还有没有更好的方法?答案是有的,我们可以来回顾下函数的 形参(parameter) 和 实参(argument) 这一基本概念:
1 | func Add(a int, b int) int { |
我们知道,函数的 形参(parameter) 只是类似占位符的东西并没有具体的值,只有我们调用函数传入实参(argument) 之后才有具体的值。
那么,如果我们将 形参 实参 这个概念延伸一下,给变量的类型也引入和类似形参实参的概念的话,问题就解决了:引入类型形参和类型实参,如下:
1 | // 假设 T 是类型形参,在定义函数时它的类型是不确定的,类似占位符 |
在上面的伪代码中,T 被成为类型形参,它不是具体的类型,在定义函数时类型并不确定。因为 T 的类型不确定,所以我们需要像函数的形参那样,在调用函数的时候再传入具体的类型,在这里被传入的具体类型被称为类型实参,伪代码如下:
1 | // [T=int]中的 int 是类型实参,代表着函数Add()定义中的类型形参 T 全都被 int 替换 |
通过引入 类型形参 和 类型实参 这两个概念,我们让一个函数获得了处理多种不同类型数据的能力,这种编程方式被称为 泛型编程。
可能你会已奇怪,我通过 Go 的 接口+反射 不也能实现这样的动态数据处理吗?是的,泛型能实现的功能通过接口+反射也基本能实现。但是使用过反射的人都知道反射机制有很多问题:
- 用起来麻烦
- 失去了编译时的类型检查,不仔细写容易出错
- 性能不太理想
而在泛型适用的时候,泛型能解决上面的问题。但也不意味着泛型是银弹,泛型有着自己的适用场景。在使用泛型时,请记住下面的原则:
如果你经常要分别为不同的类型写完全相同逻辑的代码,那么使用泛型将是最合适的选择
2. Go 的泛型
通过上面的伪代码,我们实际上已经对 Go 的泛型编程有了最初步也是最重要的认识—— 类型形参 和 类型实参。而 Go1.18 也是通过这种方式实现的泛型,但是单纯的形参实参是远远不能实现泛型编程的,所以 Go 还引入了非常多全新的概念:
- 类型形参 (Type parameter)
- 类型实参(Type argument)
- 类型形参列表( Type parameter list)
- 类型约束(Type constraint)
- 实例化(Instantiations)
- 泛型类型(Generic type)
- 泛型接收器(Generic receiver)
- 泛型函数(Generic function)
3. 类型形参、类型实参、类型约束和泛型类型
观察下面这个简单的例子:
1 | type IntSlice []int |
这里定义了一个新的类型 IntSlice
,它的底层类型是 []int
,理所当然只有 int 类型的切片能赋值给 IntSlice
类型的变量。
接下来如果我们想要定义一个可以容纳 float32
或 string
等其他类型的切片该怎么办?在没有泛型前是需要给每种类型都定义个新类型:
1 | type StringSlice []string |
但是这样做的问题显而易见,它们结构都是一样的只是成员类型不同就需要重新定义这么多新类型。那么有没有一个办法能只定义一个类型就能代表上面这所有的类型呢?答案是可以的,这时候就需要用到泛型了:
1 | type Slice[T int|float32|float64 ] []T |
不同于一般的类型定义,这里类型名称 Slice
后带了中括号:
T
就是上面介绍过的类型形参,在定义 Slice 类型的时候 T 代表的具体类型并不确定,类似一个占位符int|float32|float64
这部分被称为类型约束,中间 | 的意思是告诉编译器,类型形参 T 只可以接受 int 或 float32 或 float64 这三种类型的实参- 中括号里的
T int|float32|float64
这一整串因为定义了所有的类型形参(在这个例子中只有一个类型形参 T),所以我们称其为类型形参列表 - 这里新定义的类型名称叫
Slice[T]
这种类型定义的方式中带了类型形参,很明显和普通的类型定义不一样,所以我们将这种类型定义中带 类型形参 的类型,称之为 泛型类型。
泛型类型不能直接拿来使用,必须传入类型实参(Type argument) 将其确定为具体的类型之后才可使用。而传入类型实参确定具体类型的操作被称为 实例化(Instantiations) :
1 | // 这里传入了类型实参int,泛型类型Slice[T]被实例化为具体的类型 Slice[int] |
对于上面的例子,我们先给泛型类型 Slice[T]
传入了类型实参 int
,这样泛型类型就被实例化为了具体类型 Slice[int]
,被实例化之后的类型定义可近似视为如下:
1 | type Slice[int] []int // 定义了一个普通的类型 Slice[int] ,它的底层类型是 []int |
我们用实例化后的类型 Slice[int]
定义了一个新的变量 a
,这个变量可以存储 int 类型的切片。之后我们还用同样的方法实例化出了另一个类型 Slice[float32]
,并创建了变量 b
。
因为变量 a 和 b 就是具体的不同类型了(一个 Slice[int] ,一个 Slice[float32]),所以 a = b
这样不同类型之间的变量赋值是不允许的。
同时,因为 Slice[T] 的类型约束限定了只能使用 int 或 float32 或 float64 来实例化自己,所以 Slice[string]
这样使用 string 类型来实例化是错误的。
上面只是个最简单的例子,实际上类型形参的数量可以远远不止一个,如下:
1 | // MyMap类型定义了两个类型形参 KEY 和 VALUE。分别为两个形参指定了不同的类型约束 |
用上面的例子重新复习下各种概念的话:
- KEY 和 VALUE 是类型形参
int|string
是 KEY 的类型约束,float32|float64
是 VALUE 的类型约束KEY int|string, VALUE float32|float64
整个一串文本因为定义了所有形参所以被称为类型形参列表- Map[KEY, VALUE] 是泛型类型,类型的名字就叫 Map[KEY, VALUE]
var a MyMap[string, float64] = xx
中的 string 和 float64 是类型实参,用于分别替换 KEY 和 VALUE,实例化出了具体的类型MyMap[string, float64]
3.1 其他的泛型类型
所有类型定义都可使用类型形参,所以下面这种结构体以及接口的定义也可以使用类型形参:
1 | // 一个泛型类型的结构体。可用 int 或 sring 类型实例化 |
3.2 类型形参的互相套用
类型形参是可以互相套用的,如下
1 | type WowStruct[T int | float32, S []T] struct { |
这个例子看起来有点复杂且难以理解,但实际上只要记住一点:任何泛型类型都必须传入类型实参实例化才可以使用。所以我们这就尝试传入类型实参看看:
1 | var ws WowStruct[int, []int] |
上面的代码中,我们为 T 传入了实参 int
,然后因为 S 的定义是 []T
,所以 S 的实参自然是 []int
。经过实例化之后 WowStruct[T,S] 的定义类似如下:
1 | // 一个存储int类型切片,以及切片中最大、最小值的结构体 |
因为 S 的定义是 []T ,所以 T 一定决定了的话 S 的实参就不能随便乱传了,下面这样的代码是错误的:
1 | // 错误。S的定义是[]T,这里T传入了实参int, 所以S的实参应当为 []int 而不能是 []float32 |
3.3 泛型类型的套娃
泛型和普通的类型一样,可以互相嵌套定义出更加复杂的新类型,如下:
1 | // 先定义个泛型类型 Slice[T] |
3.4 泛型的语法错误
- 定义泛型类型的时候,基础类型不能只有类型形参,如下:
1 | // 错误,类型形参不能单独使用 |
- 当类型约束的一些写法会被编译器误认为是表达式时会报错,如下:
1 | //✗ 错误。T *int会被编译器误认为是表达式 T乘以int,而不是int指针 |
为了避免这种误解,解决办法就是给类型约束包上 interface{}
或加上逗号消除歧义(关于接口具体的用法会在后半篇提及)
1 | type NewType[T interface{*int}] []T |
因为上面逗号的用法限制比较大,这里推荐统一用 interface{} 解决问题
3.5 匿名结构体不支持泛型
我们有时候会经常用到匿名的结构体,并在定义好匿名结构体之后直接初始化:
1 | testCase := struct { |
那么匿名结构体能不能使用泛型呢?答案是不能,下面的用法是错误的:
1 | testCase := struct[T int|string] { |
4. 泛型 receiver
单纯的泛型类型实际上对开发来说用处并不大。但是如果将泛型类型和接下来要介绍的泛型 receiver 相结合的话,泛型就有了非常大的实用性了
我们知道,定义了新的普通类型之后可以给类型添加方法。那么可以给泛型类型添加方法吗?答案自然是可以的,如下:
1 | type MySlice[T int | float32] []T |
这个例子为泛型类型 MySlice[T]
添加了一个计算成员总和的方法 Sum()
。注意观察这个方法的定义:
- 首先看 receiver
(s MySlice[T])
,所以我们直接把类型名称MySlice[T]
写入了 receiver 中 - 然后方法的返回参数我们使用了类型形参 T (实际上如果有需要的话,方法的接收参数也可以实用类型形参)
- 在方法的定义中,我们也可以使用类型形参 T (在这个例子里,我们通过
var sum T
定义了一个新的变量sum
)
对于这个泛型类型 MySlice[T]
我们该如何使用?还记不记得之前强调过很多次的,泛型类型无论如何都需要先用类型实参实例化,所以用法如下:
1 | var s MySlice[int] = []int{1, 2, 3, 4} |
该如何理解上面的实例化?首先我们用类型实参 int 实例化了泛型类型 MySlice[T]
,所以泛型类型定义中的所有 T 都被替换为 int,最终我们可以把代码看作下面这样:
1 | type MySlice[int] []int // 实例化后的类型名叫 MyIntSlice[int] |
通过泛型 receiver,泛型的实用性一下子得到了巨大的扩展。在没有泛型之前如果想实现通用的数据结构,诸如:堆、栈、队列、链表之类的话,我们的选择只有两个:
- 为每种类型写一个实现
- 使用 接口+反射
通过泛型 receiver,泛型的实用性一下子得到了巨大的扩展。
5. 泛型函数
在介绍完泛型类型和泛型 receiver 之后,我们来介绍最后一个可以使用泛型的地方——泛型函数。有了上面的知识,写泛型函数也十分简单。假设我们想要写一个计算两个数之和的函数:
1 | func Add(a int, b int) int { |
这个函数理所当然只能计算 int 的和,而浮点的计算是不支持的。这时候我们可以像下面这样定义一个泛型函数使其支持浮点计算:
1 | func Add[T int | float32 | float64](a T, b T) T { |
上面就是泛型函数的定义,这种带类型形参的函数被称为 泛型函数。
它和普通函数的点不同在于函数名之后带了类型形参。这里的类型形参的意义、写法和用法因为与泛型类型是一模一样的,就不再赘述了。
和泛型类型一样,泛型函数也是不能直接调用的,要使用泛型函数的话必须传入类型实参之后才能调用。
1 | Add[int](1,2) // 传入类型实参int,计算结果为 3 |
或许你会觉得这样每次都要手动指定类型实参太不方便了。所以 Go 还支持类型实参的自动推导:
1 | Add(1, 2) // 1,2是int类型,编译请自动推导出类型实参T是int |
自动推导的写法就好像免去了传入实参的步骤一样,但请记住这仅仅只是编译器帮我们推导出了类型实参,实际上传入实参步骤还是发生了的。
5.1 匿名函数不支持泛型
在 Go 中我们经常会使用匿名函数,如:
1 | fn := func(a, b int) int { |
那么 Go 支不支持匿名泛型函数呢?答案是不能——匿名函数不能自己定义类型形参:
1 | // 错误,匿名函数不能自己定义类型实参 |
但是匿名函数可以使用别处定义好的类型实参,如:
1 | func MyFunc[T int | float32 | float64](a, b T) { |
5.2 不支持泛型方法
目前 Go 的方法并不支持泛型,如下:
1 | type A struct { |
但是因为 receiver 支持泛型, 所以如果想在方法中使用泛型的话,目前唯一的办法就是曲线救国,迂回地通过 receiver 使用类型形参:
1 | type A[T int | float32 | float64] struct { |
当方法接受者是泛型类型时,就可以使用 receiver 的方法,反之当方法的接收者是一个普通的 struct,则不支持泛型方法。
6. 引入泛型后接口发生的变化
在说泛型接口之前,先来看看引入泛型后对于接口的变化。
在使用泛型时,会书写长长的类型约束,如下:
1 | // 一个可以容纳所有int,uint以及浮点类型的泛型切片 |
这种写法是无法忍受也是难以维护的,而 Go 支持将类型约束单独拿出来定义到接口中,从而让代码个更容易维护:
1 | type IntUintFloat interface { |
这段代码把类型约束给单独拿出来,写入了接口类型 IntUintFloat
当中。需要指定类型约束的时候直接使用接口 IntUintFloat
即可。
不过这样的代码依旧不好维护,而接口和接口、接口和普通类型之间也是可以通过 |
进行组合:
1 | type Int interface { |
上面的代码中,分别定义了 Int
、Uint
、Float
三个接口类型,并最终在 Slice[T]
的类型约束中通过使用 | 来将它们组合到一起。
同时,在接口里也能直接组合其他接口,所以还可以像下面这样:
1 | type SliceElement interface { |
6.1 指定底层类型
上面定义的 Slie[T] 虽然可以达到目的,但是有一个缺点:
1 | var s1 Slice[int] // 正确 |
这里发生错误的原因是,泛型类型 Slice[T]
允许的是 int
作为类型实参,而不是 MyInt
。
虽然 MyInt 类型底层类型是 int ,但它依旧不是 int 类型
为了从根本上解决这个问题,Go 新增了一个符号 ~
,在类型约束中使用类似 ~int
这种写法的话,就代表着不光是 int ,所有以 int 为底层类型的类型也都可用于实例化。
使用 ~ 对代码进行改写之后如下:
1 | type Int interface { |
使用 ~
时有一定的限制:
~
后面的类型不能为接口~
后面的类型必须为基本类型
1 | type MyInt int |
6.2 接口含义的变更
上面的例子中,我们学习到了一种接口的全新写法,而这种写法在 Go1.18 之前是不存在的。如果你比较敏锐的话,一定会隐约认识到这种写法的改变这也一定意味着 Go 语言中 接口(interface)
这个概念发生了非常大的变化。
是的,在 Go1.18 之前,Go 官方对 接口(interface)
的定义是:接口是一个方法集(method set)
就如下面这个代码一样, ReadWriter
接口定义了一个接口(方法集),这个集合中包含了 Read()
和 Write()
这两个方法。所有同时定义了这两种方法的类型被视为实现了这一接口。
1 | type ReadWriter interface { |
但是,我们如果换一个角度来重新思考上面这个接口的话,会发现接口的定义实际上还能这样理解:
我们可以把
ReaderWriter
接口看成代表了一个 类型的集合,所有实现了Read()
Writer()
这两个方法的类型都在接口代表的类型集合当中
通过换个角度看待接口,在我们眼中接口的定义就从 方法集(method set)
变为了 ** 类型集(type set)
**。而 Go1.18 开始就是依据这一点将接口的定义正式更改为了 类型集(Type set)
为什么这么看呢?因为引入泛型之后,使用接口去简化类型约束的写法,在接口写法改变之后,接口的定义就得变更:
1 | type Float interface { |
这就体现出了为什么要更改接口的定义了。用 类型集 的概念重新理解上面的代码的话就是:
接口类型 Float 代表了一个 类型集合, 所有以 float32 或 float64 为底层类型的类型,都在这一类型集之中
而 type Slice[T Float] []T
中, 类型约束 的真正意思是:
类型约束 指定了类型形参可接受的类型集合,只有属于这个集合中的类型才能替换形参用于实例化
比如下面这个例子:
1 | var s Slice[int] // int 属于类型集 Float ,所以int可以作为类型实参 |
6.2.1 接口实现定义的变化
既然接口定义发生了变化,那么从 Go1.18 开始 接口实现(implement)
的定义自然也发生了变化:
当满足以下条件时,我们可以说 **类型 T 实现了接口 I ( type T implements interface I)**:
- T 不是接口时:类型 T 是接口 I 代表的类型集中的一个成员 (T is an element of the type set of I)
- T 是接口时: T 接口代表的类型集是 I 代表的类型集的子集(Type set of T is a subset of the type set of I)
6.2.2 类型的并集
并集我们已经很熟悉了,之前一直使用的 |
符号就是求类型的并集( union
)
1 | type Uint interface { // 类型集 Uint 是 ~uint 和 ~uint8 等类型的并集 |
6.2.3 类型的交集
接口可以不止书写一行,如果一个接口有多行类型定义,那么取它们之间的 交集
1 | type AllInt interface { |
上面这个例子中
- 接口 A 代表的是 AllInt 与 Uint 的 交集,即
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
- 接口 B 代表的则是 AllInt 和
int 的交集,即 `int`
除了上面的交集,下面也是一种交集:
1 | type C interface { |
很显然,~int 和 int 的交集只有 int 一种类型,所以接口 C 代表的类型集中只有 int 一种类型
6.2.4 类型的空集
当多个类型的交集如下面 Bad
这样为空的时候, Bad
这个接口代表的类型集为一个空集:
1 | type Bad interface { |
没有任何一种类型属于空集。虽然 Bad 这样的写法是可以编译的,但实际上并没有什么意义
6.2.5 空接口和 any
上面说了空集,接下来说一个特殊的类型集—— 空接口 interface{}
。因为,Go1.18 开始接口的定义发生了改变,所以 interface{}
的定义也发生了一些变更:
空接口代表了所有类型的集合
所以,对于 Go1.18 之后的空接口应该这样理解:
- 虽然空接口内没有写入任何的类型,但它代表的是所有类型的集合,而非一个 空集
- 类型约束中指定 空接口 的意思是指定了一个包含所有类型的类型集,并不是类型约束限定了只能使用 空接口 来做类型形参
1 | // 空接口代表所有类型的集合。写入类型约束意味着所有类型都可拿来做类型实参 |
因为空接口是一个包含了所有类型的类型集,所以我们经常会用到它。于是,Go1.18 开始提供了一个和空接口 interface{}
等价的新关键词 any
,用来使代码更简单:
1 | type Slice[T any] []T // 代码等价于 type Slice[T interface{}] []T |
实际上 any
的定义就位于 Go 语言的 builtin.go
文件中(参考如下), any
实际上就是 interaface{}
的别名(alias),两者完全等价
1 | // any is an alias for interface{} and is equivalent to interface{} in all ways. |
6.3 接口的两种类型
在引入泛型后,接口有了一种新的写法:
1 | type ReadWriter interface { |
上述代码的意思是: 接口类型 ReadWriter 代表了一个类型集合,所有以 string
或 []rune
为底层类型,并且实现了 Read() Write()
这两个方法的类型都在 ReadWriter
代表的类型集当中。
如下面代码中,StringReadWriter
存在于接口 ReadWriter 代表的类型集中
,而 BytesReadWriter
因为底层类型是 []byte
(既不是 string
也是不 []rune
) ,所以它不属于 ReadWriter
代表的类型集
1 | // 类型 StringReadWriter 实现了接口 Readwriter |
那如此一来,定义一个接口也变得太复杂了,定义一个 ReadWriter
类型的接口变量,然后接口变量赋值的时候不光要考虑到方法的实现,还必须考虑到具体底层类型?心智负担也太大了吧。是的,为了解决这个问题也为了保持 Go 语言的兼容性,Go1.18 开始将接口分为了两种类型
- 基本接口
- 一般接口
6.3.1 基本接口
接口定义中如果只有方法的话,那么这种接口被称为**基本接口(Basic interface)**。这种接口就是 Go1.18 之前的接口,用法也基本和 Go1.18 之前保持一致。基本接口大致可以用于如下几个地方:
- 最常用的,定义接口变量并且赋值
1 | type MyError interface { // 接口中只有方法,所以是基本接口 |
- 基本接口因为也代表了一个类型集,所以也可以用厚道类型约束中
1
2// io.Reader 和 io.Writer 都是基本接口,也可以用在类型约束中
type MySlice[T io.Reader | io.Writer] []Slice
6.3.2 一般接口
如果接口内不光只有方法,还有类型的话,这种接口被称为 一般接口(General interface) ,如下例子都是一般接口:
1 | type Uint interface { // 接口 Uint 中有类型,所以是一般接口 |
一般接口类型不能用来定义变量,只能用于泛型的类型约束中。所以以下的用法是错误的:
1 | type Uint interface { |
这一限制保证了一般接口的使用被限定在了泛型之中,不会影响到 Go1.18 之前的代码,同时也极大减少了书写代码时的心智负担。
6.4 泛型接口
所有类型的定义中都可以使用类型形参,所以接口定义自然也可以使用类型形参,观察下面这两个例子:
1 | type DataProcessor[T any] interface { |
因为引入了类型形参,所以这两个接口是泛型类型。而泛型类型要使用的话必须传入类型实参实例化才有意义。所以我们来尝试实例化一下这两个接口。因为 T
的类型约束是 any,所以可以随便挑一个类型来当实参(比如 string):
1 | DataProcessor[string] |
经过实例化之后就好理解了, DataProcessor[string]
因为只有方法,所以它实际上就是个 **基本接口(Basic interface)**,这个接口包含两个能处理 string 类型的方法。像下面这样实现了这两个能处理 string 类型的方法就算实现了这个接口:
1 | type CSVProcessor struct { |
再使用相同的办法实例化 DataProcessor2[T]
:
1 | DataProcessor2[string] |
DataProcessor2[string]
因为带有类型并集所以它是 **一般接口(General interface)**,所以实例化之后的这个接口代表的意思是:
- 只有实现了
Process(string) string
和Save(string) error
这两个方法,并且以int
或struct{ Data interface{} }
为底层类型的类型才算实现了这个接口 - 一般接口(General interface) 不能用于变量定义只能用于类型约束,所以接口
DataProcessor2[string]
只是定义了一个用于类型约束的类型集
1 | // XMLProcessor 虽然实现了接口 DataProcessor2[string] 的两个方法,但是因为它的底层类型是 []byte,所以依旧是未实现 DataProcessor2[string] |
6.5 接口定义的种种限制规则
Go1.18 从开始,在定义类型集(接口)的时候增加了非常多十分琐碎的限制规则,其中很多规则都在之前的内容中介绍过了,但剩下还有一些规则因为找不到好的地方介绍,所以在这里统一介绍下:
用 | 连接多个类型的时候,类型之间不能有相交的部分(即必须是不交集):
1 | type MyInt int |
但是相交的类型中是接口的话,则不受这一限制:
1 | type MyInt int |
类型的并集中不能有类型形参
1 | type MyInf[T ~int | ~string] interface { |
接口不能直接或间接地并入自己
1 | type Bad interface { |
接口的并集成员个数大于一的时候不能直接或间接并入 comparable
接口
1 | type OK interface { |
带方法的接口(无论是基本接口还是一般接口),都不能写入接口的并集中
1 | type _ interface { |
小结
- Go 的泛型目前可以使用在 3 个地方
- 泛型类型 - 类型定义中带类型形参的类型
- 泛型 receiver - 泛型类型的 receiver
- 泛型函数 - 带类型形参的函数
- 泛型接口 - 一般接口和基本接口
- 不支持泛型:
- 匿名结构体不支持泛型
- 匿名函数不支持泛型
- 不是泛型类型的 receiver 不支持泛型