不知道在什么时候该使用泛型
Go语言在1.18版本中添加了泛型特性。什么是泛型呢?简单来说,就是编写具有可以稍后指定并在需要时实例化的类型代码。注意泛型与接口的区别,泛型是在编译时确定类型,接口是在运行时。对于什么时候该使用泛型,什么时候不该使用泛型,很多人并不是很清楚。本文将先阐述Go中泛型的概念,然后深入讨论常见的泛型使用场景以及使用误区。
泛型
下面是从 map[string]int 类型中获取所有键的函数. 如果也想从另一种类型(例如 map[int]string) 也获取键怎么办?在没有泛型之前,有这几个处理方法:使用代码生成、反射或复制代码。
代码语言:javascript复制func getKeys(m map[string]int) []string {
var keys []string
for k := range m {
keys = append(keys, k)
}
return keys
}
例如,我们可以编写两个函数,对每种类型的map都创建一个获取函数。我们甚至可以扩展 getKeys 函数,让它可以接受不同的map类型,代码如下:
代码语言:javascript复制func getKeys(m any) ([]any, error) {
switch t := m.(type) {
default:
return nil, fmt.Errorf("unknown type: %T", t)
case map[string]int:
var keys []any
for k := range t {
keys = append(keys, k)
}
return keys, nil
case map[int]string:
// ...
}
}
但是上述代码存在着以下几个问题:
- 代码量增加,每当我们想要添加一个新的入参类型时,都需要进行case操作,并在里面添加循环代码。
- 函数接受任何类型作为入参,意味着正在失去Go作为静态语言的一些优势。并且对类型进行断言检查是在运行时而不是编译时完成的,因此如果提供的类型未知,还需要返回错误信息。
- 由于入参map的键类型可以是int或string, 我们必须返回any类型的切片来支持键的不确定性。这会增加调用方的工作量,因为客户端可能还必须执行键的类型检查和额外的转换。
有了泛型,现在可以使用类型参数重构上述代码,类型参数是可以与函数和类型一起使用的泛型类型。例如,下面的函数接收类型参数:
代码语言:javascript复制func foo[T any](t T) {
// ...
}
调用foo时,可以传递任何类型的类型参数给它。提供类型参数称为实例化,这个工作是在编译时完成的,将类型安全作为Go语言核心功能的一部分,同时避免了运行时开销。
回到 getKeys 函数,现在采用类型参数编写一个可以接受任何类型map的通用版本。为了能够处理任何类型的map, 定义了两种类型参数。map的value可以是任何类型,所以定义了 V any, 但是在Go语言中,map的key不能是任何类型,例如,不能使用切片作为key。
代码语言:javascript复制func getKeys[K comparable, V any](m map[K]V) []K {
var keys []K
for k := range m {
keys = append(keys, k)
}
return keys
}
切片不能作为map的key, 编译下面的代码将会报编译错误:invalid map key type []byte. 因此,有必要限制类型参数,而不是接受任何类型的参数,以便键类型满足特定要求。在这里,key要具有可比较性(可以使用 == 或 !=). 因此,上面将key定义为可比较的类型而不是任何类型。
代码语言:javascript复制var m map[[]byte]int
限制类型参数以匹配特定要求称之为约束。约束是一种接口类型,可以包含:
- 一组行为(方法)
- 任意类型
下面来看一个关于后者的具体例子。假设我们不想让map的键类型接受任何比较的类型。例如,我们希望将键限制为int或string类型,可以定义一个自定义约束。首先,我们定义了一个 customConstraint 接口, 使用运算符 | 将类型限制为 int或string 类型。让后将K的类型定义为customConstraint而不是之前的comparable.
代码语言:javascript复制type customConstraint interface {
~int | ~string
}
func getKeys[K customConstraint, V any](m map[K]V) []K {
// Same implementation
}
限制getKeys的签名强制我们可使用value为任何类型的map调用它,但key类型必须是int或string. 例如,调用方可以编写如下代码。
代码语言:javascript复制m = map[string]int{
"one": 1,
"two": 2,
"three": 3,
}
keys := getKeys(m)
在编译时,Go语言可以推断出 getKeys 是使用字符串类型参数调用的。上面的调用与下面的等价:
代码语言:javascript复制keys := getKeys[string](m)
「NOTE:例如,使用下面的约束会将参数类型限制为自定义类型」
代码语言:javascript复制type customInt int
func (i customInt) String() string {
return strconv.Itoa(int(i))
}
由于customInt是一个int(因为它是一个int类型的别名)并实现了String()字符串方法,因此,customInt类型满足定义的约束。但是,如果我们将约束更改为包含int而不是~int, 使用customInt会导致编译错误,因为int类型没有实现String() string方法。
使用~int和int进行约束的区别是什么呢?使用int会限制为该类型,使用~int会限制基础类型为int的所有类型。为了更清楚说明这一点,假想有这样一个约束,希望将一个类型限制为任何实现String() string的int类型,可以用下面的代码实现。
代码语言:javascript复制type customConstraint interface {
~int
String() string
}
我们还要注意 constraints 包(Go1.18中添加的,最新的Go1.19已经从标准库中移除)中包含一组常见的约束,例如 constraints.Signed, 它包括了所有有符号整数类型。在创建新的约束之前,确保它在 constraints 包中没有,否则没有必要创建。
代码语言:javascript复制type Signed interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
到此,已经分析了将泛型用于函数的案例。此外,泛型也可以与结构体结合使用,下面通过一个例子进行说明。假设我们将创建一个链表,该链表中存储的值可以是任意类型,同时有一个Add方法向链表中追加一个节点,实现代码如下:
代码语言:javascript复制type Node[T any] struct {
Val T
next *Node[T]
}
func (n *Node[T]) Add(next *Node[T]) {
n.next = next
}
在结构体Node中使用参数类型T来约束Node中存在的数据的类型,字段Val中存储值的类型为T,next节点也带有约束T。在编译时,接收器中的参数类型T将被实例化。T是any类型,所以它是通用的,但它也必须遵守定义的类型参数。
对于类型参数要注意的一点是,它们不能与方法参数一起使用,只能与函数参数或方法接收器一起使用。例如,下面的代码编译会报错. methods cannot have type parameters
. 如果想在方法中使用泛型,类型参数必须在接收者身上。
type Foo struct {}
func (Foo) bar[T any](t T) {}
泛型的使用场景和误用场景
泛型该在什么时候使用呢?下面讨论一些泛型的常见使用场景:
- 通用结构体的时候。例如,如果我们实现二叉树、链表或堆数据结构,可以使用泛型定义结构体中装载元素的类型。
- 需要使用slice、map和channel的函数。例如,合并两个通道的函数,需要支持任意数据类型通道。可以使用类型参数表示通道类型。
func merge[T any](ch1, ch2 <-chan T) <-chan T {
// ...
}
- 当一个方法的实现对所有类型都一样的时候。例如sort.Ints、sort.Float64s. 使用类型参数,我们可以考虑抽取排序行为。例如,定义一个包含切片和比较函数的结构体。
type SliceFn[T any] struct {
S []T
Compare func(T, T) bool
}
func (s SliceFn[T]) Len() int { return len(s.S) }
func (s SliceFn[T]) Less(i, j int) bool { return s.Compare(s.S[i], s.S[j]) }
func (s SliceFn[T]) Swap(i, j int) { s.S[i], s.S[j] = s.S[j], s.S[i] }
由于SliceFn结构体实现了sort.Interface接口,可以使用sort.Sort(sort.Interface)函数对提供的切片进行排序。像下面的代码,直接调用sort.Sort对s进行排序,程序执行的结果为:[1 2 3]
代码语言:javascript复制s := SliceFn[int]{
S: []int{3, 2, 1},
Compare: func(a, b int) bool {
return a < b
},
}
sort.Sort(s)
fmt.Println(s.S)
通过这个例子想说明,抽取出一种行为(像上面的Compare)能够避免为每种类型创建一个函数。
什么时候建议不要使用泛型呢?下面是一些不推荐使用泛型的场景。
- 只是单纯调用实参的方法时。例如,下面接收一个io.Writer类型参数并调用Write方法的函数。在这种情况下,使用泛型不会带来任何价值,我们应该直接将w参数类型设置为io.Writer.使用interface作为参数更合适,可读性更强。
func foo[T io.Writer](w T) {
b := getBytes()
_, _ = w.Write(b)
}
- 当采用泛型使得代码变得更复杂时。泛型从来都不是强制使用的,作为Go开发人员,在没有泛型(Go1.18版引入)的情况已工作很多年了。如果采用泛型不能使通用函数或结构代码更清晰,则失去了使用泛型的价值,就不应该使用泛型。
尽管泛型在特定场景下可能非常有用,但我们应该谨慎选择使用而不是盲目使用。一般来说,当我们想回答什么时候不使用泛型时,可以类比什么时候不使用接口,它们有相似之处。泛型引入了一种抽象形式,我们必须牢记,不必要的抽象会引入复杂性。同样,我们不要用不必要的抽象来污染我们的代码,让我们现在专注于解决具体问题。这要求我们不应该过早地使用泛型,而是等到即将编写模板代码的时候考虑使用泛型。