使用Go中泛型的详细指南

131 阅读10分钟

Go中的泛型就在眼前了!这是自Go语言发布以来人们最期待的功能之一。许多开发者甚至说,Go以前缺乏泛型,使得这门语言根本无法使用,太痛苦了。让我们深入了解一下什么是泛型,为什么你可能在自己的项目中使用它们,以及它们在Go中是如何工作的。

什么是泛型?

简单的说,泛型允许程序员编写行为,因为类型并不直接相关,所以可以稍后再指定类型。这是一个了不起的功能,因为它允许编写抽象函数,极大地减少了代码的重复。例如,下面这个泛型函数将把一个片子分成两半,不管片子里的类型是什么。

func splitAnySlice[T any](s []T) ([]T, []T) {
    mid := len(s)/2
    return s[:mid], s[mid:]
}

想想看,要把一个片断分成两半,我们并不关心它是整数片断还是字符串片断,其逻辑是一样的。

例如,我们可以用下面的代码来调用它:

func main() {
    firstInts, secondInts := splitAnySlice([]int{0, 1, 2, 3})
    fmt.Println(firstInts, secondInts)
    // prints [0 1] [2 3]

    firstStrings, secondStrings := splitAnySlice([]string{"zero", "one", "two", "three"})
    fmt.Println(firstStrings, secondStrings)
    // prints [zero one] [two three]
}

泛型是许多流行的强类型编程语言的一个特征,因为它们具有减少重复代码的惊人能力。在JavaScript和Python这样的动态类型语言中,你不会需要泛型,但在Go中,它是对语言的一种神奇补充。

Go中的泛型,简明扼要

我试着用几个要点来总结Go中泛型的规范:

  • 函数和类型在其正常参数之前可以有一个额外的类型参数列表,使用方括号来表示在函数主体中使用的泛型类型。这些类型参数可以像其他参数一样在定义的其余部分和文本的主体中使用。比如说
    • func splitAnySlice[T any](s []T) ([]T, []T)
  • 类型参数是用 "约束 "来定义的,它是接口类型。约束条件定义了类型参数所需的方法和允许的类型,并描述了泛型的可用方法和操作。
  • 类型推理常常允许类型参数被省略。
  • 一个名为any 的特殊内置约束与interface{} 的行为类似。
  • 在标准库中会有一个名为constraints 的新包,它将包含常用的约束。

为什么我应该关心泛型?

Go是一门神奇的语言,它强调简单性和向后兼容性。换句话说,Go特意忽略了许多其他语言所吹嘘的功能,因为这反倒使语言变得更好(至少在某些人看来,对于某些用例)。一个代码库中的Go代码看起来像另一个代码库中的Go代码。一般来说,有 "一种方法 "可以做。

根据Go调查的历史数据,Go缺乏泛型一直被列为该语言的三大问题之一。在某种程度上,缺乏一个功能所带来的弊端证明了增加语言的复杂性是合理的。社区和核心团队对此进行了多年的讨论,但在这一点上,对泛型的支持似乎是压倒性的。

简而言之,你应该关心泛型,因为它意味着你不必写那么多的代码,特别是如果你在写包和工具的业务中。在没有泛型支持的情况下,编写实用函数是很令人沮丧的。想想常见的数据结构,如二进制搜索树和链接列表。为什么你要为它们可能包含的每一种类型重写它们呢?int,bool,float64, 和string 并不是清单的终点,因为你可能想存储一个自定义的struct 类型。

泛型最终将为Go开发者提供一种优雅的方式来编写令人惊叹的实用程序包。

什么是约束条件?

有时你需要你的泛型函数中的逻辑知道有关类型的一两件事。约束是一种接口,它允许你编写只在特定接口类型的约束范围内操作的泛型。在上面的第一个例子中,我们使用了any 约束,这相当于空interface{} ,因为它意味着有关的类型可以是任何东西。

任何约束

any "约束 "的效果很好,如果你把值当作一个数据桶,也许你正在移动它,但你根本不关心这个数据桶里有什么。

根据propsalany 类型允许的操作如下:

  • 声明这些类型的变量
  • 为这些变量分配相同类型的其他值
  • 将这些变量传递给函数或从函数中返回这些变量
  • 获取这些变量的地址
  • 将这些类型的值转换或分配给该类型的值interface{}
  • 将类型T 的值转换为类型T (允许但无用)。
  • 使用类型断言将一个接口值转换为该类型
  • 在类型转换中使用该类型作为一个案例
  • 定义并使用使用这些类型的复合类型,例如该类型的一个片断
  • 将该类型传递给一些预先声明的函数,如new

如果你确实需要了解更多关于你正在处理的通用类型,你可以使用接口来约束它们。例如,也许你的函数将与任何可以表示自己为字符串的类型一起工作。

type stringer interface {
    String() string
}

func concat[T stringer](vals []T) string {
    result := ""
    for _, val := range vals {
        result += val.String()
    }
    return result
}

可比约束

comparable 约束也是一种预定义的约束,就像any 约束一样。当使用可比约束而不是any 约束时,你可以在你的函数逻辑中使用!=== 操作符。

func indexOf[T comparable](s []T, x T) (int, error) {
    for i, v := range s {
        if v == x {
            return i, nil
        }
    }
    return 0, errors.New("not found")
}

