这是我参与「第五届青训营 」伴学笔记创作活动的第 6 天
Go 泛型简介
Go 1.18版本中引入了Generics:
-
函数和类型的类型参数(type parameters)
-
接口定义的类型集合
接口现在不只是定义一组方法,而是可以定义一组类型
-
类型推断
Type Parameters
[P, Q constraint1, R constraint2]
使用方括号来声明一个类型参数列表。
如何使用泛型?
以一个最简单的min函数为例:
func min(x, y, float64) float64{
if x < y {
return x
}
return y
}
通过将其中的类型float64替换为类型参数,可以得到如下的一个Generic min函数
func min[T constraints.Ordered] (x, y T) T{
if x < y {
return x
}
return y
}
// 使用
m := min[int](2, 3)
//也可以省略掉[int]让其自动推导
关于其中的constraints.Ordered,可以参考golang.org/x/exp/const…,它定义了一些泛型类型,Ordered包括了整数、浮点数和字符串类型。
泛型的实例化(Instantiation)
- 使用类型实参(args)替换掉类型形参(para)(Substitute type arguments for type parameters)
- 检查类型实参是否实现给定的约束
如果上述步骤2不成功那么实例化也就是不成功的。
可以将一个实例化的泛型函数赋值给一个普通的非泛型函数,示例如下:
fmin := min[float64]
m := fmin(2.72, 3.14)
泛型类型(以二叉树为例)
type Tree[T interface{}] struct{
left, right * Tree[T]
data T
}
func (t *Tree[T]) Lookup(x T) * Tree[T]
var stringTree Tree[string]
Type Sets
对于一个普通的函数min
func min(x, y float64) float64
其中的float64告诉我们,何种参数/返回值类型在这里是合法的
func min[T constraints.Ordered](x, y T) T
而在泛型版本中,constraints.Ordered告诉我们,哪些类型参数在这里是合法的,同样的,constraint.Ordered保证了这里传入的类型可以使用operator<运算符来进行比较。
在文档中可以看到constraints.Ordered的定义如下:
type Ordered interface {
Integer | Float | ~string
}
其中Interger和Float是在前面定义的类型集合。
Type constraints
在Go语言中,type constraints必须是interfaces,在接口中,可以定义一系列需要使用的方法,从另一个视角来看,有一些类型实现了这些方法,那么接口也定义了一系列的类型(无限多的,只要满足该接口)的集合。即这两种视角可以认为是等价的。
从type set的视角来看,相比于方法定义的接口而言,有更好的灵活性,因为我们可以显式的添加一个类型到该集合中去。
可以使用如下的语法来定义一个类型集合:
interface{
int|string|bool
}
即,上面的min函数可以改成:
func min[T interface{~float64|~int|~string}] (x, y T) T
换种角度来看,可以说是float64、int和string这三种类型实现了该接口。
-
~string前面的波浪号表示:我们不只关心string这一特定的类型,而是所有底层类型为string的类型都可以。
type MyStr string var s1, s2 MyStr = "str1", "str2"对于上述的s1和s2,如果不采用
~string,而是采用string来定义接口的约束,那么无法使用这种定义下的min函数
type constraints的两种功能
- 一个type constraints定义了合法类型的集合
- 如果该类型集合中的所有类型都支持同一个操作,那么这个操作就可以被使用。
具名的type constraints
[S interface{~[]E}, E interface{}]
//也可写成这样 -语法糖
[S ~[]E, E any]
上面定义了一个类型参数E,以及对应的一个E的slice类型的类型参数S
Type Inference
即类型推导,我们可以省略泛型实例化时的参数,而从实参中推导出来。
func min[T constraints.Ordered] (x, y T) T
var a, b float64
m: = min(a,b) // 不需要写成 min[float64](a,b)
使用中存在的一些问题
以如下一个函数为例:
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
}
type Point []int
func (p Point) String() string
func ScaleAndPrint(p Point){
r := Scale(p, 2)
fmt.Println(r.String()) // 编译不通过
}
存在的问题是,即便我们为Point类型定义了String()方法,从Scale函数的返回值中得到的是 []int而不是Point
修正的方式如下所示:
func Scale[S ~[]E, E constraints.Integer](s S, sc E) S
使用泛型的Guideline
Write code, don't design types.
如果在写代码时,从设计泛型类型开始,那么方向可能就错了。应该从编写函数开始,然后在需要的时候再添加泛型参数。
一些类型参数可能派上用场的地方:
-
用于处理某种类型slice、maps、channels的函数
-
某种通用的数据结构(例如上述的泛型二叉树Tree)
-
相比于使用interface{},可以在编译期间检查类型的正确性
-
在使用过程中,如果需要一个函数,尽量使用传入函数作为参数而不要使用类型的方法
以比较为例,虽然可以通过constraints来限制必须提供compare方法,但是如果想要使用一些简单类型例如int32时,就不得不自定义一个新类型并为其定义一个方法,而传入一个compare函数要更加方便一些。
-
-
当一个方法对于所有类型来说,看起来都一样时
在什么时候不使用泛型
-
当只是需要在该类型参数上调用一个方法时
例如使用io.Reader接口
// good func ReadFour(r io.Reader) ([]byte, error) // bad func ReadFour[T io.Reader](r T)([]byte, error) -
当一个通用方法对于每个类型的实现并不一样时
-
当对于每个类型,操作都不一样时
- 使用反射,例如json处理和标准库的一些函数
总的建议:
使用泛型来减少重复模板。
即当在编写代码时发现需要复制另一段代码,但仅需要修改其参数类型时,可以考虑使用泛型。
References: