前言
Go泛型的设计者Ian Lance Taylor在官网博客网站上发表了一篇文章when to use generics,具体阐明了在什么场景下应该应用泛型,什么场景下不要应用泛型。这对于咱们写出合乎最佳实际的Go泛型代码十分有指导意义。
自己对原文在翻译的根底上做了一些表述上的优化,不便大家了解。
原文翻译
Ian Lance Taylor
2022.04.14
这篇博客汇总了我在2021年Google开源流动日和GopherCon会议上对于泛型的分享。
Go 1.18版本新增了一个重大性能:反对泛型编程。本文不会介绍什么是泛型以及如何应用泛型,而是把重点放在解说Go编程实际中,什么时候应该应用泛型,什么时候不要应用泛型。
须要明确的是,我将会提供一些通用的指引,这并不是硬性规定,大家能够依据本人的判断来决定,然而如果你不确定如何应用泛型,那倡议参考本文介绍的指引。
写代码
Go编程有一条通用准则:write Go programs by writing code, not by defining types.
具体到泛型,如果你写代码的时候从定义类型参数束缚(type parameter constraints)开始,那你可能搞错了方向。从编写函数开始,如果写的过程中发现应用类型参数更好,那再应用类型参数。
类型参数何时有用?
接下来咱们看看在什么状况下,应用类型参数对咱们写代码更有用。
应用Go内置的容器类型
如果函数应用了语言内置的容器类型(包含slice, map和channel)作为函数参数,并且函数代码对容器的解决逻辑并没有预设容器里的元素类型,那应用类型参数(type parameter)可能就会有用。
举个例子,咱们要实现一个函数,该函数的入参是一个map,要返回该map的所有key组成的slice,key的类型能够是map反对的任意key类型。
// 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的类型做任何限定,并且没有用map里的value,因而这段代码实用于所有的map类型。这就是应用类型参数的一个很好的示例。
这种场景下,也能够应用反射(reflection),然而反射是一种比拟顺当的编程模型,在编译期没法做动态类型查看,并且会导致运行期的速度变慢。
实现通用的数据结构
对于通用的数据结构,类型参数也会有用。通用的数据结构相似于slice和map,然而并不是语言内置的数据结构,比方链表或者二叉树。
在没有泛型的时候,如果要实现通用的数据结构,有2种计划:
- 计划1:针对每个元素类型别离实现一个数据结构
- 计划2:应用interface类型
泛型绝对计划1的长处是代码更精简,也更不便给其它模块调用。泛型绝对计划2的长处是数据存储更高效,节约内存资源,并且能够在编译期做动态类型查看,防止代码里应用类型断言。
上面的例子就是应用类型参数实现的通用二叉树数据结构:
// 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
的变量val
。当二叉树实例化的时候,须要传入类型实参,这个时候val
的类型曾经确定下来了,不会被存为interface类型。
这种场景应用类型参数是正当的,因为Tree
是个通用的数据结构,包含办法里的代码实现都和T
的类型无关。
Tree
数据结构自身不须要晓得如何比拟二叉树节点上类型为T
的变量val
的大小,它有一个成员变量cmp
来实现val
大小的比拟,cmp
是一个函数类型变量,在二叉树初始化的时候被指定。因而二叉树上节点值的大小比拟是Tree
内部的一个函数来实现的,你能够在find
办法的第4行看到对cmp
的应用。
类型参数优先应用在函数而不是办法上
下面的 Tree
数据结构示例论述了另外一个通用准则:当你须要相似cmp
的比拟函数时,优先思考应用函数而不是办法。
对于下面Tree
类型,除了应用函数类型的成员变量cmp
来比拟val
的大小之外,还有另外一种计划是要求类型T
必须有一个Compare
或者Less
办法来做大小比拟。要做到这一点,就须要定义一个类型束缚(type constraint)用于限定类型T
必须实现这个办法。
这造成的后果是即便T
只是一个一般的int类型,那使用者也必须定义一个本人的int类型,实现类型束缚里的办法(method),而后把这个自定义的int类型作为类型实参传参给类型参数T
。
然而如果咱们参照下面Tree
的代码实现,定义一个函数类型的成员变量cmp
用来做T
类型的大小比拟,代码实现就比拟简洁。
换句话说,把办法转为函数比给一个类型减少办法容易得多。因而对于通用的数据类型,优先思考应用函数,而不是写一个必须有办法的类型限度。
不同类型须要实现专用办法
类型参数另一个有用的场景是不同的类型要实现一些专用办法,并且对于这些办法,不同类型的实现逻辑是一样的。
上面举个例子,Go规范库里有一个sort包,能够对存储不同数据类型的slice做排序,比方Float64s(x)
能够对[]float64
做排序,Ints(x)
能够对[]int
做排序。
同时sort包还能够对用户自定义的数据类型(比方构造体、自定义的int类型等)调用sort.Sort()
做排序,只有该类型实现了sort.Interface
这个接口类型里Len()
、Less()
和Swap()
这3个办法即可。
上面咱们对sort包能够应用泛型来做一些革新,就能够对存储不同数据类型的slice对立调用sort.Sort()
来做排序,而不必专门为[]int
调用Ints(x)
,为[]float64
调用Float64s(x)
做差异化解决了,能够简化代码逻辑。
上面的代码实现了一个泛型的构造体类型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])}
对于不同的slice类型, Len
和 Swap
办法的实现是一样的。Less
办法须要对slice里的2个元素做比拟,比拟逻辑实现在SliceFn
里的成员变量less
外头,less
是一个函数类型的变量,在构造体初始化的时候进行传参赋值。这点和下面Tree
这个二叉树通用数据结构的解决相似。
咱们再将sort.Sort
依照泛型格调封装为SortFn
泛型函数,这样对于所有slice类型,咱们都能够对立调用SortFn
做排序。
// 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很相似,只不过这里的less
比拟函数的参数是具体的值,而sort.Slice
里比拟函数less
比拟函数的参数是slice的下标索引。
这种场景应用类型参数比拟适合,因为不同类型的SliceFn
的办法实现逻辑都是一样的,只是slice
里存储的元素的类型不一样而已。
类型参数何时不要用
当初咱们谈谈类型参数不倡议应用的场景。
不要把interface类型替换为类型参数
咱们大家都晓得Go语言有interface类型,interface反对某种意义上的泛型编程。
举个例子,被宽泛应用的io.Reader
接口提供了一种泛型机制用于读取数据,比方反对从文件和随机数生成器里读取数据。
如果你对某些类型的变量的操作只是调用该类型的办法,那就间接应用interface类型,不要应用类型参数。io.Reader
从代码角度易于浏览且高效,没必要应用类型参数。
举个例子,有人可能会把上面第1个基于interface类型的ReadSome
版本批改为第2个基于类型参数的版本。
func ReadSome(r io.Reader) ([]byte, error)func ReadSome[T io.Reader](r T) ([]byte, error)
不要做这种批改,应用第1个基于interface的版本会让函数更容易编写和浏览,并且函数执行效率也简直一样。
留神:只管能够应用不同的形式来实现泛型,并且泛型的实现可能会随着工夫的推移而发生变化,然而Go 1.18中泛型的实现在很多状况下对于类型为interface的变量和类型为类型参数的变量解决十分类似。这意味着应用类型参数通常并不会比应用interface快,所以不要单纯为了程序运行速度而把interface类型批改为类型参数,因为它可能并不会运行更快。
如果办法的实现不同,不要应用类型参数
当决定要用类型参数还是interface时,要思考办法的逻辑实现。正如咱们后面说的,如果办法的实现对于所有类型都一样,那就是用类型参数。相同,如果每个类型的办法实现是不同的,那就是用interface类型,不要用类型参数。
举个例子,从文件里Read
的实现和从随机数生成器里Read
的实现齐全不一样,在这种场景下,能够定义一个io.Reader
的interface类型,该类型蕴含有一个Read
办法。文件和随机数生成器实现各自的Read
办法。
在适当的时候能够应用反射(reflection)
Go有 运行期反射。反射机制反对某种意义上的泛型编程,因为它容许你编写实用于任何类型的代码。如果某些操作须要反对以下场景,就能够思考应用反射。
- 操作没有办法的类型,interface类型不实用。
- 每个类型的操作逻辑不一样,泛型不实用。
一个例子是encoding/json包的实现。咱们并不心愿要求咱们编码的每个类型都实现MarshalJson
办法,因而咱们不能应用interface类型。而且不同类型编码的逻辑不一样,因而咱们不应该用泛型。
因而对于这种状况,encoding/json应用了反射来实现。具体实现细节能够参考源码。
一个简略准则
总结一下,何时应用泛型能够简化为如下的一个简略准则。
如果你发现反复在写简直齐全一样的代码,惟一的区别是代码里应用的类型不一样,那就要思考是否能够应用泛型来实现。
开源地址
文章和示例代码开源在GitHub: Go语言高级、中级和高级教程。
公众号:coding进阶。关注公众号能够获取最新Go面试题和技术栈。
集体网站:Jincheng's Blog。
知乎:无忌
References
- Go Blog on When to Use Generics: https://go.dev/blog/when-gene...
- Go Day 2021 on Google Open Source : https://www.youtube.com/watch...
- GopherCon 2021: https://www.youtube.com/watch...