Golang Generics

Context

泛型允许程序员在强类型程序设计语言中编写代码时使用一些以后才指定的类型,在实例化时作为参数指明这些类型。换句话说,在编写某些代码或数据结构时先不提供值的类型,而是之后再提供。

Go 1.18版本增加了对泛型的支持,泛型也是自 Go 语言开源以来所做的最大改变。

泛型是一种独立于所使用的特定类型的编写代码的方法。使用泛型可以编写出适用于一组类型中的任何一种的函数和类型。

Why

之前实现一个反转切片函数

1
2
3
4
5
6
7
8
9
10
11
func reverse(s []int) []int {
l := len(s)
r := make([]int, l)

for i, e := range s {
r[l-i-1] = e
}
return r
}

fmt.Println(reverse([]int{1, 2, 3, 4})) // [4 3 2 1]

可是这个函数只能接收[]int类型的参数,如果我们想支持[]float64类型的参数,我们就需要再定义一个reverseFloat64Slice函数。

1
2
3
4
5
6
7
8
9
func reverseFloat64Slice(s []float64) []float64 {
l := len(s)
r := make([]float64, l)

for i, e := range s {
r[l-i-1] = e
}
return r
}

从Go1.18开始,使用泛型就能够编写出适用所有元素类型的“普适版”reverse函数。

1
2
3
4
5
6
7
8
9
func reverseWithGenerics[T any](s []T) []T {
l := len(s)
r := make([]T, l)

for i, e := range s {
r[l-i-1] = e
}
return r
}

any 有一个真身,本质上是 interface{} 的别名:

1
type any = interface{}

也就是,在常规代码中,也可以直接使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func f(a any) {
switch a.(type) {
case int:
fmt.Println("aaa")
case float64:
fmt.Println("bbb")
case string:
fmt.Println("ccc")
}
}

func main() {
f(2)
f(3.1415)
f("aa")
}

Generic syntax

泛型为Go语言添加了三个新的重要特性:

  1. 函数和类型的类型参数。
  2. 将接口类型定义为类型集,包括没有方法的类型。
  3. 类型推断,它允许在调用函数时在许多情况下省略类型参数。

类型参数

类型形参和类型实参

函数的形参 parameter 和实参 argument

1
2
3
4
5
6
func Add(a int, b int) int {  
// 变量a,b是函数的形参 "a int, b int" 这一串被称为形参列表
return a + b
}

Add(100,200) // 调用函数时,传入的100和200是实参

函数的 形参(parameter) 只是类似占位符的东西并没有具体的值,只有我们调用函数传入实参(argument) 之后才有具体的值。

1
2
3
4
// 假设 T 是类型形参,在定义函数时它的类型是不确定的,类似占位符
func Add(a T, b T) T {
return a + b
}

在上面这段伪代码中, T 被称为 **类型形参(type parameter)**, 它不是具体的类型,在定义函数时类型并不确定。因为 T 的类型并不确定,所以我们需要像函数的形参那样,在调用函数的时候再传入具体的类型。这样我们不就能一个函数同时支持多个不同的类型了吗?在这里被传入的具体类型被称为 类型实参(type argument)

通过Go的 接口+反射 不也能实现这样的动态数据处理吗?是的,泛型能实现的功能通过接口+反射也基本能实现。但是使用过反射的人都知道反射机制有很多问题:

  1. 用起来麻烦
  2. 失去了编译时的类型检查,不仔细写容易出错
  3. 性能不太理想

现在,Go语言中的函数和类型支持添加类型参数。类型参数列表看起来像普通的参数列表,只不过它使用方括号([])而不是圆括号(())。

借助泛型,我们可以声明一个适用于一组类型min函数。

1
2
3
4
5
6
func min[T int | float64](a, b T) T {
if a <= b {
return a
}
return b
}

类型实例化

这次定义的min函数就同时支持intfloat64两种类型,也就是说当调用min函数时,既可以传入int类型的参数。

