何时在 Go 中使用泛型

前言

本文是 Go 官方博客的翻译,原文地址:when generics

Go 1.18 增加了一个重要的语言特性:对泛型编程的支持。本文不会介绍什么是泛型,如何去使用泛型。而是聚焦于何时在Go代码中使用泛型,又该在何时不使用泛型。

先说明,本文只是提供一个通用的指引,而非硬性的规定。具体使用的时机取决于你的判断。但是如果你不确定是否该使用,那么你就可以遵循本文的说明。

编写代码

首先是从 Go 编程的一般性准则:通过编写代码而非是定义类型来编写 Go 程序。如果在涉及到泛型时,首先想到定义类型参数约束来编写你的程序,那么你可能就走入了一个误区。首先编写函数,在明确了解类型参数的作用后,再添加类型参数约束就是一件很容易的事了。

类型参数何时有用

让我们来看看在什么情况下类型参数是有用的

当使用程序内置的数据结构时

一种情况就是在使用程序内置的特殊容器进行操作的函数时: map,slice 和 channel。

如果有一个函数有这种类型的参数,并且函数内部的代码不会对元素的类型有任何的假设,那么使用类型参数就可能是有用的

举个例子,下面这个函数返回任何类型 map 的所有 key 的 slice:

1
2
3
4
5
6
7
8
9
// 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 的 key 的类型做出任何的假设,并且它也没有使用 value 的类型。它适用于任何类型的 map。这使得它成为使用泛型的一个好的选择。

对于这样的函数,除类型参数外的替代方案就是使用反射,但这是一个更为笨拙的方案。在编译时没有静态类型检查,而且在运行时的速度也会变慢。

通用数据结构

参数类型有用的另一种情况就是通用数据结构中。一个通用的数据结构就像 slice 或者 map 一样,但是却不是语言内置的,比如链表或者是二叉树。

目前,使用这种数据结构的程序往往会选择下面中的一种做法,一是用特定的元素类型来编写,或者是使用 interface{} 类型。使用类型参数来替换特定的类型可以使得数据结构更加的通用。使用类型参数来代替 interface{} 类型则可以更有效的存储数据,从而节省内存使用,同时也可以在代码中避免类型断言,在编译期就可以进行完全的类型检查。

举例来说,一个使用类型参数的二叉树的一部分可能像下面这段代码一样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 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 的值。当二叉树以特定类型参数实例化时,该类型的值将直接存储在节点中。而不是被存储为 interface{} 类型。

这就是类型参数的合理使用,因为二叉树的结构,包括方法中的代码,在很大程度上独立于元素类型T。

二叉树并不需要知道如何去比较每种类型 T 的值大小,它通过使用传入的比较函数来进行值的比较。你可以在find方法的第四行 bt.cmp 调用处看到这一点。除此之外,类型参数根本不重要。

对于类型参数,更适用于函数而非方法

二叉树的例子表明了另一个准则:当需要实现像比较大小一样的功能时,使用函数要比方法好。

如果不使用函数,我们也可以定义一个节点的值需要有CompareLess方法的二叉树。这可以通过对参数类型增加需要有这两种方法的约束来实现,这意味着用于实例化二叉树的类型都需要有这两种方法才行。

这样做的结果是,任何想将二叉树与int等简单数据类型一起使用的人都必须定义自己的整数类型并编写自己的比较方法。如果我们定义二叉树需要传入比较函数,如上图所示的代码,那么就很容易传递所需的函数。使用函数而非是带有方法的类型约束。

实现通用方法

还有一种类型参数十分有用的情况,那就是不同的类型都需要实现一些通用的方法,并且每个类型的实现方式都是相同时。

举个例子,标准库中的sort.Interface接口。它要求实现它的类型需要实现三个方法:Len,SwapLess

这里有一个为任何 slice 实现 sort.Interface 接口的泛型类型 SortFn

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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])
}

对于任何一个 slice 来说,Len 和 Swap 方法都是相同的。Less 方法需要一个比较函数,也就是 SliceFn 的 Fn 部分。就像之前的二叉树的例子一样,我们在实例化 SliceFn 时传入比较函数

下面的代码就展示了,如何通过传入比价函数来使用 SortFn 为任何类型的 slice 进行排序

1
2
3
4
// 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, cmp})
}

这类似于标准库函数 sort.Slice,但比较函数是使用值而不是切片索引编写的。

对这类代码使用类型参数是合适的,因为所有切片类型的方法看起来都完全相同。

(需要注意的是,Go 1.19-而不是1.18-很可能包括一个使用比较函数对切片进行排序的泛型函数,而该泛型函数很可能不使用sort.Interface。见提案#47619。)

类型参数何时没用

现在让我们来谈谈问题的另一面:类型参数何时是没用的

不要用类型参数替换接口

我们都知道,Go 有着接口类型。 接口可以看作是一种泛型编程。

例如,广泛使用的 io.Reader 接口提供了一个通用机制,用于从任何包含信息(例如文件)或生成信息(例如随机数生成器)的值中读取数据。如果只是需要调用某个类型的值的方法时,使用接口,而不是类型参数。io.Reader易读,高效和有效的。没有必要去使用类型参数调用一个值的 Read 方法来读取数据。

举例来说,将这里仅使用接口类型的第一个函数签名更改为使用类型参数的第二版本可能会很诱人

1
2
3
func ReadSome(r io.Reader) ([]byte, error)

func ReadSome[T io.Reader](r T) ([]byte, error)

不要做这种改变。省略类型参数使函数更容易写入,更容易读取,并且执行时间可能相同。

值得强调的是最后一点,虽然可以以几种不同的方式实现泛型,并且实现会随着时间的推移而变化和改进,但Go 1.18中使用的实现在许多情况下将处理类型为类型参数的值,就像对待类型为接口类型的值一样。这意味着使用类型参数通常不会比使用接口类型更快。因此,不要仅仅为了速度而从接口类型更改为类型参数,因为它可能不会运行得更快。

如果方法的实现不同,不要使用泛型

在决定是使用类型参数还是接口类型时,请考虑方法的实现。在之前我们说过,当实现的方法对所有的类型都是相同的时候,使用类型参数。相反地,如果每种类型的方法实现都是不同的,那么就使用接口类型,编写不同的方法实现,而不是使用类型参数。

例如,File 类型的 Read方法实现与随机数生成器 Read方法的实现不同。这意味着我们应该编写里两个不同的 Read 方法,使用一个接口类型就像是 io.Reader

酌情使用反射

Go 拥有运行时反射。反射可以做到泛型编程,因为它允许你编写适用于任何类型的代码。

如果某些操作甚至必须支持没有方法的类型(因此接口类型没有帮助),并且如果每种类型的操作都不同(因此类型参数不合适),那么在这种情况下请使用反射。

这种情况的一个例子就是 encoding/json 包。我们不希望编写的每种类型都有一个MarshalJSON方法,因此我们不能使用接口类型。但对接口类型序列化与对结构类型进行序列化的方式又完全不一样,因此我们也不能使用类型参数。相反,该包使用了反射。实现的代码并不简单,但很有效。更多细节见源码

一个简答的指南

最后,关于何时使用泛型的讨论可以简化为一个简单的指南。

如果你发现自己多次写完全相同的代码,其中唯一的区别是代码使用了不同的类型,考虑是否可以使用类型参数。

换句话说,你应该避免使用类型参数,直到你注意到你要多次编写完全相同的代码。


何时在 Go 中使用泛型
https://blog.zhangliangliang.cc/post/when-to-use-generics.html
作者
Bobby Zhang
发布于
2022年4月25日
许可协议