Go 博客

何时使用泛型

Ian Lance Taylor
2022 年 4 月 12 日

引言

这是我在 Google Open Source Live 和 GopherCon 2021 上的演讲的博客版本。

和 GopherCon 2021

Go 1.18 版本引入了一项重大的新语言特性:支持泛型编程。在本文中,我不会描述泛型是什么,也不会如何使用它们。本文将重点介绍何时在 Go 代码中使用泛型,以及何时不使用它们。

需要明确的是,我将提供一般性指导,而不是硬性规定。请自行判断。但如果您不确定,我建议参考此处讨论的指南。

编写代码

让我们从一个编程 Go 的一般性指导开始:通过编写代码来编写 Go 程序,而不是通过定义类型。当涉及到泛型时,如果您开始通过定义类型参数约束来编写程序,那么您很可能走错了方向。从编写函数开始。当清楚类型参数会有用时,可以很容易地在以后添加它们。

什么时候类型参数有用?

话虽如此,让我们看看类型参数可能有用的一些情况。

在使用语言定义的容器类型时

一种情况是编写操作语言定义的特殊容器类型(切片、映射和通道)的函数。如果函数具有这些类型的参数,并且函数代码不对元素类型做任何特定的假设,那么使用类型参数可能很有用。

例如,这是一个返回任何类型映射的所有键的切片的函数

// 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
}

此代码不假定映射键类型,并且根本不使用映射值类型。它适用于任何映射类型。这使其成为使用类型参数的良好候选。

对于这类函数,类型参数的替代方案通常是使用反射,但这是一种更笨拙的编程模型,在构建时不会进行静态类型检查,并且在运行时通常速度较慢。

通用数据结构

类型参数有用的另一种情况是通用数据结构。通用数据结构类似于切片或映射,但不是语言内置的,例如链表或二叉树。

今天,需要此类数据结构的程序通常会执行以下两项操作之一:使用特定元素类型编写它们,或使用接口类型。用类型参数替换特定元素类型可以生成更通用的数据结构,该结构可以在程序的其他部分或由其他程序使用。用类型参数替换接口类型可以更有效地存储数据,从而节省内存资源;它还可以使代码避免类型断言,并在构建时进行完全类型检查。

例如,这是使用类型参数的二叉树数据结构可能的样子

// 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` 的值。当使用特定的类型参数实例化树时,该类型的值将直接存储在节点中。它们不会作为接口类型存储。

这是类型参数的合理用法,因为 `Tree` 数据结构(包括方法中的代码)在很大程度上独立于元素类型 `T`。

`Tree` 数据结构确实需要知道如何比较元素类型 `T` 的值;它为此使用了传入的比较函数。您可以在 `find` 方法的第四行看到这一点,在调用 `bt.cmp` 时。除此之外,类型参数完全无关紧要。

对于类型参数,优先使用函数而不是方法

`Tree` 示例说明了另一个通用指南:当您需要类似比较函数的功能时,请优先使用函数而不是方法。

我们可以定义 `Tree` 类型,使其元素类型需要有一个 `Compare` 或 `Less` 方法。这可以通过编写一个需要该方法的约束来实现,这意味着用于实例化 `Tree` 类型的任何类型参数都需要具有该方法。

后果是,任何想要使用 `Tree` 结合简单数据类型(如 `int`)的人都必须定义自己的整数类型并编写自己的比较方法。如果我们定义 `Tree` 以接受比较函数,如上所示的代码,那么传入所需的函数就很简单。编写该比较函数与编写方法一样简单。

如果 `Tree` 元素类型碰巧已经有一个 `Compare` 方法,那么我们可以简单地使用 `ElementType.Compare` 这样的方法表达式作为比较函数。

换句话说,将方法转换为函数比向类型添加方法要简单得多。因此,对于通用数据类型,优先使用函数而不是编写需要方法的约束。

实现公共方法

类型参数有用的另一个情况是,当不同类型需要实现某些公共方法,并且不同类型的实现看起来都一样时。

