什么是泛型

借助泛型,你可以先编写数据结构和函数,稍后再指定其中的类型。在当前的Go中,函数当然有参数,使用泛型后,函数可以拥有一种新的参数,称为“类型参数”。而且以前不能拥有任何参数的类型本身,也可以有类型参数。使用类型参数的函数和类型,可以使用类型实参来实例化。

对于类型参数,我们会说“实例化”而不是调用。这是因为相关操作完全在编译阶段而不是在运行时发生。类型参数具有限制条件,限制允许的类型实参集,就像普通参数具有类型限制允许的普通实参集一样。例如,下面的函数接受一个map[string]int类型参数,并返回该类型中所有键的切片。

func MapKeys(m map[string]int) []string{
    var s []string
    for k := range m {
        s = append(s, k)
    }
}
return s

你可以轻松地为任何特定的map类型编写这个函数,但是你需要为要使用的每种映射类型编写一个不同的函数副本。或者,也可以使用reflect包编写此函数,但编写起来很费劲且函数运行速度相对较慢。使用reflect包的过程非常复杂,我就不举例说明了。现在,你也可以使用类型参数,借助类型参数你只需要编写一次这个函数,就可以适用于所有的映射类型。同时,编译器会对其进行全面的类型检查。

func MapKeys[K comparable, V any](m map[K]V) []K {
    var s []K
    for k := range m {
        s = append(s, k)
    }
    return s
}

上述代码中,类型参数名为KV,普通参数m以前的类型为map[string]int,现在的类型为map[K]V。类型参数K是映射的键类型因此必须可以进行比较,这通过为K预先声明comparable限制条件来明确表达,你可以将其视为该类型参数的元类型。类型参数V可以是任意类型,因此它的限制是预先声明的限制条件any。函数主体和以前一样,只是变量s现在是k的类型切片而不是字符串的类型切片。关于泛型还有很多其他细节,在这里就不继续讨论了,有兴趣的可以去阅读官方的使用手册。非常重要的一点是,虽然这个示例中没有展示,但实际上类型本身也可以有类型参数。

什么时候适用泛型

言归正传,今天并不是要讨论什么是泛型或者如何使用他们,而是讨论在什么情况下应该适用泛型以及什么情况下不适用。需要明确的是这个讲座只提供一般指导并不是硬性规定,具体情况由你自行判断。但是,如果你不确定,可以参考我将要讨论的准则。

首先,我来谈谈使用Go编程的一般准则。我们是通过编写代码来编写Go程序而不是通过定义类型。

Write code, don't design types

当涉及到泛型时,如果你通过定义类型参数限制条件来开始编写程序则可能走错了方向。首先,应编写函数,然后当你清楚地看到可以使用类型参数时再轻松地添加。

为了说明这一点,现在我们看看类型参数在什么情况下可能有用。

  • 类型参数可能有用的一种情况是对语言中定义的特殊类型进行操作的函数。例如,切片、映射和通道。

如果函数具有这些类型的参数,并且函数代码没有对元素类型做出任何特定假设,那么使用类型参数可能会很有用。例如,我们之前看到的MapKeys函数。该函数返回映射中的所有键,代码没有对MapKeys的类型做出任何假设,因此该函数非常适用类型参数。正如我之前提到的,对此类函数使用类型参数的替代方法通常是使用反射。但,这是一个更笨拙的编程模型,因为其不仅无法以静态方式进行类型检查。而且通常运行也更慢。

  • 类型参数可能有用的另一个类似情况是通用数据结构。

我所说的通用数据结构是指切片或映射等数据结构,但没有内置到语言中。例如,链表或者二叉树等。目前,需要此类数据结构的程序使用特定的元素类型进行编写或者使用接口类型。将特定元素类型替换为类型参数可以生成更通用的数据结构。将接口类型替换为类型参数通常可以更高效地存储数据。在某些情况下,使用类型参数而不是接口类型可能意味着代码可以避免类型断言,而且可以在编译时就进行全面的类型检查。例如,使用类型参数的二叉树数据结构看上去可能是下面这样。

type Tree[T any] struct {
    cmp func(T, T) int
    root *leaf[T]
}

type leaf[T any] struct {
    val T
    left, right *leaf[T]
}

树中的每个叶节点都包含类型参数T的值,当使用特定的类型实参将该二叉树实例化时,该类型实参的值将直接存储在叶子节点中,而不会作为接口类型存储。

下面的示例展示了通用二叉树中的方法。

func (bt *Tree[T]) find(val T) *leaf[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
}

请不用过多在意上述代码的细节,在实际的使用过程中也不用生搬硬套将上述代码作为模版。重点是,这是对类型参数的合理使用,因为树数据结构和find方法中的代码在很大程度上独立于元素类型T。树数据结构确实需要知道如何比较元素类型T的值,它使用一个传入的函数来实现此目的。你可以在代码的第四行对bt.cmp的调用中看到这一点,除此之外,类型参数没有任何其他作用。同时,这个二叉树示例展示了另一条一般准则。

  • 当你需要使用比较函数等功能时,最好使用函数,而不是方法。

