跳至正文

Go 何时使用泛型

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类型,使得元素类型需要具有CompareorLess方法。这将通过编写一个需要该方法的约束来完成,这意味着用于实例化该Tree类型的任何类型参数都需要具有该方法。

结果是任何想要使用Tree简单数据类型的int人都必须定义自己的整数类型并编写自己的比较方法。如果我们定义Tree一个比较函数,如上面的代码所示,那么很容易传入所需的函数。编写比较函数就像编写方法一样容易。

如果Tree元素类型恰好已经有Compare方法,那么我们可以简单地使用方法表达式ElementType.Compare 作为比较函数。

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

实现通用方法

类型参数有用的另一种情况是不同类型需要实现一些通用方法,并且不同类型的实现看起来都一样。

例如,考虑标准库的sort.Interface. 它要求一个类型实现三种方法:LenSwap和 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])
}

对于任何切片类型,LenSwap方法完全相同。该Less方法需要一个比较,这是Fnname 的一部分SliceFn。与前面的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 有接口类型。接口类型允许一种泛型编程。

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

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

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

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

不要做出那种改变。省略类型参数使函数更易于编写、更易于阅读,并且执行时间可能相同。

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

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

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

例如,Readfrom 文件的实现与Readfrom 随机数生成器的实现完全不同。这意味着我们应该编写两个不同Read的方法,并使用像io.Reader.

在适当的地方使用反射

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

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

一个简单的指导方针

最后,关于何时使用泛型的讨论可以简化为一个简单的准则。

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

另一种说法是,您应该避免使用类型参数,直到您注意到您将要多次编写完全相同的代码。

*声明:本文于网络整理,如来源信息有误或侵犯权益,请联系我删除或授权事宜。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注