泛型的语法
泛型为Go语言添加了三个新的重要特性:
- 函数和类型的类型参数。
- 将接口类型定义为类型集,包括没有方法的类型。
- 类型推断,它允许在调用函数时在许多情况下省略类型参数。
类型参数
类型参数的使用
除了函数中支持类型参数列表 外,类型也支持类型参数列表。
单个类型参数
go
type Slice[T int | string] []T
多个类型参数
go
type Map[K int | string, V float32 | float64] map[K]V
不同泛型参数之间使用,
隔开。
泛型类型使用方法
go
type Tree[T interface{}] struct {
left, right *Tree[T]
value T
}
// 同时,泛型类型可以使用用*方法*。
func (t *Tree[T]) Lookup(x T) *Tree[T] { ... }
在上述泛型中,T
、K
、V
都属于类型形参 ,类型形参后面是类型约束。
需要注意 的是:
要使用泛型类型,必须先对其实例化。
go
var stringTree Tree[string]
类型实例化
定义一个适用于一组类型的min
函数
go
func min(T int | float64)(a,b T) T {
if a<b{
return a
}
return b
}
进行类型实例化
go
// 1. int
m1 := min[int](1,2)
// 相当于
// m1 := min[int]
// mi := m1(1,2)
// 2. float
m2 := min[float64](-0.1,-0.2)
// 相当于
// 同上
根据上面的例子,
在定义函数时:
(T int | float64)
就是形参
在调用函数时:
m1 := min[int]
是在进行类型实例化 ;mi := m1(1,2)
则是在调用。
类型实例化分为两步:
- 首先,编译器在整个泛型函数或类型中将所有类型形参(type parameters)替换为它们各自的类型实参(type arguments)。
- 其次,编译器验证每个类型参数是否满足相应的约束。
即先"替换",再"验证"。
类型约束
Go语言中的类型约束是接口类型 。
类型参数列表中每个类型参数都有一个类型约束 ,它定义了一个类型集 ,只有在这个类型集中的类型才能用作类型实参。
类型约束常见有两种方式:
- 类型约束接口直接在类型参数列表中使用
go
// 类型约束字面量,通常外层interface{}可省略
func min[T interface{ int | float64 }](a, b T) T {
if a <= b {
return a
}
return b
}
- 作为类型约束使用的接口类型可以事先定义并支持复用
go
// 事先定义好的类型约束类型
type Value interface {
int | float64
}
func min[T Value](a, b T) T {
if a <= b {
return a
}
return b
}
若省略外层interface{}
会引起歧义,则不能省略。
go
type IntPtrSlice [T *int] []T // T*int ?
type IntPtrSlice[T *int,] []T // 只有一个类型约束时可以添加`,`
type IntPtrSlice[T interface{ *int }] []T // 使用interface{}包裹
类型集
**Go1.18开始接口类型的定义也发生了改变,由过去的接口类型定义方法集(method set)变成了接口类型定义类型集(type set)。**也就是说,接口类型现在可以用作值的类型,也可以用作类型约束。
把接口类型当做类型集相较于方法集有一个优势: 我们可以显式地向集合添加类型,从而以新的方式控制类型集。
Go语言扩展了接口类型的语法,让我们能够向接口中添加类型。例如
go
type V interface {
int | string | bool
}
上面的代码就定义了一个包含 int
、 string
和 bool
类型的类型集。
从 Go 1.18 开始,一个接口不仅可以嵌入其他接口,还可以嵌入任何类型、类型的联合或共享相同底层类型的无限类型集合。
当用作类型约束时,由接口定义的类型集精确地指定允许作为相应类型参数的类型。
|
符号
T1 | T2
表示类型约束为T1和T2这两个类型的并集
go
type Integer interface {
Signed | Unsigned
}
~
符号
~T
表示所以底层类型是T的类型,例如~string
表示所有底层类型是string
的类型集合。
go
type MyString string // MyString的底层类型是string
通过[T ~string]
就可以限制泛型参数的底层类型必须为string
。
注意:
~
符号后面只能是基本类型。
any接口
空接口在类型参数列表中很常见,在Go 1.18引入了一个新的预声明标识符,作为空接口类型的别名。
go
// src/builtin/builtin.go
type any = interface{}
由此,我们可以使用如下代码:
go
func foo[S ~[]E, E any]() {
// ...
}
类型判断
函数参数类型判断
go
func min[T int | float64](a, b T) T {
if a <= b {
return a
}
return b
}
类型形参T
用于指定a
和b
的类型。我们可以使用显式类型实参调用它:
go
var a, b, m float64
m = min[float64](a, b) // 显式指定类型实参
在许多情况下,编译器可以从普通参数推断 T
的类型实参。这使得代码更短,同时保持清晰。
go
var a, b, m float64
m = min(a, b) // 无需指定类型实参
这种从实参的类型推断出函数的类型实参的推断称为函数实参类型推断 。
其只适用于函数参数中使用的类型参数,而不适用于仅在函数结果中或仅在函数体中使用的类型参数。
例如,它不适用于像 MakeT [ T any ]() T
这样的函数,因为它只使用 T
表示结果。
类型约束推断
Go 语言支持另一种类型推断,即约束类型推断。
go
// Scale 返回切片中每个元素都乘c的副本切片
func Scale[E constraints.Integer](s []E, c E) []E {
r := make([]E, len(s))
for i, v := range s {
r[i] = v * c
}
return r
}
这是一个泛型函数适用于任何整数类型的切片。
现在假设我们有一个多维坐标的 Point
类型,其中每个 Point
只是一个给出点坐标的整数列表。这种类型通常会实现一些业务方法,这里假设它有一个String
方法。
go
type Point []int32
func (p Point) String() string {
b, _ := json.Marshal(p)
return string(b)
}
由于一个Point
其实就是一个整数切片,我们可以使用前面编写的Scale
函数:
go
func ScaleAndPrint(p Point) {
r := Scale(p, 2)
fmt.Println(r.String()) // 编译失败
}
不幸的是,这代码会编译失败,输出r.String undefined (type []int32 has no field or method String
的错误。
问题是Scale
函数返回类型为[]E
的值,其中E
是参数切片的元素类型。当我们使用Point
类型的值调用Scale
(其基础类型为[]int32)时,我们返回的是[]int32
类型的值,而不是Point
类型。这源于泛型代码的编写方式,但这不是我们想要的。
为了解决这个问题,我们必须更改 Scale
函数,以便为切片类型使用类型参数。
go
func Scale[S ~[]E, E constraints.Integer](s S, c E) S {
r := make(S, len(s))
for i, v := range s {
r[i] = v * c
}
return r
}
现在这个Scale
函数,不仅支持传入普通整数切片参数,也支持传入Point
类型参数。
编译器推断 E
的类型参数是切片的元素类型的过程称为约束类型推断。
约束类型推断从类型参数约束推导类型参数。当一个类型参数具有根据另一个类型参数定义的约束时使用。当其中一个类型参数的类型参数已知时,约束用于推断另一个类型参数的类型参数。
总结
总之,如果你发现自己多次编写完全相同的代码,而这些代码之间的唯一区别就是使用的类型不同,这个时候你就应该考虑是否可以使用类型参数。
泛型和接口类型之间并不是替代关系,而是相辅相成的关系。泛型的引入是为了配合接口的使用,让我们能够编写更加类型安全的Go代码,并能有效地减少重复代码。