介绍
这篇博文基于我们在 GopherCon 2021 上的演讲:
Go 1.18 版本增加了对泛型的支持。泛型是自第一个开源版本以来我们对 Go 所做的最大改变。在本文中,我们将介绍新的语言功能。我们不会试图涵盖所有细节,但我们会触及所有要点。有关更详细和更长的描述,包括许多示例,请参阅提案文档。有关语言更改的更准确描述,请参阅 更新的语言规范。(请注意,实际的 1.18 实施对提案文件允许的内容施加了一些限制;规范应该是准确的。未来的版本可能会取消一些限制。)
泛型是一种编写代码的方式,它独立于所使用的特定类型。现在可以编写函数和类型以使用一组类型中的任何一种。
泛型为语言添加了三个新的重要内容:
-
函数和类型的类型参数。
-
将接口类型定义为类型集,包括没有方法的类型。
-
类型推断,允许在调用函数时在许多情况下省略类型参数。
类型参数
现在允许函数和类型具有类型参数。类型参数列表看起来像一个普通的参数列表(除了它使用方括号而不是圆括号)。
Min 函数
为了展示泛型是如何工作的,让我们从浮点值的基本非泛型函数开始:
func Min(x, y float64) float64 {
if x < y {
return x
}
return y
}
我们可以通过添加类型参数列表来使这个函数通用,其实就是使其适用于不同的类型。在这个示例中,我们添加了一个带有单个类型参数的类型参数列表T
,然后用T来替换float64
。
func GMin[T constraints.Ordered](x, y T) T {
if x < y {
return x
}
return y
}
现在可以通过编写类似的调用来使用类型参数调用此函数
x := GMin[int](2, 3)
GMin
在这种情况下,向 提供类型参数int
称为 实例化。实例化分两步进行。首先,编译器在泛型函数或类型中用所有类型参数替换它们各自的类型参数。其次,编译器验证每个类型参数是否满足各自的约束。我们很快就会明白这意味着什么,但是如果第二步失败,实例化就会失败并且程序无效。
成功实例化后,我们有一个可以像任何其他函数一样调用的非泛型函数。例如,在类似的代码中
fmin := GMin[float64]
m := fmin(2.71, 3.14)
实例化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的
的示例。
类型集
让我们更深入地了解可用于实例化类型参数的类型参数。
普通函数对每个值参数都有一个类型;该类型定义了一组值。例如,上面提到的非泛型函数Min,它的参数是float64类型的
,所以允许的参数值集是可以由 float64 类型表示的浮点值集。
同样,类型参数列表对每个类型参数都有一个类型。因为类型参数本身就是一种类型,所以类型参数的类型定义了类型集。这种元类型称为 类型约束。
在泛型函数 GMin
中,类型约束是从 constraints 包中导入的。Ordered约束描述了具有可以排序的值的所有类型的集合,或者换句话说,可以通过 < 运算符(或 <= 、 > 等)进行比较。该约束确保只有具有可排序值的类型才能传递给GMin
. 这也意味着在GMin
函数体中该类型参数的值可以用于与 < 运算符进行比较。
在 Go 中,类型约束必须是接口。即接口类型可以作为值类型,也可以作为元类型。接口定义了方法,因此我们可以表达需要某些方法存在的类型约束。但是constraints.Ordered
也是接口类型,< 操作符却不是方法。
为了完成这项工作,我们以一种新的方式看待接口。
直到最近,Go 规范才说接口定义了一个方法集,大致就是接口中枚举的方法集。实现所有这些方法的任何类型都实现了该接口。
但另一种看法是,接口定义了一组类型,即实现这些方法的类型。从这个角度来看,作为接口类型集元素的任何类型都实现了该接口。
这两个视图导致相同的结果:对于每组方法,我们可以想象实现这些方法的相应类型集,即接口定义的类型集。
但是,就我们的目的而言,类型集视图比方法集视图具有优势:我们可以显式地将类型添加到集合中,从而以新的方式控制类型集。
我们扩展了接口类型的语法以使其工作。例如,interface{ int|string|bool }
定义包含类型int
、string
和bool的类型集
。
另一种说法是这个接口只满足int
, string
, 或bool
。
现在让我们看一下 的实际定义contraints.Ordered
:
type Ordered interface {
Integer|Float|~string
}
这个声明说Ordered
接口是所有整数、浮点数和字符串类型的集合。竖线表示类型的联合(或本例中的类型集)。 Integer 和
Float
中是定义在包constraints中的的接口类型。请注意,接口Ordered中没有定义任何方法。
对于类型约束,我们通常不关心特定类型,例如string
; 我们对所有字符串类型都感兴趣,这就是 ~符号
的用途。表达式~string
表示基础类型为 string的所有类型的集合。这包括类型string
本身以及所有使用定义声明的类型,例如type MyString string
.
当然,我们仍然希望在接口中指定方法,并且我们希望向后兼容。在 Go 1.18 中,接口除了可以像以前一样包含方法和嵌入式接口外,也可以嵌入非接口类型、联合和底层类型集。
当用作类型约束时,接口定义的类型集准确地指定了允许作为相应类型参数的类型参数的类型。在泛型函数体内,如果操作数的类型是具有约束 C 的类型参数 P,则在 C 的类型集中的所有类型都允许的情况下允许操作(这里目前有一些实现限制,但普通代码不太可能遇到他们)。
用作约束的接口可以被赋予名称(例如Ordered
),或者它们可以是内联在类型参数列表中的文字接口。例如:
[S interface{~[]E}, E interface{}]
这里S必须是一个切片类型,其元素类型可以是任何类型。
interface{}
因为这是一种常见的情况,约束位置的接口可以省略包围,我们可以简单地写:
[S ~[]E, E interface{}]
因为空接口在类型参数列表和普通 Go 代码中很常见,所以 Go 1.18 引入了一个新的预声明标识符any作为空接口类型的别名。有了这个,我们得到了这个惯用的代码:
[S ~[]E, E any]
作为类型集的接口是一种强大的新机制,是使类型约束在 Go 中起作用的关键。目前,使用新语法形式的接口只能用作约束。但不难想象,明确类型约束的接口在一般情况下会有多大用处。
类型推断
最后一个新的主要语言特性是类型推断。在某些方面,这是对语言最复杂的更改,但它很重要,因为它让人们在编写调用泛型函数的代码时可以使用自然风格。
函数参数类型推断
使用类型参数需要传递类型参数,这可能会产生冗长的代码。回到我们的通用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 作为结果的函数。
约束类型推断
该语言支持另一种类型推断,即约束类型推断。为了描述这一点,让我们从这个缩放整数切片的例子开始:
// 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
是参数切片的元素类型。当我们使用 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
,它是切片参数的类型。我们已经对其进行了约束,使得基础类型是S
而不是 []E
,现在结果类型是 S
。由于E
被限制为整型,因此效果与之前相同:第一个参数必须是某个整数类型的切片。函数体的唯一变化是:当我们调用make时
,我们传递S
,而不是[]E
,。
如果我们用普通切片调用新函数,它的作用与以前相同,但如果我们用 Point 类型调用它,我们现在会返回一个 Point 类型的值。这就是我们想要的。在这个版本的 Scale 中,早期的 ScaleAndPrint 函数将按照我们的预期编译和运行。
但公平地问:为什么在不传递显式类型参数的情况下编写对 Scale 的调用是可以的?也就是说,为什么我们可以写 Scale(p, 2),没有类型参数,而不是写 Scale[Point, int32](p, 2)?我们的新 Scale 函数有两个类型参数,S 和 E。在调用 Scale 时不传递任何类型参数,函数参数类型推断,如上所述,让编译器推断 S 的类型参数是 Point。但是该函数也有一个类型参数 E。编译器推断 E 的类型参数是切片的元素类型的过程称为约束类型推断。
约束类型推断从类型参数约束中推导出类型参数。当一个类型参数具有根据另一个类型参数定义的约束时使用它。当其中一个类型参数的类型参数已知时,约束用于推断另一个类型参数的类型参数。
这适用的通常情况是,当一个约束对某种类型使用 ~type 形式时,该类型是使用其他类型参数编写的。我们在 Scale 示例中看到了这一点。S 是 ~[]E,它是 ~ 后跟一个类型 []E,用另一个类型参数编写。如果我们知道 S 的类型参数,我们可以推断出 E 的类型参数。S 是切片类型,E 是该切片的元素类型。
这只是对约束类型推断的介绍。有关完整的详细信息,请参阅提案文档 文档或语言规范。
实践中的类型推断
类型推断如何工作的确切细节很复杂,但使用它并不复杂:类型推断要么成功,要么失败。如果成功,可以省略类型参数,调用泛型函数看起来与调用普通函数没有什么不同。如果类型推断失败,编译器将给出错误消息,在这种情况下,我们可以只提供必要的类型参数。
在向语言添加类型推断时,我们试图在推断能力和复杂性之间取得平衡。我们希望确保当编译器推断类型时,这些类型永远不会令人惊讶。我们试图小心地在未能推断类型而不是推断错误类型方面犯错。我们可能还没有完全正确,我们可能会在未来的版本中继续完善它。结果将是可以编写更多程序而无需显式类型参数。今天不需要类型参数的程序明天也不需要它们。
结论
泛型是 1.18 中一个很大的新语言特性。这些新的语言更改需要大量未在生产环境中进行过重大测试的新代码。这只会在更多人编写和使用通用代码时发生。我们相信这个功能实现得很好并且质量很高。然而,与Go的大多数方面不同,我们不能用现实世界的经验来支持这种信念。因此,虽然我们鼓励在有意义的地方使用泛型,但在生产环境中部署泛型代码时请谨慎行事。
除了这个谨慎之外,我们很高兴能有泛型可用,我们希望它们能让 Go 程序员更有效率。
翻译自:https://go.dev/blog/intro-generics
视频:https://www.youtube.com/watch?v=Pa_e9EeCdy8
文章评论