共计 5260 个字符,预计需要花费 14 分钟才能阅读完成。
前言
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…