介绍#
这是我在 Google Open Source Live 以及 GopherCon 2021 上演讲的博文版本:
Go Day 2021 on Google Open Source Live_哔哩哔哩_bilibili
泛型 Generics! - Robert Griesemer & Ian Lance Taylor - GopherCon 2021_哔哩哔哩_bilibili
Go 1.18 release 增加了一个重大新语言特性:对泛型编程的支持。在这篇文章中,我不会去讲什么是泛型,或是怎么样使用泛型。这篇文章主要阐述何时在 Go 中使用泛型,何时不应该使用。
澄清一下,我会提供一般的指导原则,而不是硬性规则。遵从你的判断。但如果你不确定,我建议使用这里讨论的指导原则。
编写代码#
让我们从编写 Go 的一般准则开始:通过编写代码而不是定义类型来编写 Go 程序。到了泛型这里,如果你从定义类型参数约束开始编写你的 Go 程序,你的方向可能错了。从写函数开始。在添加类型参数很有用时再添加也很容易。
何时类型参数是有用的?#
也就是说,让我们看看类型参数对哪些情况有用。
使用语言预定义的容器类时#
一种用例是写的函数操作由语言定义的特定容器类时:切片,map 以及 channel。如果一个函数由这几种类型的参数,并且函数代码不会对元素类型做任何假设,那么用类型参数(泛型)会很有用。
例如,有一个函数返回切片,该切片包含了一个任意类型 map 中所有的键:
// MapKeys returns a slice of all the keys in m.
// The keys are not returned in any particular order.
func MapKeys[Key comparable, Val any](m map[Key]Val) []Key {
s := make([]Key, 0, len(m))
for k := range m {
s = append(s, k)
}
return s
}
这段代码不会假设 map 键的类型,也完全不使用值类型。它对所有 map 类型都有效。这就是使用类型参数的一个很好的情况。
This code doesn't assume anything about the map key type, and it doesn’t use the map value type at all. It works for any map type. That makes it a good candidate for using type parameters.
这种函数的类型参数替代方案一般是使用反射,但那是更为笨重的编程模型,编译时不进行静态类型检查,在运行时一般也更慢一些。
泛用数据结构#
可以用类型参数的另外一种情况时泛用数据结构。泛用数据结构时像切片或 map 这样,但不是语言自带的,比如链表或二叉树。
现在,需要这种数据结构的程序一般只有两个选择:将它们写成固定元素类型,或是使用接口类型。用类型参数替代特定类型可以生成更为通用的数结构,可以用在程序的其他部分或是其他程序中。用类型参数替代接口类型让数据能更有效地存储,节省内存资源;还可以让代码避免类型断言,在编译时接收全面的类型检查。
例如,这可能是用类型参数的二叉树数据结构的一部分:
// Tree is a binary tree.
type Tree[T any] struct {
cmp func(T, T) int
root *node[T]
}
// A node in a Tree.
type node[T any] struct {
left, right *node[T]
val T
}
// find returns a pointer to the node containing val,
// or, if val is not present, a pointer to where it
// would be placed if added.
func (bt *Tree[T]) find(val T) **node[T] {
pl := &bt.root
for *pl != nil {
switch cmp := bt.cmp(val, (*pl).val); {
case cmp < 0:
pl = &(*pl).left
case cmp > 0:
pl = &(*pl).right
default:
return pl
}
}
return pl
}
// Insert inserts val into bt if not already there,
// and reports whether it was inserted.
func (bt *Tree[T]) Insert(val T) bool {
pl := bt.find(val)
if *pl != nil {
return false
}
*pl = &node[T]{val: val}
return true
}
树的每个节点包含一个类型参数 T 的值。当树用一个特定类型实例化时,该类型的值会直接存储在节点中。不会被存储为接口类型。
这是类型参数的合理使用,因为树数据结构,以及方法中的代码,很大程度上是独立于元素类型 T 的。
树数据结构不需要直到如何比较元素类型 T 的值;它用一个传入地比较函数实现它。可以看到find方法的第四行,调用bt.cmp。除此之外,类型参数不会有任何影响。
对于类型参数,首选函数而不是方法#
Tree的例子描述了另一种通用准则:当你需要像比较函数这样的东西时,首选函数而不是方法。
我们可以定义 Tree 类型,这样元素类型就必须有一个 Compare 或 Less 方法。这可以通过编写一个需要该方法的约束来实现,这意味着用于实例化 Tree 类型的任何类型参数都需要该方法。
结果是任何想用带着像 int 这样简单类型的Tree的用户都不得不定义他们自己的整数类型并编写他们自己的比较方法。如果我们像上面所示的代码那样定义 Tree 以获取比较函数,那么很容易传递所需的函数。编写比较函数和编写方法一样容易。
如果 Tree 的元素类型一开始就有 Compare 方法,那么我们可以简单的用ElementType.Compare这样的表达式作为比较函数。
换句话说,将方法转换为函数要比将方法添加到类型中简单得多。因此,对于通用数据类型,首选使用函数而不是编写需要方法的约束。
实现一个通用方法#
另外一种使用类型参数很有用的情况是不同类型需要实现相同通用方法,不同类型的方法实现看起来都一样的时候。
例如,标准库中的 sort.Interface。它需要类型实现三个方法Len、Swap以及Less。
这是泛型类型SliceFn的例子,它实现所有切片类型的 sort.Interface:
// SliceFn implements sort.Interface for a slice of T.
type SliceFn[T any] struct {
s []T
less func(T, T) bool
}
func (s SliceFn[T]) Len() int {
return len(s.s)
}
func (s SliceFn[T]) Swap(i, j int) {
s.s[i], s.s[j] = s.s[j], s.s[i]
}
func (s SliceFn[T]) Less(i, j int) bool {
return s.less(s.s[i], s.s[j])
}
对任何切片类型而言,Len 和 Swap 方法是完全相同的。Less 方法需要比较,也就是 SliceFn 中的 Fn 部分。像之前的Tree例子一样,在创建一个 SliceFn 的时候,将传入一个函数。
下面是如何使用 SliceFn 用比较函数排序任意切片:
// SortFn sorts s in place using a comparison function.
func SortFn[T any](s []T, less func(T, T) bool) {
sort.Sort(SliceFn[T]{s, less})
}
这于标准库函数 sort.Slice 相同,但比较函数是用值来写的,而非切片索引。
对这类代码使用类型参数是合适的,因为所有切片类型的方法看起来完全相同。
(我应该指出的是,Go 1.19(而不是 1.18)很可能会包含一个通用函数,用比较函数对切片进行排序,而该通用函数很可能不使用 sort.Interface。参见提议 #47619。但是,即使这个特定的示例很可能没有多大用处,普遍的观点仍然成立:当你需要实现的方法对其所有相关类型看起来都相同时,使用类型参数是合理的。)
何时类型参数没有用?#
现在让我们聊聊这个问题的另一面:何时不使用类型参数。
不要用类型参数替代接口#
众所周知,Go 有接口类型。接口允许允许一种泛型编程。例如,广泛使用的 io.Reader 接口提供了一种通用机制,用于从包含信息(例如文件)或产生信息(例如随机数生成器)的任何值中读取数据。如果对某个类型的值只需要调用该值的方法,则使用接口类型,而不是类型参数。io.Reader 易于读取、高效且有效。不需要类型参数,再通过调用其 Read 方法从值中读取数据。
例如,将此处仅使用接口类型的第一个函数签名更改为使用类型参数的第二个版本可能很有吸引力。
func ReadSome(r io.Reader) ([]byte, error)
func ReadSome[T io.Reader](r T) ([]byte, error)
不要做出那种改变。省略类型参数使函数更容易编写,阅读,并且执行时间可能相同。
最后一点值得强调。虽然可以用几种不同的方式实现泛型,而且随着时间的推移,实现也会发生变化和改进,但在许多情况下,Go 1.18 中使用的实现将处理类型为类型参数的值,就像处理类型为接口类型的值一样。这意味着使用类型参数通常不会比使用接口类型快。因此,不要为了速度而从接口类型更改为类型参数,因为它可能不会运行得更快。
如果方法实现不同,不要使用类型参数#
当决定是用类型参数还是接口类型时,考虑一下方法的实现。先前我们说如果对所有类型而言,实现方法都相同,使用类型参数。相反,如果实现不同,就用接口类型,并编写不同的接口实现,不要使用类型参数。
例如,从文件读取的实现与从随机数生成器读取的 Read 实现完全不同。这意味着我们应该编写两个不同的 Read 方法,并使用像 io.Reader 这样的接口类型。
适时使用反射#
Go 有运行时反射。反射允许一种泛型编程,因为它允许您编写适用于任何类型的代码。
如果某些操作甚至必须支持没有方法的类型(因此接口类型没有帮助),并且每个类型的操作都不同(因此类型参数不合适),请使用反射。
encoding/json 包就是一个例子。我们不想让我们编码的每个类型都有 MarshalJSON 方法,所以我们不能使用接口类型。但对接口类型的编码与对结构类型的编码不同,因此我们不应该使用类型参数。相反,该包使用反射。代码不简单,但它有效。有关详细信息,请参阅源码。
一个简单的准则#
最后,关于何时使用泛型的讨论可以简化为一个简单的准则。
如果你发现自己多次编写完全相同的代码,其副本之间的唯一区别是代码使用不同的类型,请考虑是否可以使用类型参数。
另一种角度说,在注意到要多次编写完全相同的代码之前,应避免使用类型参数。