Go 语言设计者 Robert Griesemer 深入介绍泛型
Go 官方博客近日发表了一篇介绍新特性“泛型”的文章,作者是两位重量级人物 —— Robert Griesemer 和 Ian Lance Taylor,内容基于他们在 2021 年 GopherCon 大会上的演讲。
不久前正式发布的 Go 1.18 添加了对泛型的支持,据称泛型是 Go 开源以来所做的最大改变。泛型是一种编程范式,这种范式独立于所使用的特定类型,泛型允许在函数和类型的实现中使用某个类型集合中的任何一种类型。泛型为 Go 添加了三个新的重要内容:
面向函数和类型的“类型形参” (type parameters)
将接口类型定义为类型集合,包括没有方法的接口类型(type sets)
类型推断:在大多数情况下,在调用泛型函数时可省略“类型实参” (type arguments)
Type Parameters
现在函数和类型都具有类型形参” (type parameters),类型形参列表看起来就是一个普通的参数列表,除了它使用的是方括号而不是小括号。
先看一下基本的非泛型函数:
func Min(x, y float64) float64 {
if x < y {
return x
}
return y
}
通过添加类型形参列表来使这个函数泛型化——使其适用于不同的类型。在此示例中,添加了一个带有单个类型形参T的类型参数列表,并替换了 float64。
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
的作用称为实例化。编译中这个过程分为两个步骤:1. 编译器在泛型函数或泛型类型中用所有类型形参替换它们各自的类型实参;2. 编译器验证每个类型形参是否满足各自的约束。如果第二步失败,实例化就会失败。成功实例化后,非泛型函数就生成了,与其他普通函数的调用方式一样:
fmin := GMin[float64]
m := fmin(2.71, 3.14)
实例化 Gmin[float64]
会生成与普通的 Min(x,yfloat64)功能一样的函数。
类型参数也可以与 type
一起使用。
type Tree[T interface{}] struct {
left, right *Tree[T]
value T
}
func (t *Tree[T]) Lookup(x T) *Tree[T] { ... }
var stringTree Tree[string]
在上面的例子中,泛型 type Tree
存储了类型形参 T 的值。泛型类型也可以有方法,比如本例中的 Lookup。为了使用泛型类型,它必须被实例化;Tree[string] 就是是使用类型实参 string 来实例化 Tree类型。
Type sets
普通函数的每个形参都有一个类型;该类型定义了一组值。例如,如果我们有一个 float64 类型,就像上面非泛型函数 Min 中那样,允许的实参是由 float64 类型表示的浮点值。
同样,类型参数列表具有每个类型参数的类型(读起来有点绕)。但正是因为类型参数本身是一种类型,所以类型形参的类型定义了一组类型。这个元类型称为类型约束。
在泛型函数 GMin
中,类型约束是从包 constraints[1] 中导入的。Ordered 约束描述了所有类型的集合,这些类型的值可以被排序,或者换句话说,与这些类型可以使用操作符 <,<=,>
,等比较。约束确保只有具有可排序值的类型才能传递给 GMin
。这也意味着在 GMin
函数体中,可以使用该类型形参的值与<操作符进行比较。
在 Go 中,类型约束必须是接口。也就是说,接口类型可以用作值类型,也可以用作元类型(meta-type)。接口定义了方法,因此显然我们可以表达需要特定方法出现的类型约束。但约束 Ordered
也是接口类型。
我们可以换个角度来理解 inerface。
方法集可以看出,接口定义了一组类型集合,即实现这些方法的类型。从这个角度来看,作为接口类型集合元素的任何类型都实现了接口。
从这两个图中我们可以得出一个结论,对于每一组方法,我们可以想象成实现这些方法的相应类型集合,这就是接口定义的类型集。但是,以“类型”的方式操作比方法设置具有优势:我们可以将类型添加到集合中,以一种新的方法控制它们。为此,我们扩展了接口类型的语法。例如,interface{int|string|bool}
定义了包含 int、string 和 bool 类型的类型集合。
也可以说这个接口只对 int、string 或 bool 类型生效。
此时 contraints.Ordered
的实际定义是:
type Ordered interface {
Integer|Float|~string
}
这个定义的含义是,Ordered interface
是所有 int、float 和 string 类型的集合。|
表示类型的并集(在本例中为类型集合)。Integer 和 Float 是在约束包中类似定义的接口类型。注意,Ordered 接口没有定义方法。
对于类型约束,我们通常不关心特定的类型,比如string;我们对所有字符串类型都感兴趣。这就是~
标记的作用。表达式 ~string
表示基础类型为 string
的所有类型的集合。这包括类型字符串本身以及用定义声明的所有类型,如 type MyString string
。
当然,我们仍然希望在接口中指定方法,并且希望向后兼容。在 Go 1.18 中,接口可以像以前一样包含方法和嵌入接口,但它也可以嵌入非接口类型、联合和底层类型集。
用作约束的接口可以指定名称(如Ordered),也可以直接内联在类型参数列表中使用。例如:
[S interface{~[]E}, E interface{}]
这里 S 必须是一个切片类型,其元素类型可以是任何类型。
因为这是一种很常用的使用方式,对于处于约束位置的接口,我们可以写成:
[S ~[]E, E interface{}]
因为空 interface 在类型参数列表和普通的 Go 代码中都很常见,所以 Go 1.18引入了一个新的预先声明的标识符 any
,作为空接口类型的别名。这样我们就可以这样写:
[S ~[]E, E any]
接口作为类型集是一种强大的新机制,是类型约束的关键技术。
Type inference
最后一个新的主要语言特性是类型推断。这是为了支持泛型而做的最复杂的工作,但它可以支持开发者以最自然的方式编写调用泛型函数的代码。
函数参数类型推断 (Function argument 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) // no type argument
这是通过将参数 a 和 b 的类型与参数 x 和 y 的类型进行推导匹配来实现的。
这种从函数的实参类型推断出类型参数的方式称为函数参数类型推断。
另外,类型推导只对函数形参生效,函数体或者返回值的类型无法推断,比如 MakeT[T any]() T
这样的函数,该函数只使用 T 作为返回值。
约束类型推断 (Constraint type inference)
另一种类型推断是约束类型推断。看一下下面这个例子:
// Scale returns a copy of s with each element multiplied by c.
// This implementation has a problem, as we will see.
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
只是一个给出点坐标的整数列表。
type Point []int32
func (p Point) String() string {
// Details not important.
}
如果我们想要缩放一个点,因为 Point
只是一个整数的切片,所以我们可以使用之前编写的 Scale
泛型函数。
// ScaleAndPrint doubles a Point and prints it.
func ScaleAndPrint(p Point) {
r := Scale(p, 2)
fmt.Println(r.String()) // DOES NOT COMPILE
}
但是这段代码不能编译通过,会报 r.String undefined (type []int32 has no field or method String)
的错误。
问题在于 Scale
函数返回一个类型为 []E
的值,其中 E
是参数 slice 的元素类型。当使用基础类型为 []int32
的 Point
类型值调用 Scale
时,返回的值为 []int32
类型,而不是 Point
类型。
为了解决这个问题,我们使用类型参数作为切片的类型,修改 Scale 函数。
// Scale returns a copy of s with each element multiplied by c.
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,它是 slice 参数的类型。并对它进行了约束,使其基础类型是 S 而不是 []E ,返回值类型也是S。因为 E 被约束为整数,所以效果与之前相同:第一个参数必须是某个整数类型的切片。
所以,约束类型推断从类型参数约束推导类型参数。当一个类型参数有一个根据另一个类型参数定义的约束时,就使用它。当其中一个类型参数的类型已知时,就使用约束来推断另一个类型参数(这里译者水平有限,感觉不能讲出作者的本意,可以阅读 proposal document[2] 和 language spec[3] 来深入了解)。
Type inference in practice
虽然类型推断的工作原理细节很复杂,但使用起来比较简单:类型推断要么成功,要么失败。如果它成功,类型实参可以被省略,泛型函数的调用与普通函数是一样的。如果类型推断失败,编译器将会报错,在这种情况下,只需要提供必要的类型实参。
Conclusion
泛型是 Go 1.18 的最重要语言特性,Robert Griesemer 和 Ian Lance Taylor 表示,这个功能实现得很好并且质量很高。虽然他们鼓励在有必要的场景中使用泛型以减少冗余代码,但在生产环境中部署泛型代码时,还是要谨慎小心。
原文地址[4]
参考资料
[1]package constraints: https://golang.org/x/exp/constraints
[2]proposal docs: https://go.googlesource.com/proposal/+/HEAD/design/43651-type-parameters.md
[3]language spec: https://go.dev/ref/spec
[4]原文地址: https://go.dev/blog/intro-generics
官方资讯*最新技术*独家解读