Go语言中常见100问题-#9 Being confused about when to use generics

2022-12-18 12:35:47 浏览数 (1)

不知道在什么时候该使用泛型

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. 如果想在方法中使用泛型,类型参数必须在接收者身上。

代码语言:javascript复制
type Foo struct {}

func (Foo) bar[T any](t T) {}
泛型的使用场景和误用场景

泛型该在什么时候使用呢?下面讨论一些泛型的常见使用场景:

  • 通用结构体的时候。例如,如果我们实现二叉树、链表或堆数据结构,可以使用泛型定义结构体中装载元素的类型。
  • 需要使用slice、map和channel的函数。例如,合并两个通道的函数,需要支持任意数据类型通道。可以使用类型参数表示通道类型。
代码语言:javascript复制
func merge[T any](ch1, ch2 <-chan T) <-chan T {
        // ...
}
  • 当一个方法的实现对所有类型都一样的时候。例如sort.Ints、sort.Float64s. 使用类型参数,我们可以考虑抽取排序行为。例如,定义一个包含切片和比较函数的结构体。
代码语言:javascript复制
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作为参数更合适,可读性更强。
代码语言:javascript复制
func foo[T io.Writer](w T) {
        b := getBytes()
        _, _ = w.Write(b)
}
  • 当采用泛型使得代码变得更复杂时。泛型从来都不是强制使用的,作为Go开发人员,在没有泛型(Go1.18版引入)的情况已工作很多年了。如果采用泛型不能使通用函数或结构代码更清晰,则失去了使用泛型的价值,就不应该使用泛型。

尽管泛型在特定场景下可能非常有用,但我们应该谨慎选择使用而不是盲目使用。一般来说,当我们想回答什么时候不使用泛型时,可以类比什么时候不使用接口,它们有相似之处。泛型引入了一种抽象形式,我们必须牢记,不必要的抽象会引入复杂性。同样,我们不要用不必要的抽象来污染我们的代码,让我们现在专注于解决具体问题。这要求我们不应该过早地使用泛型,而是等到即将编写模板代码的时候考虑使用泛型。

0 人点赞