func main() {
    i, err := indexOf([]string{"apple", "banana", "pear"}, "banana")
    fmt.Println(i, err)
    // prints 1 <nil>
}

自定义约束

参数化约束

你的接口定义,以后可以作为约束条件使用,可以采取自己的类型参数。

type vehicleUpgrader[C car, T truck] interface {
    Upgrade(C) T
}

类型列表

提案中,我们可以简单地列出一堆类型来得到一个新的接口/约束:

// Ordered is a type constraint that matches any ordered type.
// An ordered type is one that supports the <, <=, >, and >= operators.
type Ordered interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
        ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
        ~float32 | ~float64 |
        ~string
}

混合

我们也可以把参数化声明和类型列表混合起来,得到新的接口:

type ComparableStringer interface {
    comparable
    String() string
}

自参考性

Cloneable interface {
    Clone() Cloneable
}

泛型类型与泛型函数

所以我们知道我们可以编写使用通用类型的函数,但是如果我们想创建一个可以包含通用类型的自定义类型呢?例如,一个可排序对象的片断。新的提议使这成为可能。

type comparableSlice[T comparable] []T

func allEqual[T comparable](s comparableSlice[T]) bool {
    if len(s) == 0 {
        return true
    }
    last := s[0]
    for _, cur := range s[1:] {
        if cur != last {
            return false
        }
        last = cur
    }
    return true 
}

func main() {
    fmt.Println(allEqual([]int{4,6,2}))
    // false

    fmt.Println(allEqual([]int{1,1,1}))
    // true
}

获取泛型的零值

var name T 语法是在 Go 中生成泛型类型的零值的一种简单方法。考虑到成语Go对保护句的一贯使用,这一点特别有用。

func returnZero[T any](s ...T) T {
    var zero T
    return zero
}

func main() {
    fmt.Println(returnZero(5))
    // prints "0"
    fmt.Println(returnZero("string"))
    // prints ""
    fmt.Println(returnZero(true))
    // prints "false"
}

泛型的局限性

不能对泛型的底层类型进行切换

// DOES NOT WORK
func is64Bit[T Float](v T) T {
    switch (interface{})(v).(type) {
    case float32:
        return false
    case float64:
        return true
    }
}

绕过这一点的唯一方法是直接使用接口并执行运行时的类型切换。

没有继承性

如果您希望泛型可以使 Go 成为具有完全继承能力的面向对象语言,那么您会感到失望。虽然泛型可以减少代码的重复,但你仍然不能对类型的层次进行子类化。

泛型与接口

Go中的接口是通用编程的一种形式,它让我们要求不同的类型实现相同的API。然后我们编写实现这些接口类型的函数,这些函数对任何实现这些方法的类型都有效。Tada,我们有了一个漂亮的抽象层。

在很多情况下,这种方法的问题在于,它要求每个类型都要重写其逻辑,即使逻辑是相同的。Go中的泛型使用接口作为约束条件,因为接口已经是执行必要的API的完美东西了,但是泛型增加了一个新的功能,即类型参数,这可以使我们的代码变得更加干燥

泛型与代码生成

Go程序员有使用代码生成的历史,工具链甚至内置了go generate。简而言之,由于Go缺乏泛型,过去许多开发者使用代码生成来解决这个问题。他们会生成几乎相同的函数的副本,其中唯一真正的区别是参数的类型。

现在,有了泛型,我们就可以停止生成这么多的代码了。代码生成在解决其他问题时仍有一席之地,但在任何需要为多种类型编写相同逻辑的地方,我们应该使用泛型。泛型是一个更优雅的解决方案。

现在就使用泛型

今天你可以在Golang.org 的泛型操场上玩玩泛型。你也可以在本地使用测试版的编译器

泛型是如何工作的?

泛型实际上只是语法上的糖,使用泛型不会对你的代码的运行速度产生什么基本影响。由于实现尚未完全发布,我们还不太清楚对性能的影响到底是什么。也就是说,这是我的猜测:

  1. 编译时间会延长一些(可能可以忽略不计)非零的因素:在我看来,增加一个新的编译时间特性并不能帮助编译器运行得更快。
  2. 泛型与单类型函数(无论是手工编写还是代码生成)的运行时间几乎是相同的。
  3. 一般来说,泛型函数在运行时的表现会比接口好一些(可能是可以忽略不计的)非零因素。由于类型断言等原因,接口似乎可能会有一些额外的运行时开销。

标准库现在会使用泛型吗?

对于新的函数、类型和方法,答案是肯定的。然而,对于现有的API,Go团队似乎仍然致力于不破坏向后兼容性,在我看来这是一个伟大的决定。Russ Cox开了一个讨论来讨论这个问题,他有一个建议,就是重写那些如果我们今天写的话显然会使用泛型的类型和函数。

他建议为更新的函数采用 "Of "后缀。例如,sync.Pool变成sync.PoolOf。

泛型会改变 "习惯 "吗?

肯定会的。一个微不足道的例子是,如果你需要一个数字类型,过去默认使用float64 是明智的。现在有很多情况下,你可以使用某种数字约束,并将你的代码开放给更多的重用。

我很高兴地看到,随着泛型进入生产代码,我们都能玩转它们,会出现哪些新的最佳实践。