这是我在Google开源直播上演讲的博客文章版本:
https://youtu.be/nr8EpUO9jhw?si=jlWTapr6NM6isLgt&embedable=true
以及GopherCon 2021:
https://youtu.be/Pae9EeCdy8?si=M-87Eisb2nU1qmJ&embedable=true
Go 1.18版本增加了一个重要的新语言特性:对泛型编程的支持。在本文中,我不会描述什么是泛型以及如何使用它们。本文是关于何时在Go代码中使用泛型,以及何时不使用它们。
需要明确的是,我将提供一般性指导,而非严格的规则。请使用您自己的判断。但如果您不确定,我建议使用这里讨论的指导原则。
让我们从编程Go的一般指导原则开始:通过编写代码来编写Go程序,而不是通过定义类型。当涉及到泛型时,如果您开始通过定义类型参数约束来编写程序,那么您可能走错了路。从编写函数开始。当明确它们将会有用时,再添加类型参数很容易。
话虽如此,让我们看看类型参数可能有用的情况。
一种情况是编写操作语言定义的特殊容器类型的函数:切片、映射和通道。如果函数有这些类型的参数,并且函数代码对元素类型没有特别的假设,那么使用类型参数可能会有用。
例如,这里有一个函数,它返回任何类型的映射中所有键的切片:
// MapKeys返回m中所有键的切片。
// 键的返回顺序不确定。
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
}
这段代码对映射键类型没有任何假设,也完全不使用映射值类型。它适用于任何映射类型。这使它成为使用类型参数的好候选者。
对于这种函数,类型参数的替代方案通常是使用反射,但这是一个更笨拙的编程模型,在构建时不会进行静态类型检查,并且在运行时通常更慢。
类型参数可能有用的另一种情况是通用数据结构。通用数据结构类似于切片或映射,但不是内置在语言中的,例如链表或二叉树。
如今,需要此类数据结构的程序通常会做两件事之一:使用特定元素类型编写它们,或使用接口类型。用类型参数替换特定元素类型可以产生更通用的数据结构,可以在程序的其他部分或其他程序中使用。用类型参数替换接口类型可以使数据更有效地存储,节省内存资源;它还可以使代码避免类型断言,并在构建时进行完全类型检查。
例如,这里是使用类型参数的二叉树数据结构可能的部分样子:
// Tree是一个二叉树。 type Tree[T any] struct { cmp func(T, T) int root *node[T] }// Tree中的节点。 type node[T any] struct { left, right *node[T] val T }
// find返回包含val的节点的指针, // 或者,如果val不存在,返回它将被添加的位置的指针。 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将val插入bt(如果尚未存在), // 并报告它是否被插入。 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的值。当树用特定类型参数实例化时,该类型的值将直接存储在节点中。它们不会作为接口类型存储。
这是类型参数的合理使用,因为Tree数据结构,包括方法中的代码,在很大程度上独立于元素类型T。
Tree数据结构确实需要知道如何比较元素类型T的值;它使用传入的比较函数来实现这一点。您可以在find方法的第四行中看到这一点,在调用bt.cmp时。除此之外,类型参数完全不重要。
Tree示例说明了另一个一般指导原则:当您需要类似比较函数的东西时,优先选择函数而非方法。
我们本可以定义Tree类型,使元素类型必须有Compare或Less方法。这将通过编写要求该方法的约束来完成,这意味着用于实例化Tree类型的任何类型参数都需要有该方法。
一个后果是,任何想要将Tree与简单数据类型(如int)一起使用的人都必须定义自己的整数类型并编写自己的比较方法。如果我们定义Tree接受比较函数,如上面所示的代码,那么传入所需函数就很容易。编写比较函数与编写方法一样容易。
如果Tree元素类型恰好已经有Compare方法,那么我们可以简单地使用像ElementType.Compare这样的方法表达式作为比较函数。
换句话说,将方法转换为函数比向类型添加方法要简单得多。因此,对于通用数据类型,优先选择函数,而不是编写要求方法的约束。
类型参数可能有用的另一种情况是当不同类型需要实现某些通用方法,并且不同类型的实现看起来都相同。
例如,考虑标准库的sort.Interface。它要求类型实现三个方法:Len、Swap和Less。
这里是一个泛型类型SliceFn的例子,它为任何切片类型实现sort.Interface:
// SliceFn为T的切片实现sort.Interface。 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使用比较函数对s进行原地排序。
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实现完全不同。这意味着我们应该编写两种不同的Read方法,并使用像io.Reader这样的接口类型。
Go有运行时反射。反射允许一种泛型编程,因为它允许您编写适用于任何类型的代码。
如果某些操作必须支持甚至没有方法的类型(因此接口类型没有帮助),并且操作对每种类型都不同(


