likes
comments
collection
share

Golang泛型的理解

作者站长头像
站长
· 阅读数 20

自从2022年 Golang 1.18 发布至今已有一年多了,在1.18版本中增加了非常重磅的一个功能,那就是泛型!Golang官方也对泛型格外重视: “Generics are the biggest change we’ve made to Go since the first open source release”(泛型是自第一个开源版本以来我们对 Go 所做的最大改变)

然而由于平时工作项目所用Go版本较为古老,一直没有大范围应用泛型这个特性,虽然之前刚发布时就了解学习过,但是也有些模糊了,这里还是希望能够记录一下Golang泛型,以备后续参考,我讲基于Golang官方文档、博客、youtube演讲等来进行学习。

什么是泛型

泛型是一种可以编写独立于使用的特定类型的代码的方法,可以通过编写函数或类型来使用一组类型中的任何一个。泛型为Golang增添了三个重要功能:

Golang泛型的理解

  1. 函数和类型的类型参数
  2. 将接口类型定义为类型集,包括没有方法的类型。也就是我们可以定义类型集和方法集
  3. 类型推断,允许函数调用时省略类型参数

我们从类型参数开始逐步了解

类型参数 Type parameters

类型参数让我们可以参数化函数或者具有类型的类型,与普通的参数列表类似,类型参数使用方括号来表示

函数中使用类型参数

这里有一个常见的取最小值函数,我们经常会在代码中写(新版的Golang官方库已经支持了max以及min):

func Min(x, y float64) float64 {
    if x < y {
        return x
    }
    return y
}

我们可以通过类型参数来替换 float64 类型来使这个函数更加通用,让这个函数不仅仅适用于 float64 类型,可以这样来做:

import "golang.org/x/exp/constraints"

func Min[T constraints.Ordered](x, y T) T {
    if x < y {
        return x
    }
    return y
}

m := Min[int](2, 3)

在这里我们使用了类型参数 T 来替换 float64 类型使得函数通用,由于 T 是一个新的类型,所以我们要在 [] 中声明它。

函数定义好后,与普通的函数调用类似,我们需要传入函数的实参以及创建一个接收值来接受函数实际返回的结果,不同的是,我们需要在 [] 传入具体的类型值,向函数提供类型参数 int 成为实例化。

实例化将会分两步进行:

Golang泛型的理解

  1. 编译器将整个泛型函数或类型中的所有类型实参进行替换
  2. 编译器验证每个类型参数是否满足了各自的约束

如果编译器在第二步执行失败,实例化就会失败且程序会fail。

我们也可以直接传入类型参数来实例化函数,而不需要传入具体实参进行实际调用,实例化过后我们就可以像普通函数调用一样来调用这个实例化过后的函数了:

fmin := Min[float64]
m := fmin(2.71, 3.14)

类型中使用类型参数

前面是一个函数中使用类型参数的例子,还有一个在类型中使用类型参数的例子:

type Tree[T interface{}] struct {
    left, right *Tree[T]
    value       T
}

func (t *Tree[T]) Lookup(x T) *Tree[T] { ... }

var stringTree Tree[string]

这是一个通用的二叉树类型定义,我们再次采用了类型参数 T 来作为一个通用性的数据类型,这里定义了类型 Tree[T] 同时定义了它所具有的方法 Lookup(x T) *Tree[T]

最后一行通过 var 对变量 stringTree 做了一次实例化,传入参数类型为 string

类型集

func min(x,y float64)float64

func Gmin[T constraints.Ordered](x, y T) T

普通函数 min() 每个参数值都有一个类型,例如min函数中,限定了 x,y 及 返回值只有在 float64 类型时才有效;而函数 Gmin() 类型参数列表中每个类型参数都有一个类型,由于类型参数本身就是一种类型,因此类型参数的类型定义了类型集,这种元类型告诉了我们那些类型对该参数类型有效,因此这个元类型实际定义了类型集,我们可以称之为类型约束。

Gmin() 中,类型越是是从约束包中导入的,这个包也是 Golang 标准库中新增的包。这个Ordered约束描述了具有可排序值的所有类型的集合,或者换句话说,约束了能够使用 < 运算符(或 <= 、 > 等)进行比较的类型范围。所以只有具有可排序值的类型才能传递给GMin,在GMin函数体中,该类型参数的值可以用于与 < 等运算符进行比较。

接口类型定义为类型集 Type sets define by interface

在Golang中的类型约束必须是接口,接口类型可以用作值类型,也可以用作元类型。接口定义了方法,所以我们可以选择需要存在某些方法的类型约束。但是在例子中的constraints.Ordered也是一个接口类型,并且 < 运算符也并不是一个方法,那它是如何工作的呢?

在Golang的规范中,如果我们有一个接口定义了一组方法,带有方法 a b c,那我们就可以说接口定义了一个带有方法 a b c 的方法集,实现了这些方法的每个类型也就实现了该接口。如图所示,类型 P Q R 都实现了接口

Golang泛型的理解

我们可以换个角度来看待这个规范,那就是接口定义了一组类型,这些类型都要具有接口中的方法。从这个角度来看,每个接口方法集都可以延伸出无限的类型,作为接口类型集元素的任何类型都实现该接口。要检查某一类型是否实现了接口,仅需检查该类型是否是该类型集的元素。

