小马别过河
2022/10/07阅读:66主题:默认主题
Go 概念[译]:泛型介绍
Go 1.18 版本增加了对泛型的支持。泛型是我们自 Go 开源以来最大的变动。本文介绍这个新功能。我们会介绍要点,而不是涵盖所有细节。如果想要所有细节和更多描述,包括例子,可以查看 proposal document。查阅 updated langue spec 获取更准确的描述。
泛型是一种独立于已有的特定类型的编码方式。编写函数和类型可以使用类型集的其中一个。
泛型为 Go 语言添加了3个重大的功能:
-
函数和类型的类型参数 -
接口作为类型集,包括没有方法的类型。 -
类型推断,当调用函数时,它允许在大多情况下忽略类型参数。
Type Parameters(类型参数)
函数和类型如今允许有类型参数
。一个类型参数列表看起来像普通的参数列表,只不过他使用中括号,而不是小括号。
为了展示它如何工作的,我们从一个作用于浮点数的非泛型函数 Min
开始:
func Min(x, y float64) float64 {
if x < y {
return x
}
return y
}
我们可以增加类型参数,让这个函数泛化,使其支持不同类型的参数。在这个例子里,我们增加一个类型参数 T
,然后把 float64
替代成 T
。
import "golang.org/x/exp/constraints"
func GMin[T constraints.Ordered](x, y T) T {
if x < y {
return x
}
return y
}
现在可以附带一个类型参数,调用这个这个函数,像这样:
x := GMin[int](2, 3)
给GMin
提供类型参数(本例中是int
)的过程,叫做"实例化"(instantiation
)。实例化分为两步。第一,编译器会把泛型函数或类型的类型参数,替换成各自的类型。第二,编译器验证类型参数是否满足条件约束,这个后面会讲,如果第二步失败了,实例化失败,程序也会编译不通过。
实例化成功以后,我们得到一个非泛化的普通函数。比如
fmin := GMin[float64]
m := fmin(2.71, 3.14)
实例化GMin[float64]
得到的fmin
,和Min(x, y float64)
效果一样,我们可以在函数调用中使用它。
类型参数也可以用在定义类型的场景。
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
。泛型可以有用方法,像例子中的LoopUp
一样。使用泛型前,必须先实例化。Tree[string]
就是使用string
实例化Tree
。
类型集(Type Sets)
我们深入一点,类型实参可以用作实例化类型参数.
普通函数的每个参数都有各自的类型。比如,上面的非泛型函数Min
的参数是float64
,允许传入的参数值都必须是浮点的。
同样的,类型参数本身是个类型,它的定义了一系列的类型。这个元类型(meta-type)称作类型约束
(type constraint)。
泛型函数GMin
中,类型约束(constraints.Ordered)是从 constraints
包导入的。Ordered
约束描述了可以被排序的一系列类型,可排序意味着可比较(< ,<=,> 等操作符)。这个类型约束可以确保传给GMin
的参数值都是可比较的。这也就是说GMin
函数体用到的类型参数可用作比较。
Go 语言中,类型约束必须是接口(interface)。一个接口类型可以作为值类型,也可以作为元类型。接口定义了方法集,所以显然类型约束也需要实现对应的方法。但是constraints.Ordered
是个接口,而操作符 < 不是一个方法。
为了解决这个问题,我们扩展了接口。
最新 Go 规格规定,接口定义了一个方法集(method set)。任何类型实现了这些方法,也实现了接口。

换个角度看,接口定义了类型集(type set),实现了方法的类型,都可以算作类型集的一员。

这两个角度引出同一个结果:对于每个方法集,我们都可以想象成实现对应方法的类型集,也是被接口定义的类型集。
不过,对于我们而言,类型集的视角比方法集的视角更具优势:我们可以显式地增加类型到类型集,从而以新的方式控制类型集。
我们扩展了接口的语法以达到目的。比如,interface{int|string|bool}
定义了包含了int
、string
、bool
的一个类型集。