1
m1 := min[int](1, 2)  // 1

也可以传入float64类型的参数。

1
m2 := min[float64](-0.1, -0.2)  // -0.2

向 min 函数提供类型参数(在本例中为intfloat64)称为实例化( instantiation )。

类型实例化分两步进行:

  1. 首先,编译器在整个泛型函数或类型中将所有类型形参(type parameters)替换为它们各自的类型实参(type arguments)。
  2. 其次,编译器验证每个类型参数是否满足相应的约束。

在成功实例化之后,我们将得到一个非泛型函数,它可以像任何其他函数一样被调用。例如:

1
2
fmin := min[float64] // 类型实例化,编译器生成T=float64的min函数
m2 = fmin(1.2, 2.3) // 1.2

min[float64]得到的是类似我们之前定义的minFloat64函数——fmin,我们可以在函数调用中使用它。

类型参数的使用

除了函数中支持使用类型参数列表外,类型也可以使用类型参数列表。

1
2
3
4
5
6
7
8
type Slice[T int | string] []T

type Map[K int | string, V float32 | float64] map[K]V

type Tree[T interface{}] struct {
left, right *Tree[T]
value T
}

在上述泛型类型中,TKV都属于类型形参,类型形参后面是类型约束,类型实参需要满足对应的类型约束。

泛型类型可以有方法,例如为上面的Tree实现一个查找元素的Lookup方法。

1
func (t *Tree[T]) Lookup(x T) *Tree[T] { ... }

要使用泛型类型,必须进行实例化。Tree[string]是使用类型实参string实例化 Tree 的示例。

1
var stringTree Tree[string]

类型约束

普通函数中的每个参数都有一个类型; 该类型定义一系列值的集合。例如,我们上面定义的非泛型函数minFloat64那样,声明了参数的类型为float64,那么在函数调用时允许传入的实际参数就必须是可以用float64类型表示的浮点数值。

类似于参数列表中每个参数都有对应的参数类型,类型参数列表中每个类型参数都有一个类型约束。类型约束定义了一个类型集——只有在这个类型集中的类型才能用作类型实参。

Go语言中的类型约束是接口类型。

就以上面提到的min函数为例,我们来看一下类型约束常见的两种方式。

类型约束接口可以直接在类型参数列表中使用。

1
2
3
4
5
6
7
// 类型约束字面量,通常外层interface{}可省略
func min[T interface{ int | float64 }](a, b T) T {
if a <= b {
return a
}
return b
}

作为类型约束使用的接口类型可以事先定义并支持复用。

1
2
3
4
5
6
7
8
9
10
// 事先定义好的类型约束类型
type Value interface {
int | float64
}
func min[T Value](a, b T) T {
if a <= b {
return a
}
return b
}

在使用类型约束时,如果省略了外层的interface{}会引起歧义,那么就不能省略。例如:

1
2
3
4
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语言扩展了接口类型的语法,让我们能够向接口中添加类型。例如

1
2
3
type V interface {
int | string | bool
}

上面的代码就定义了一个包含 int、 string 和 bool 类型的类型集。

从 Go 1.18 开始,一个接口不仅可以嵌入其他接口,还可以嵌入任何类型、类型的联合或共享相同底层类型的无限类型集合。

当用作类型约束时,由接口定义的类型集精确地指定允许作为相应类型参数的类型。

  • |符号

    T1 | T2表示类型约束为T1和T2这两个类型的并集,例如下面的Integer类型表示由SignedUnsigned组成。

    1
    2
    3
    type Integer interface {
    Signed | Unsigned
    }
  • ~符号

    ~T表示所以底层类型是T的类型,例如~string表示所有底层类型是string的类型集合。

    1
    type MyString string  // MyString的底层类型是string

    注意:~符号后面只能是基本类型。

接口作为类型集是一种强大的新机制,是使类型约束能够生效的关键。目前,使用新语法表的接口只能用作类型约束。

any接口