我们本来可以将这个树类型定义为该元素类型需要一个compare或less方法。因此可以编写一个需要compare或less方法的限制条件,这意味着用于实例化树类型的任何实参都需要具有该方法,同时意味着如果有任何人想使用具有简单数据类型(如int)的树,都必须使用compare方法定义自己的int类型,并且任何人想使用具有自定义数据类型的树也必须为他们的数据类型定义compare方法(即使本来并不需要)。如果我们将树定义为接受一个函数,就像上面的代码中那样,就可以轻松的传入所需的比较函数。如果元素碰巧已经有一个compare方法,我们只需要传入方法表达式即可。换句话说,将方法转换为函数,比将方法添加到类型要简单的多。因此,对于通用数据类型,最好使用函数而不是编写需要方法的限制条件。

  • 类型参数可能有用的另一种情况是,当不同的类型需要实现一些通用的方法,而针对各种类型的实现看起来都相同时。

例如,考虑使用Sort包中标准库的sort.Interface,它要求每个类型实现三个方法,即lenswapless。下面示例为一种为任何切片类型实现sort.Interface的泛型类型。

type SliceFn(T any) struct {
    s []T
    cmp 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) int {
    return s.cmp(s.s[i], s.s[j])
}

对于任何切片类型而言,len和swap方法完全相同,less方法则需要一个比较函数,也就是slicFn名称中的“Fn”部分。与前面的树示例一样,我们将在创建sliceFn时传入一个函数。下面示例展示了如何使用sliceFn通过比较函数对任何切片进行排序。

func SortFn[T any](s []T, cmp func(T, T) bool) {
    return sort.Sort(SLiceFn[T]{s, cmp})
}

在此示例中,非常适合使用类型参数。因为所有切片类型对应的方法看起来都完全相同。当你需要实现对于所有相关类型看起来相同的方法时,使用类型参数是合理的做法。

什么时候不适用泛型

现在,我们来谈一谈问题的另一面。什么情况下不适用泛型。

什么时候使用类型参数不是个好主意?Go具有接口类型,接口类型已经允许某种泛型编程。例如,广泛使用的io.Reader接口提供了一种通用机制用于从包含信息(如文件)或生成信息(如随机数生成器)的任何值中读取数据。

  • 对于某个类型的值,如果你只需对该值调用一个方法,请使用接口类型而不是类型参数。

io.Reader易于读取,有效且高效。从值中读取数据时,比如像调用Read方法不需要使用类型参数。例如,不要编写下面这样的代码。

func ReadFour[T io.Reader](r T) ([]byte, error) {
    buf := make([]byte, 4)
    _, err := io.ReadFull(r, buf)
    if err != nil {
        return nil, err
    }
    return buf, nil

}

上面代码中即使不使用类型参数也可以编写相同的函数,而且省略类型参数将使函数更易于编写和更易于阅读,并且执行时间可能相同。

最后值得强调的一点是,人们可能会假设使用特定类型实参实例化的函数往往比使用接口方法的代码稍快。当然,在Go中,确切的细节将取决于编译器,与使用接口方法的类似代码相比,使用类型参数实例化的函数很有可能并不会更快。因此,不要出于效率考虑使用类型参数。使用它们的原因是能让你的代码更清晰。如果他们使你的代码更复杂,请不要使用。

现在,回到类型参数与接口类型之间的选择。当不同类型使用一个共同的方法时,考虑该方法的实现。

  • 前面我们说过,如果一个方法实现对于所有类型都相同则使用类型参数,相反如果每种类型的实现各不相同,请使用不同的方法不要使用类型参数。

例如,从文件读取的实现与从随机数生成器读取的实现完全不同,这意味着我们要编写两种不同的读取方法,并且两种方法都不应使用类型参数。

虽然我今天只提到了几次,Go也有反射,反射确实允许进行某种通用编程。它允许你编写适用于任何类型的代码。如果某些操作必须支持没有方法的类型那么接口类型就不起作用,并且如果每种类型的操作都不相同请使用反射。这方面的一个例子是json编码包。我们不要求我们编码的每个类型都支持marshal json方法,因此我们不能使用接口类型。同时,对整数类型编码和对结构类型编码完全不同,因此我们不能使用类型参数。软件包中使用的是反射,相关代码太复杂,不方便在这里展示,但如果你有兴趣请查阅相关源码。

总结

上述整个讨论可以简化为一个简单的准则。

  • 如果你发现自己多次编写完全相同的代码,各个版本之前的唯一区别是代码使用不同的类型,请考虑是否可以使用类型参数。【另一种表达方法是,在你注意到自己要多次编写完全相同的代码之前,应该避免使用类型参数】

最后,希望各位读者谨慎、合理地在Go中使用泛型,同时衷心希望本文能够对各位读者有一定的帮助。

【关注公众号】