换种说法就是只有int
,string
,bool
才能满足这个接口。
我们看下 constraints.Ordered
的定义:
type Ordered interface {
Integer|Float|~string
}
这个声明说的是,Ordered
接口是所有的整形、浮点型、字符串类型的集合。竖线表示并集。Integer
、Float
也是定义在constraints
包的接口类型。注意的是Ordered
接口是没有定义任何方法的。
对于类型约束,我们通常不关心具体的类型,如string
。我们感兴趣的是所有的string
类型。这就是~
的作用。表达式~string
意味着底层是string
类型的所有类型的集合。包括string
本身和其他所有像 type MyString string
这种自定义的类型。
当然我们依然希望在接口中定义方法,并且我们希望向后兼容。在 Go 1.18 一个接口可以像之前一样包含方法和内嵌其他接口,不同的是,它还能内嵌非接口类型,并集,和底层类型集。
当我们使用类型约束,接口规定的类型集明确哪些类型参数是被允许的。在泛型的函数体,如果参数是 P
,约束是 C
, 对 P
的操作必须是适用于所有 C
类型集。
被用作约束的接口可能会定义接口名(比如 Ordered
),或者是字面量的形式用在参数列表。比如
[S interface{~[]E}, E interface{}]
这里 S 必须是个切片,切片元素的类型不做限制。
因为这很常见,所以用作约束条件的接口 interface{}
可以省略,也就是简化成:
[S ~[]E, E interface{}]
因为空接口在类型参数和普通代码中很常见,Go 1.18 预声明了 any
作为空接口的别名。有了它,上面的代码我们可以写作:
[S ~[]E, E any]
接口作为类型集是一种强大的机制,也是 Go 的类型约束的关键。现在,使用新语法的接口只能用作类型约束。但是不难想象,明确了类型约束的接口将会在泛型中发挥多大的用处。
类型推断 (Type inference)
最新的主要语言特性是类型推断。从某个角度上说,这是最复杂的语言变更,但是它让人们在写代码调用泛型函数时,使用比较自然的风格。
函数参数类型推断
使用类型参数(形参)需要传递类型参数(实参),这会使代码冗余。回到我们上文提过的 GMin
泛型函数:
func GMin[T constraints.Ordered](x, y T) T { ... }
类型参数 T
用作定义 x、 y。正如我们早前说的,调用时可以传入具体的类型。
var a, b, m float64
m = GMin[float64](a, b) // explicit type argument
在很多场景编译器可以从普通参数推断出 T 的类型参数。这可以使代码更短,同时保持清晰。
var a, b, m float64
m = GMin(a, b) // 没有类型参数
根据函数实参来推断形参的类型的机制,称之为函数参数类型推断
(function argument type inference)
函数参数类型推断仅用于函数参数,不作用于函数返回值或者只在函数体的类型参数。比如,它不适用于像 MakeT[T any]() T
只使用 T 作为结果的函数。
约束类型推断(Constraint type inference)
Go 语言支持另一种类型推断,约束类型推断。为了描述这些,我们来看看另一个例子:
// Scale 返回一个 s 的复制,s 的所有元素乘以 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
}
这个泛型函数用于所有整形的切片。
假设我们有一个底层是 []int32
的 Point
类型,同时有着自定义方法。
type Point []int32
func (p Point) String() string {
// Details not important.
}
既然 Point
是一个整形的切片,我们可以把它传给 Scale
函数:
// ScaleAndPrint 把 Point 的元素乘以 2,再打印
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 是切片元素的类型。当我们调用 Scale(Point)
时,底层类型是 []int32
,我们返回的类型是 []int32
,而不是 Point
。这并不是我们预期的。
为了解决这个问题,我们增加 Scale
函数的切片参数的类型参数。
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
的底层类型是 S
而不是 []E
,返回值是 S
。既然 S
的约束是整数,效果和以前一样:第一个参数必须是整形切片。唯一的区别是,函数体我们把make([]E, len(s))
改成make(S, len(s))
。
如果参数是普通切片,新旧函数的表现是一样的,但如果我们传递的参数是 Point
,新的函数返回值是 Point
。这是符合我们预期的。使用新版本的 Scale
,ScaleAndPrint
可以编译通过并如我们预期地运行。
公平地问一句:为什么不显示地指明类型参数还能成功调用 Scale
? 也就是说,为什么我们可以写Scale(p, 2)
,而不是写Scale[Point, int32](p, 2)
?我们的新版Scale
函数有两个参数:S
和E
。调用Scale
没有传递任何类型参数,编译器的函数参数类型推断推断出 S
的类型是 Point
。但是函数还有一个类型参数E
,用于约束乘数c
。对应的函数值是 2,由于 2 无类型的常量,函数参数类型推断机制无法检测出E
的当前类型(最多推断出 2 的默认类型 int
,但这是错误的)。相反,编译器推断E
是切片的元素类型的过程,称作约束类型推断。
约束类型推断是从类型参数约束推导出类型参数。当一个类型参数是由另一个类型参数定义的时候,会用到它。当类型参数中的一个是已知的,约束会通过它推断出其他参数的类型。
这适用的其中一个场景是,当一个约束使用 ~type
定义某种类型,然后这个约束被用在另一个类型约束上。比如在 Scale
中,S
是 ~[]E
,E
是定义在另一个参数上。如果我们知道 S
的类型,就可以推断出 E
的类型。S
是一个切片类型,E
是切片的元素。
实践中的类型推断
类型推断的细节非常复杂,但是使用起来很简单:只有成功或失败。如果推断成功,类型参数可以忽略,此时可以像调用普通函数一样调用泛型函数。如果推断失败,编译器会报错,我们据此调整就行。
增加类型推断时,我们试着在推断能力和复杂性之间取得平衡。我们想要确保,编译器推断类型时,不会出现意外的类型。我们注意在推断不出时报错,而不是推断出错误的类型。可能目前不是十全十美,但是我们会将来还会进一步优化。结果将是更多的程序无需指明类型参数。目前已经不用指明类型参数的程序,将来也不用。
结论
泛型是 1.18 的重大新特性。这些新语言变更需要经过生产环境的测试。这需要更多的人编写和使用泛型代码。我们相信,这个特性实现良好且质量高。然而,不像 Go 的其他方面,我们无法实际生产经验来支持这种信念。因此,虽然我们鼓励在合适的地方使用泛型,但也请在生产环境中也请小心谨慎。
抛开谨慎的说法,我们也很激动有泛型可用,希望它能使 Go 开发者更加高效。
引用
[1] An Introduction To Generics(https://go.dev/blog/intro-generics)
作者介绍