引子
如果我们要写一个函数分别比较2个整数和浮点数的大小,我们就要写2个函数。如下:
func Min(x, y float64) float64 {
if x < y {
return x
}
return y
}
func MinInt(x, y int) int {
if x < y {
return x
}
return y
}
2个函数,除了数据类型不一样,其他处理逻辑完全一言。那有没有方法能一个函数完成上面的功能呢?有,那就是泛型。
func min[T int | float64](x, y T) T {
if x < y {
return x
}
return y
}
泛型
官网文档:https://go.dev/blog/intro-generics
泛型为该语言添加了三个新的重要功能:
- 函数和类型的类型参数。
- 将接口类型定义为类型集,包括没有方法的类型。
- 类型推断,在许多情况下允许在调用函数时省略类型参数。
类型参数(Type Parameters)
现在允许函数和类型具有类型参数。类型参数列表看起来与普通参数列表类似,只是它使用方括号而不是圆括号。
package main
import (
"fmt"
"golang.org/x/exp/constraints"
)
func GMin[T constraints.Ordered](x, y T) T {
if x < y {
return x
}
return y
}
func main() {
x := GMin[int](2, 3)
fmt.Println(x) // 输出结果为2
}
其中constraints.Ordered是自定义类型(这里不展示源码)。
理解不了的,可以暂时把constraints.Ordered替换为 ·int | float64
。
向 GMin 提供类型参数(在本例中为 int)称为实例化)(instantiation)。实例化分两步进行。
- 首先,编译器在整个泛型函数或类型中将所有类型实参替换为其各自的类型参数。
- 其次,编译器验证每个类型参数是否满足各自的约束。
我们很快就会明白这意味着什么,但如果第二步失败,实例化就会失败,程序就会无效。
成功实例化后,我们有一个非泛型函数,可以像任何其他函数一样调用它。例如,在类似的代码中
fmin := GMin[float64]
m := fmin(2.71, 3.14)
全部代码为
package main
import (
"fmt"
"golang.org/x/exp/constraints"
)
func GMin[T constraints.Ordered](x, y T) T {
if x < y {
return x
}
return y
}
func main() {
fmin := GMin[float64] // 相当于func GMin(x, y float64) float64{...}
m := fmin(2.71, 3.14)
fmt.Println(m) // 输出结果为2.71
}
实例化 GMin[float64] 生成的实际上是我们原始的浮点 Min 函数,我们可以在函数调用中使用它。
类型参数也可以与类型一起使用。
type Tree[T interface{}] struct {
left, right *Tree[T]
value T
}
func (t *Tree[T]) Lookup(x T) *Tree[T] { ... }
var stringTree Tree[string]
这里泛型类型 Tree 存储类型参数 T 的值。泛型类型可以有方法,就像本例中的 Lookup 一样。为了使用泛型类型,必须对其进行实例化; Tree[string] 是使用类型参数 string 实例化 Tree 的示例。
类型集(Type sets)
类型参数列表中的每个类型参数都有一个类型。由于类型参数本身就是一种类型,因此类型参数的类型定义了类型集。这种元类型称为类型约束。
在泛型方法GMin 中,类型约束是从约束包中导入的。 Ordered 约束描述了具有可排序值的所有类型的集合,或者换句话说,与 < 运算符(或 <= 、 > 等)进行比较。该约束确保只有具有可排序值的类型才能传递给 GMin。这也意味着在 GMin 函数体中,该类型参数的值可以用于与 < 运算符进行比较。
在 Go 中,类型约束必须是接口。也就是说,接口类型可以用作值类型(value type),也可以用作元类型(meta-type)。接口定义方法,因此显然我们可以表达需要存在某些方法的类型约束。但是constraints.Ordered也是一个接口类型,并且<运算符不是一个方法。
在 Go 语言中,接口类型的双重用途确实是一个重要的概念。让我们来深入理解并举例说明"接口类型可以用作值类型,也可以用作元类型"这一说法[1][2][3][4][5]。
- 接口作为值类型:
当接口用作值类型时,它定义了一组方法,任何实现了这些方法的类型都可以赋值给这个接口变量。这是接口最常见的用法。
例如:
type Stringer interface {
String() string
}
type Person struct {
Name string
}
func (p Person) String() string {
return p.Name
}
var s Stringer = Person{"Alice"} // Person 实现了 Stringer 接口
fmt.Println(s.String()) // 输出: Alice
在这个例子中,Stringer
接口被用作值类型,Person
类型实现了 String()
方法,因此可以赋值给 Stringer
类型的变量。
- 接口作为元类型(meta-type):
当接口用作元类型时,它定义了一组类型约束,用于泛型编程。这是 Go 1.18 引入泛型后的新用法。
例如:
type Ordered interface {
int | float64 | string
}
func Min[T Ordered](a, b T) T {
if a < b {
return a
}
return b
}
fmt.Println(Min(3, 5)) // 输出: 3
fmt.Println(Min(3.14, 2.71)) // 输出: 2.71
fmt.Println(Min("a", "b")) // 输出: a
在这个例子中,Ordered
接口被用作元类型,它定义了一组可以进行比较操作的类型(整数、浮点数和字符串)。Min
函数使用这个接口作为类型约束,可以接受任何满足 Ordered
约束的类型作为参数。
这种双重用途使得 Go 的接口在泛型编程中变得非常强大和灵活。它们不仅可以定义对象的行为(作为值类型),还可以定义类型集合(作为元类型),从而在保持语言简洁性的同时,大大增强了代码的表达能力和复用性。
直到最近,Go 规范还说接口定义了一个方法集,大致就是接口中枚举的方法集。任何实现所有这些方法的类型都实现该接口。
但看待这个问题的另一种方式是说接口定义了一组类型,即实现这些方法的类型。从这个角度来看,作为接口类型集元素的任何类型都实现该接口。
这两种视图导致相同的结果:对于每组方法,我们可以想象实现这些方法的相应类型集,即由接口定义的类型集。
不过,就我们的目的而言,类型集视图比方法集视图有一个优势:我们可以显式地将类型添加到集合中,从而以新的方式控制类型集。
我们扩展了接口类型的语法来实现这一点。例如,interface{ int|string|bool } 定义了包含 int、string 和 bool 类型的类型集。
另一种说法是,该接口仅由 int、string 或 bool 满足。
现在让我们看看constraints.Ordered的实际定义:
type Ordered interface {
Integer|Float|~string
}
该声明表示 Ordered 接口是所有整数、浮点和字符串类型的集合。竖线表示类型的联合(或本例中的类型集)。 Integer 和 Float 是在约束包中类似定义的接口类型。请注意,Ordered 接口没有定义任何方法。
对于类型约束我们通常不关心具体的类型,比如字符串;我们对所有字符串类型都感兴趣。这就是 ~
令牌的用途。表达式 ~string
表示基础类型为 string 的所有类型的集合。这包括类型 string 本身以及使用定义声明的所有类型,例如type MyString string
当然我们还是想在接口中指定方法,并且我们希望能够向后兼容。在 Go 1.18 中,接口可以像以前一样包含方法和嵌入接口,但它也可以嵌入非接口类型、联合和底层类型集。
用作约束的接口可以指定名称(例如 Ordered),也可以是内联在类型参数列表中的文字接口。例如:
[S interface{~[]E}, E interface{}]
这里S必须是一个切片类型,其元素类型可以是任何类型。
因为这是常见的情况,所以对于约束位置的接口,可以省略封闭的interface{},我们可以简单地编写(Go 语言中泛型的语法糖和类型约束的简化写法):
[S ~[]E, E interface{}]
由于空接口在类型参数列表以及普通 Go 代码中很常见,因此 Go 1.18 引入了一个新的预声明标识符 any 作为空接口类型的别名。这样,我们就得到了这个惯用的代码:
[S ~[]E, E any]
类型推断(Type inference)
有了类型参数,就需要传递类型参数,这可能会导致代码冗长。回到我们的通用 GMin 函数: