1
介绍
这是作者在 Google Open Source Live 和 GopherCon 2021 上演讲的博客版本:
https://youtu.be/nr8EpUO9jhw;https://youtu.be/Pa_e9EeCdy8
Go 1.18 版本增加了一个主要的新语言特性:支持泛型编程。在本文中,不会描述什么是泛型,也不会描述如何使用它们。本文将关注在 Go 编程中何时使用泛型,什么时候不适合使用泛型。
需要明确的是,本文提供的是一般的指导准则,而不是硬性规定。是否采用取决于你自己的判断,但如果你不确定,建议使用这里讨论的指导准则。
2
编写代码
让我们从编写 Go 的一般准则开始:通过编写代码来编写 Go 程序,而不是通过定义类型。当涉及泛型时,如果你通过定义类型参数约束来开始编写程序,则可能走错了方向。所以,首先应编写函数,然后当你清楚地看到可以使用类型参数时,再添加类型参数就很容易了。
3
类型参数什么时候有用?
再说明了上面这一点之后,现在让我们看看类型参数在哪些情况下可能有作用。
当使用语言自身定义的容器类型
slicesmapschannels
mapslicemap
// 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
}
mapmapmap
对于这类函数,类型参数的替代方法通常是使用反射,但这是一种更笨拙的编程模型,在构建时不会进行静态类型检查,而且通常运行时也更慢。
通用的数据结构
slicemap
目前,需要此类数据结构的程序通常会采用下面两种方法实现:使用特定的元素类型进行编写,或者使用接口类型。而将特定的元素类型替换为类型参数,可以生成更通用的数据结构,以用在程序的其他部分或其他程序中。将接口类型替换为类型参数,可以更高效地存储数据,节省内存资源;它还意味着代码可以避免类型断言,而且在编译时就进行全面的类型检查。
例如,使用类型参数的二叉树数据结构,看上去可能是这样的:
// 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
TreeT
TreeTfindbt.cmp
对于类型参数,首选函数而不是方法
Tree
TreeCompareLessTree
TreeintintCompareTree
TreeCompareElementType.Compare
换句话说,将方法转换为函数要比向类型中添加方法简单得多。因此,对于通用数据类型,最好使用函数,而不是限制需要编写一个方法。
实现一个通用方法
类型参数可能有用的另一种情况是,当不同的类型需要实现一些公共方法,而针对各种类型的实现看起来都一样时。
sort.InterfaceLenSwapLess
sort.InterfaceSliceFn
// 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])
}
LenSwapLessSliceFnFnTreeSliceFn
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, cmp})
}
sort.Sliceslice
对这类代码使用类型参数是合适的,因为所有切片类型对应的方法看起来都完全相同。
sort.Interface
4
类型参数什么时候没有用?
现在,让我们谈谈问题的另一面:什么时候不使用类型参数。
不要用类型参数替换接口类型
众所周知,Go 具有接口类型,接口类型已经可以允许某种泛型编程。
io.Readerio.ReaderRead
例如,下面的第一个函数签名(仅使用接口类型)可能很容易更改为第二个版本(使用类型参数)。
func ReadSome(r io.Reader) ([]byte, error)
func ReadSome[T io.Reader](r T) ([]byte, error)
但不要做这种改变。使用接口类型会使函数更易于编写和阅读,并且执行时间可能相同。
最后值得强调的一点是。虽然可以用几种不同的方式实现泛型,而且实现会随着时间的推移而改变和改进。但在 Go 1.18 许多情况下的实现是,对于处理类型为类型参数的值,就像处理类型为接口类型的值一样。这意味着使用类型参数通常不会比使用接口类型更快。所以不要为了速度而从接口类型更改为类型参数,因为它很可能不会运行得更快。
如果方法实现不同,不要使用类型参数
在决定是否使用类型参数或接口类型时,请先考虑方法的实现。前面我们说过,如果一个方法的实现对于所有类型都是相同的,那么就使用一个类型参数。相反,如果每种类型的实现不同,则使用接口类型并编写不同的方法实现,不要使用类型参数。
ReadReadio.Reader
适当的时候使用反射
Go 也有运行时反射的功能。反射也能实现某种泛型编程,因为它允许你编写适用于任何类型的代码。
如果某些操作必须支持甚至没有方法的类型,那么接口类型就不起作用。并且如果每个类型的操作不同,那么类型参数也不合适。这个时候请使用反射。
MarshalJSON
5
一个简单的准则
最后,关于何时使用泛型的讨论可以简化为一个简单的准则。
如果你发现自己多次编写完全相同的代码,其中代码之间的唯一差异是使用不同的类型,那么考虑是否可以使用类型参数。
另一种说法是,应该避免使用类型参数,直到你注意到自己编写多次完全相同的代码。
原文信息
本文永久链接:https:/github.com/gocn/translator/blob/master/2022/w16_When_To_Use_Generics.md
译者:haoheipi
校对:zxmfke
关注 公众号,回复关键词 [实战群] ,就有机会进群和我们进行交流~