例如,考虑标准库的 `sort.Interface`。它要求一个类型实现三个方法:`Len`、`Swap` 和 `Less`。

这是一个通用类型 `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])
}

对于任何切片类型,`Len` 和 `Swap` 方法都是完全相同的。`Less` 方法需要比较,这是 `SliceFn` 名称的 `Fn` 部分。与前面的 `Tree` 示例一样,我们在创建 `SliceFn` 时会传入一个函数。

以下是如何使用 `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, less})
}

这类似于标准库函数 `sort.Slice`,但比较函数使用值而不是切片索引来编写。

使用类型参数编写此类代码是合适的,因为对于所有切片类型,方法看起来都完全相同。

(我应该提到,Go 1.19(而不是 1.18)很可能包含一个使用比较函数对切片进行排序的泛型函数,并且该泛型函数很可能不使用 `sort.Interface`。请参阅 proposal #47619。但即使这个具体的例子很可能没有用,一般的观点仍然是正确的:当您需要实现对于所有相关类型都看起来相同的那些方法时,使用类型参数是合理的。)

什么时候类型参数无用?

现在让我们来谈谈问题的另一面:何时不使用类型参数。

不要用类型参数替换接口类型

众所周知,Go 拥有接口类型。接口类型允许一种通用的编程方式。

例如,广泛使用的 `io.Reader` 接口提供了一种通用的机制,用于从包含信息的任何值(例如文件)或生成信息的任何值(例如随机数生成器)中读取数据。如果您对某个类型的值唯一需要做的就是调用该值上的方法,请使用接口类型,而不是类型参数。`io.Reader` 易于阅读、高效且有效。没有必要使用类型参数通过调用 `Read` 方法从值中读取数据。

例如,将第一个使用接口类型的函数签名更改为第二个使用类型参数的版本可能很诱人。

func ReadSome(r io.Reader) ([]byte, error)

func ReadSome[T io.Reader](r T) ([]byte, error)

请勿进行此类更改。省略类型参数使函数更易于编写、更易于阅读,并且执行时间可能相同。

值得强调最后一点。虽然可以通过多种不同的方式实现泛型,并且实现会随时间变化和改进,但 Go 1.18 中使用的实现通常会将类型参数的值视为与接口类型的值类似。这意味着使用类型参数通常不会比使用接口类型更快。因此,不要仅仅为了速度而从接口类型更改为类型参数,因为它可能不会运行得更快。

如果方法实现不同,请不要使用类型参数

在决定是使用类型参数还是接口类型时,请考虑方法的实现。前面我们说过,如果一个方法的实现对于所有类型都相同,就使用类型参数。反之,如果实现对于每种类型都不同,那么就使用接口类型并编写不同的方法实现,不要使用类型参数。

例如,从文件读取的 `Read` 实现与从随机数生成器读取的 `Read` 实现完全不同。这意味着我们应该编写两个不同的 `Read` 方法,并使用 `io.Reader` 这样的接口类型。

在适当的地方使用反射

Go 具有运行时反射。反射允许一种通用的编程方式,因为它允许您编写适用于任何类型的代码。

如果某个操作必须支持即使是没有方法的类型(因此接口类型无济于事),并且如果该操作对于每种类型都不同(因此类型参数不适用),请使用反射。

一个例子是 encoding/json 包。我们不希望要求我们编码的每种类型都有 `MarshalJSON` 方法,因此我们不能使用接口类型。但是,编码接口类型与编码结构体类型完全不同,因此我们不应该使用类型参数。相反,该包使用反射。代码并不简单,但它有效。有关详细信息,请参阅源代码

一个简单的指导原则

最后,关于何时使用泛型的讨论可以归结为一个简单的指导原则。

如果您发现自己多次编写完全相同的代码,其中副本之间的唯一区别是代码使用了不同的类型,请考虑是否可以使用类型参数。

换句话说,在注意到您即将多次编写完全相同的代码之前,您都应该避免使用类型参数。

下一篇文章:Go 开发者调查 2021 年结果
上一篇文章:熟悉工作区
博客索引