Golang泛型的理解

通过视角的转换,就我们的目的而言,类型集视图比方法集视图有一个优势:我们可以显式地将类型添加到集合中,从而以新的方式控制类型集。

Golang通过对接口类型语法的扩展来实现这一点。在下面的示例中,我们有一个具有int\string\bool三种类型的接口示例,并且该接口定义了这三种类型的集合

Golang泛型的理解

实际声明的constraints.Ordered

package constraints

type Ordered interface {
    Integer|Float|~string
}

这表明,Ordered接口是所有 int\float\string 类型的集合,竖线"|"表示这些类型的联合(类型集)。Ordered接口没有定义任何方法。"~"表示我们接受任意的基础类型为string的类型集,包括类型string本身以及使用定义声明的所有类型,例如type MyString string

我们如果想在接口中规定方法,那么仍然是向后兼容的,在1.18版本后,接口可以像之前一样包含方法或嵌入接口,同时我们也可以嵌入非接口类型、联合和底层类型集。

用作约束的接口可以指定名称(例如Order),也可以是内联在类型参数列表中的接口,例如:

[S interface{~[]E}, E interface{}]

这里类型参数列表中有两个类型参数,分别是 S 和 E,S定义的是一个切片类型的类型参数,其元素类型可以是任何类型,由于 interface{} 在约束时可以省略封装,因此可以简单写为:

[S ~[]E, E interface{}]

// Go1.18中引入的any即为interface{}: interface{} => any
[S ~[]E, E any]

作为类型集的接口是一种新机制,是Go中类型约束的关键。

类型推断 type inference

函数参数类型推断

有了类型参数,就需要传递类型参数,这可能会导致代码冗长

func GMin[T constraints.Ordered](x, y T) T { ... }

var a, b, m float64

m = GMin[float64](a, b) // explicit type argument

这里我们指定了实例化的参数类型 float64,实际上,编译器可以从实参推断出类型参数,这样可以使代码更为简洁清晰:

var a, b, m float64

m = GMin(a, b) // no type argument

这种从函数参数的类型推断出参数类型的推断称为函数参数类型推断。如果推断成功,函数就和普通函数一样正常使用,如果不成功,编译器会报错,我们还是需要指定类型。

函数参数类型推断仅适用于在函数参数中使用的类型参数,不适用于在函数结果或仅在函数体中使用的类型参数。

约束类型推断

从一个整数切片的缩放示例看起:

// Scale returns a copy of s with each element multiplied by c.
// This implementation has a problem, as we will see.
func Scale[E constraints.Integer](s []E, c E) []E {
    r := make([]E, len(s))
    for i, v := range s {
        r[i] = v * c
    }
    return r
}

这个函数适用于所有整数类型的切片,看起来和之前的没有什么不同,但是存在一个问题,让我们继续揭开这个问题:

假设我们有一个 Point 类型,它是一个 int32 类型元素的切片,同时他具有自己的一个方法 String():

type Point []int32

func (p Point) String() string {
    // Details not important.
}

这时候我们想要去放大 Point 为2倍,我们可以调用 Scale 函数:

// ScaleAndPrint doubles a Point and prints it.
func ScaleAndPrint(p Point) {
    r := Scale(p, 2)
    fmt.Println(r.String()) // DOES NOT COMPILE
}

看起来没有什么问题,但是编译就会报错。

问题在于,调用 Scale() 函数后,会返回一个 []E 类型的值,由于传入的实参类型为 int32,因此函数的实际返回值为 []int32 而不是 Point,而 []int32 类型不具有其他方法。为了解决这个问题,我们要这样做:

// Scale returns a copy of s with each element multiplied by c.
func Scale[S ~[]E, E constraints.Integer](s S, c E) S {
    r := make(S, len(s))
    for i, v := range s {
        r[i] = v * c
    }
    return r
}

通过引入新的参数类型 S 及约束,使得返回值类型为 S 而不是 []E,函数唯一的变化为,在实际调用时传递 S 而不是 []E,同样返回值会返回 type Point。

这里也就引出了约束类型推断的概念,也就是说,我们无需通过指定约束类型来实例化调用函数,类似于:

r := Scale[Point, int32](p, 2)

编译器可以推断出类型参数 S 是 Point。但是该函数还有一个类型参数E,实参为 2,2是一个无类型的常量,因此, 编译器推断出 E 的类型实参是切片的元素类型过程就是我们所说的约束类型推断

通常情况当一个约束使用某种类型的形式时 ,其中该类型是使用其他类型参数编写的,会用到这种应用场景

官方建议的泛型使用时机

适用场景:

适用于任何元素类型的切片、映射和通道的函数 通过通用数据结构用于通用数据(非接口类型,省去断言) 当不同类型实现通用方法并且不同类型的方法实现看起来都一样时

非适用场景:

仅在类型参数上调用方法时(例如io.reader) 当每种类型的公共方法的实现不同时 当对不同参数具有不同操作时

参考

go.googlesource.com/proposal/+/… go.dev/blog/intro-… www.youtube.com/watch?v=qyM… go.dev/ref/spec