泛型
1. 泛型
我们知道,函数的 形参(parameter) 只是类似占位符的东西并没有具体的值,只有我们调用函数传入实参(argument) 之后才有具体的值。那么,如果我们将形参 实参这个概念推广一下,给变量的类型也引入和类似形参实参的概念的话,问题就迎刃而解:在这里我们将其称之为 类型形参(type parameter) 和 类型实参(type argument),如下:
// 假设 T 是类型形参,在定义函数时它的类型是不确定的,类似占位符
func Add(a T, b T) T {
return a + b
}
在上面这段伪代码中, T被称为类型形参(type parameter), 它不是具体的类型,在定义函数时类型并不确定。因为T的类型并不确定,所以我们需要像函数的形参那样,在调用函数的时候再传入具体的类型。这样我们不就能一个函数同时支持多个不同的类型了吗?在这里被传入的具体类型被称 类型实参(type argument):
下面一段伪代码展示了调用函数时传入类型实参的方式:
// [T=int]中的 int 是类型实参,代表着函数Add()定义中的类型形参 T 全都被 int 替换
Add[T=int](100, 200)
// 传入类型实参int后,Add()函数的定义可近似看成下面这样:
func Add( a int, b int) int {
return a + b
}
// 另一个例子:当我们想要计算两个字符串之和的时候,就传入string类型实参
Add[T=string]("Hello", "World")
// 类型实参string传入后,Add()函数的定义可近似视为如下
func Add( a string, b string) string {
return a + b
}
通过引入 类型形参 和 类型实参 这两个概念,我们让一个函数获得了处理多种不同类型数据的能力,这种编程方式被称为 泛型编程。
1.1. 类型形参、类型实参、类型约束和泛型类型
观察下面这个简单的例子:
type IntSlice []int
var a IntSlice = []int{1, 2, 3} // 正确
var b IntSlice = []float32{1.0, 2.0, 3.0} // ✗ 错误,因为IntSlice的底层类型是[]int,浮点类型的切片无法赋值
这里定义了一个新的类型 IntSlice ,它的底层类型是 []int ,理所当然只有int类型的切片能赋值给 IntSlice 类型的变量。
接下来如果我们想要定义一个可以容纳 float32 或 string 等其他类型的切片的话该怎么办?很简单,给每种类型都定义个新类型:
type StringSlice []string
type Float32Slie []float32
type Float64Slice []float64
但是这样做的问题显而易见,它们结构都是一样的只是成员类型不同就需要重新定义这么多新类型。那么有没有一个办法能只定义一个类型就能代表上面这所有的类型呢?答案是可以的,这时候就需要用到泛型了:
type Slice[T int|float32|float64 ] []T
不同于一般的类型定义,这里类型名称 Slice 后带了中括号,对各个部分做一个解说就是:
T就是上面介绍过的类型形参(Type parameter),在定义Slice类型的时候 T 代表的具体类型并不确定,类似一个占位符int|float32|float64这部分被称为类型约束(Type constraint),中间的|的意思是告诉编译器,类型形参 T 只可以接收 int 或 float32 或 float64 这三种类型的实参- 中括号里的
T int|float32|float64这一整串因为定义了所有的类型形参(在这个例子里只有一个类型形参T),所以我们称其为 类型形参列表(type parameter list) - 这里新定义的类型名称叫
Slice[T]
这种类型定义的方式中带了类型形参,很明显和普通的类型定义非常不一样,所以我们将这种
[!note]
类型定义中带类型形参的类型,称之为泛型类型(Generic type)
泛型类型不能直接拿来使用,必须传入类型实参(Type argument) 将其确定为具体的类型之后才可使用。而传入类型实参确定具体类型的操作被称为 实例化(Instantiations) :
// 这里传入了类型实参int,泛型类型Slice[T]被实例化为具体的类型 Slice[int]
var a Slice[int] = []int{1, 2, 3}
fmt.Printf("Type Name: %T",a) //输出:Type Name: Slice[int]
// 传入类型实参float32, 将泛型类型Slice[T]实例化为具体的类型 Slice[string]
var b Slice[float32] = []float32{1.0, 2.0, 3.0}
fmt.Printf("Type Name: %T",b) //输出:Type Name: Slice[float32]
// ✗ 错误。因为变量a的类型为Slice[int],b的类型为Slice[float32],两者类型不同
a = b
// ✗ 错误。string不在类型约束 int|float32|float64 中,不能用来实例化泛型类型
var c Slice[string] = []string{"Hello", "World"}
// ✗ 错误。Slice[T]是泛型类型,不可直接使用必须实例化为具体的类型
var x Slice[T] = []int{1, 2, 3}
对于上面的例子,我们先给泛型类型 Slice[T] 传入了类型实参 int ,这样泛型类型就被实例化为了具体类型 Slice[int] ,被实例化之后的类型定义可近似视为如下:
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 类型来实例化是错误的。
上面只是个最简单的例子,实际上类型形参的数量可以远远不止一个,如下:
// MyMap类型定义了两个类型形参 KEY 和 VALUE。分别为两个形参指定了不同的类型约束
// 这个泛型类型的名字叫: MyMap[KEY, VALUE]
type MyMap[KEY int | string, VALUE float32 | float64] map[KEY]VALUE
// 用类型实参 string 和 flaot64 替换了类型形参 KEY 、 VALUE,泛型类型被实例化为具体的类型:MyMap[string, float64]
var a MyMap[string, float64] = map[string]float64{
"jack_score": 9.6,
"bob_score": 8.4,
}
用上面的例子重新复习下各种概念的话:
- 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]
总结:
1.2. 其他的泛型类型
所有类型定义都可使用类型形参,所以下面这种结构体以及接口的定义也可以使用类型形参:
// 一个泛型类型的结构体。可用 int 或 sring 类型实例化
type MyStruct[T int | string] struct {
Name string
Data T
}
// 一个泛型接口(关于泛型接口在后半部分会详细讲解)
type IPrintData[T int | float32 | string] interface {
Print(data T)
}
// 一个泛型通道,可用类型实参 int 或 string 实例化
type MyChan[T int | string] chan T
1.3. 类型形参的互相套用
类型形参是可以互相套用的,如下
type WowStruct[T int | float32, S []T] struct {
Data S
MaxValue T
MinValue T
}
这个例子看起来有点复杂且难以理解,但实际上只要记住一点:任何泛型类型都必须传入类型实参实例化才可以使用。所以我们这就尝试传入类型实参看看:
var ws WowStruct[int, []int]
// 泛型类型 WowStuct[T, S] 被实例化后的类型名称就叫 WowStruct[int, []int]
上面的代码中,我们为T传入了实参 int,然后因为 S 的定义是 []T ,所以 S 的实参自然是 []int 。经过实例化之后 WowStruct[T,S] 的定义类似如下:
// 一个存储int类型切片,以及切片中最大、最小值的结构体
type WowStruct[int, []int] struct {
Data []int
MaxValue int
MinValue int
}
因为 S 的定义是[]T,所以 T 一定决定了的话 S 的实参就不能随便乱传了,下面这样的代码是错误的:
// 错误。S的定义是[]T,这里T传入了实参int, 所以S的实参应当为 []int 而不能是 []float32
ws := WowStruct[int, []float32]{
Data: []float32{1.0, 2.0, 3.0},
MaxValue: 3,
MinValue: 1,
}
1.4. 泛型receiver
看了上的例子,你一定会说,介绍了这么多复杂的概念,但好像泛型类型根本没什么用处啊。是的,单纯的泛型类型实际上对开发来说用处并不大。但是如果将泛型类型和接下来要介绍的泛型receiver相结合的话,泛型就有了非常大的实用性了
我们知道,定义了新的普通类型之后可以给类型添加方法。那么可以给泛型类型添加方法吗?答案自然是可以的,如下:
type MySlice[T int | float32] []T
func (s MySlice[T]) Sum() T {
var sum T
for _, value := range s {
sum += value
}
return sum
}
这个例子为泛型类型 MySlice[T] 添加了一个计算成员总和的方法 Sum() 。注意观察这个方法的定义:
- 首先看receiver
(s MySlice[T]),所以我们直接把类型名称MySlice[T]写入了receiver中 - 然后方法的返回参数我们使用了类型形参 T **(实际上如果有需要的话,方法的接收参数也可以实用类型形参)
- 在方法的定义中,我们也可以使用类型形参 T (在这个例子里,我们通过
var sum T定义了一个新的变量sum)
对于这个泛型类型 MySlice[T] 我们该如何使用?还记不记得之前强调过很多次的,泛型类型无论如何都需要先用类型实参实例化,所以用法如下:
var s MySlice[int] = []int{1, 2, 3, 4}
fmt.Println(s.Sum()) // 输出:10
var s2 MySlice[float32] = []float32{1.0, 2.0, 3.0, 4.0}
fmt.Println(s2.Sum()) // 输出:10.0
该如何理解上面的实例化?首先我们用类型实参 int 实例化了泛型类型 MySlice[T],所以泛型类型定义中的所有 T 都被替换为 int,最终我们可以把代码看作下面这样:
type MySlice[int] []int // 实例化后的类型名叫 MyIntSlice[int]
// 方法中所有类型形参 T 都被替换为类型实参 int
func (s MySlice[int]) Sum() int {
var sum int
for _, value := range s {
sum += value
}
return sum
}
用 float32 实例化和用 int 实例化同理,此处不再赘述。
通过泛型receiver,泛型的实用性一下子得到了巨大的扩展。在没有泛型之前如果想实现通用的数据结构,诸如:堆、栈、队列、链表之类的话,我们的选择只有两个:
- 为每种类型写一个实现
- 使用接口+反射
而有了泛型之后,我们就能非常简单地创建通用数据结构了。接下来用一个更加实用的例子 —— 队列来讲解
1.4.1. 基于泛型的队列
队列是一种先入先出的数据结构,它和现实中排队一样,数据只能从队尾放入、从队首取出,先放入的数据优先被取出来
// 这里类型约束使用了空接口,代表的意思是所有类型都可以用来实例化泛型类型 Queue[T] (关于接口在后半部分会详细介绍)
type Queue[T interface{}] struct {
elements []T
}
// 将数据放入队列尾部
func (q *Queue[T]) Put(value T) {
q.elements = append(q.elements, value)
}
// 从队列头部取出并从头部删除对应数据
func (q *Queue[T]) Pop() (T, bool) {
var value T
if len(q.elements) == 0 {
return value, true
}
value = q.elements[0]
q.elements = q.elements[1:]
return value, len(q.elements) == 0
}
// 队列大小
func (q Queue[T]) Size() int {
return len(q.elements)
}
💡 为了方便说明,上面是队列非常简单的一种实现方法,没有考虑线程安全等很多问题
Queue[T] 因为是泛型类型,所以要使用的话必须实例化,实例化与使用方法如下所示:
var q1 Queue[int] // 可存放int类型数据的队列
q1.Put(1)
q1.Put(2)
q1.Put(3)
q1.Pop() // 1
q1.Pop() // 2
q1.Pop() // 3
var q2 Queue[string] // 可存放string类型数据的队列
q2.Put("A")
q2.Put("B")
q2.Put("C")
q2.Pop() // "A"
q2.Pop() // "B"
q2.Pop() // "C"
var q3 Queue[struct{Name string}]
var q4 Queue[[]int] // 可存放[]int切片的队列
var q5 Queue[chan int] // 可存放int通道的队列
var q6 Queue[io.Reader] // 可存放接口的队列
// ......
1.4.2. 动态判断变量的类型
使用接口的时候经常会用到类型断言或 type swith 来确定接口具体的类型,然后对不同类型做出不同的处理,如:
var i interface{} = 123
i.(int) // 类型断言
// type switch
switch i.(type) {
case int:
// do something
case string:
// do something
default:
// do something
}
}
那么你一定会想到,对于 valut T 这样通过类型形参定义的变量,我们能不能判断具体类型然后对不同类型做出不同处理呢?答案是不允许的,如下:
func (q *Queue[T]) Put(value T) {
value.(int) // 错误。泛型类型定义的变量不能使用类型断言
// 错误。不允许使用type switch 来判断 value 的具体类型
switch value.(type) {
case int:
// do something
case string:
// do something
default:
// do something
}
// ...
}
虽然type switch和类型断言不能用,但我们可通过反射机制达到目的:
func (receiver Queue[T]) Put(value T) {
// Printf() 可输出变量value的类型(底层就是通过反射实现的)
fmt.Printf("%T", value)
// 通过反射可以动态获得变量value的类型从而分情况处理
v := reflect.ValueOf(value)
switch v.Kind() {
case reflect.Int:
// do something
case reflect.String:
// do something
}
// ...
}
这看起来达到了我们的目的,可是当你写出上面这样的代码时候就出现了一个问题:
你为了避免使用反射而选择了泛型,结果到头来又为了一些功能在在泛型中使用反射
当出现这种情况的时候你可能需要重新思考一下,自己的需求是不是真的需要用泛型(毕竟泛型机制本身就很复杂了,再加上反射的复杂度,增加的复杂度并不一定值得)
1.5. 泛型函数
在介绍完泛型类型和泛型receiver之后,我们来介绍最后一个可以使用泛型的地方——泛型函数。有了上面的知识,写泛型函数也十分简单。假设我们想要写一个计算两个数之和的函数:
func Add(a int, b int) int {
return a + b
}
这个函数理所当然只能计算int的和,而浮点的计算是不支持的。这时候我们可以像下面这样定义一个泛型函数:
func Add[T int | float32 | float64](a T, b T) T {
return a + b
}
上面就是泛型函数的定义。
这种带类型形参的函数被称为泛型函数
它和普通函数的点不同在于函数名之后带了类型形参。这里的类型形参的意义、写法和用法因为与泛型类型是一模一样的,就不再赘述了。
和泛型类型一样,泛型函数也是不能直接调用的,要使用泛型函数的话必须传入类型实参之后才能调用。
Add[int](1,2) // 传入类型实参int,计算结果为 3
Add[float32](1.0, 2.0) // 传入类型实参float32, 计算结果为 3.0
Add[string]("hello", "world") // 错误。因为泛型函数Add的类型约束中并不包含string
或许你会觉得这样每次都要手动指定类型实参太不方便了。所以Go还支持类型实参的自动推导:
Add(1, 2) // 1,2是int类型,编译请自动推导出类型实参T是int
Add(1.0, 2.0) // 1.0, 2.0 是浮点,编译请自动推导出类型实参T是float32
自动推导的写法就好像免去了传入实参的步骤一样,但请记住这仅仅只是编译器帮我们推导出了类型实参,实际上传入实参步骤还是发生了的。
1.5.1. 匿名函数不支持泛型
在Go中我们经常会使用匿名函数,如:
fn := func(a, b int) int {
return a + b
} // 定义了一个匿名函数并赋值给 fn
fmt.Println(fn(1, 2)) // 输出: 3
那么Go支不支持匿名泛型函数呢?答案是不能——匿名函数不能自己定义类型形参:
// 错误,匿名函数不能自己定义类型实参
fnGeneric := func[T int | float32](a, b T) T {
return a + b
}
fmt.Println(fnGeneric(1, 2))
但是匿名函数可以使用别处定义好的类型实参,如:
func MyFunc[T int | float32 | float64](a, b T) {
// 匿名函数可使用已经定义好的类型形参
fn2 := func(i T, j T) T {
return i*2 - j*2
}
fn2(a, b)
}
1.5.2. 既然支持泛型函数,那么泛型方法呢?
既然函数都支持泛型了,那你应该自然会想到,方法支不支持泛型?很不幸,目前Go的方法并不支持泛型,如下:
type A struct {
}
// 不支持泛型方法
func (receiver A) Add[T int | float32 | float64](a T, b T) T {
return a + b
}
但是因为receiver支持泛型, 所以如果想在方法中使用泛型的话,目前唯一的办法就是曲线救国,迂回地通过receiver使用类型形参:
type A[T int | float32 | float64] struct {
}
// 方法可以使用类型定义中的形参 T
func (receiver A[T]) Add(a T, b T) T {
return a + b
}
// 用法:
var a A[int]
a.Add(1, 2)
var aa A[float32]
aa.Add(1.0, 2.0)