空接口在类型参数列表中很常见,在Go 1.18引入了一个新的预声明标识符,作为空接口类型的别名。

1
2
3
// src/builtin/builtin.go

type any = interface{}

由此,我们可以使用如下代码:

1
2
3
func foo[S ~[]E, E any]() {
// ...
}

constraints package - golang.org/x/exp/constraints - Go Packages 包提供了一些常用类型。

类型推断

最后一个新的主要语言特征是类型推断。从某些方面来说,这是语言中最复杂的变化,但它很重要,因为它能让人们在编写调用泛型函数的代码时更自然。

函数参数类型推断

对于类型参数,需要传递类型参数,这可能导致代码冗长。回到我们通用的 min函数:

1
2
3
4
5
6
func min[T int | float64](a, b T) T {
if a <= b {
return a
}
return b
}

类型形参T用于指定ab的类型。我们可以使用显式类型实参调用它:

1
2
var a, b, m float64
m = min[float64](a, b) // 显式指定类型实参

在许多情况下,编译器可以从普通参数推断 T 的类型实参。这使得代码更短,同时保持清晰。

1
2
3
var a, b, m float64

m = min(a, b) // 无需指定类型实参

这种从实参的类型推断出函数的类型实参的推断称为函数实参类型推断。函数实参类型推断只适用于函数参数中使用的类型参数,而不适用于仅在函数结果中或仅在函数体中使用的类型参数。例如,它不适用于像 MakeT [ T any ]() T 这样的函数,因为它只使用 T 表示结果。

约束类型推断

Go 语言支持另一种类型推断,即约束类型推断。接下来我们从下面这个缩放整数的例子开始:

1
2
3
4
5
6
7
8
// 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方法。

1
2
3
4
5
6
type Point []int32

func (p Point) String() string {
b, _ := json.Marshal(p)
return string(b)
}

由于一个Point其实就是一个整数切片,我们可以使用前面编写的Scale函数:

1
2
3
4
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 函数,以便为切片类型使用类型参数。

1
2
3
4
5
6
7
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
}

我们引入了一个新的类型参数S,它是切片参数的类型。我们对它进行了约束,使得基础类型是S而不是[]E,函数返回的结果类型现在是S。由于E被约束为整数,因此效果与之前相同:第一个参数必须是某个整数类型的切片。对函数体的唯一更改是,现在我们在调用make时传递S,而不是[]E

现在这个Scale函数,不仅支持传入普通整数切片参数,也支持传入Point类型参数。

这里需要思考的是,为什么不传递显式类型参数就可以写入 Scale 调用?也就是说,为什么我们可以写 Scale(p, 2),没有类型参数,而不是必须写 Scale[Point, int32](p, 2) ?

新 Scale 函数有两个类型参数——S 和 E。在不传递任何类型参数的 Scale(p, 2) 调用中,如上所述,函数参数类型推断让编译器推断 S 的类型参数是 Point。但是这个函数也有一个类型参数 E,它是乘法因子 c 的类型。相应的函数参数是2,因为2是一个非类型化的常量,函数参数类型推断不能推断出 E 的正确类型(最好的情况是它可以推断出2的默认类型是 int,而这是错误的,因为Point 的基础类型是[]int32)。相反,编译器推断 E 的类型参数是切片的元素类型的过程称为约束类型推断

约束类型推断从类型参数约束推导类型参数。当一个类型参数具有根据另一个类型参数定义的约束时使用。当其中一个类型参数的类型参数已知时,约束用于推断另一个类型参数的类型参数。

通常的情况是,当一个约束对某种类型使用 ~type 形式时,该类型是使用其他类型参数编写的。我们在 Scale 的例子中看到了这一点。S 是 ~[]E,后面跟着一个用另一个类型参数写的类型[]E。如果我们知道了 S 的类型实参,我们就可以推断出E的类型实参。S 是一个切片类型,而 E是该切片的元素类型。

reference

Generics can make your Go code slower

Generics and Value Types in Golang