小马别过河

V1

2022/10/07阅读:23主题:默认主题

Go 概念[译]:泛型介绍

Go 1.18 版本增加了对泛型的支持。泛型是我们自 Go 开源以来最大的变动。本文介绍这个新功能。我们会介绍要点,而不是涵盖所有细节。如果想要所有细节和更多描述,包括例子,可以查看 proposal document。查阅 updated langue spec 获取更准确的描述。

泛型是一种独立于已有的特定类型的编码方式。编写函数和类型可以使用类型集的其中一个。

泛型为 Go 语言添加了3个重大的功能:

  1. 函数和类型的类型参数
  2. 接口作为类型集,包括没有方法的类型。
  3. 类型推断,当调用函数时,它允许在大多情况下忽略类型参数。

Type Parameters(类型参数)

函数和类型如今允许有类型参数。一个类型参数列表看起来像普通的参数列表,只不过他使用中括号,而不是小括号。

为了展示它如何工作的,我们从一个作用于浮点数的非泛型函数 Min 开始:

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

我们可以增加类型参数,让这个函数泛化,使其支持不同类型的参数。在这个例子里,我们增加一个类型参数 T,然后把 float64 替代成 T

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

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

现在可以附带一个类型参数,调用这个这个函数,像这样:

x := GMin[int](23)

GMin提供类型参数(本例中是int)的过程,叫做"实例化"(instantiation)。实例化分为两步。第一,编译器会把泛型函数或类型的类型参数,替换成各自的类型。第二,编译器验证类型参数是否满足条件约束,这个后面会讲,如果第二步失败了,实例化失败,程序也会编译不通过。

实例化成功以后,我们得到一个非泛化的普通函数。比如

fmin := GMin[float64]
m := fmin(2.713.14)

实例化GMin[float64]得到的fmin,和Min(x, y float64)效果一样,我们可以在函数调用中使用它。

类型参数也可以用在定义类型的场景。

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

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

var stringTree Tree[string]

泛型结构体Tree存着类型参数T。泛型可以有用方法,像例子中的LoopUp一样。使用泛型前,必须先实例化。Tree[string]就是使用string实例化Tree

类型集(Type Sets)

我们深入一点,类型实参可以用作实例化类型参数.

普通函数的每个参数都有各自的类型。比如,上面的非泛型函数Min的参数是float64,允许传入的参数值都必须是浮点的。

同样的,类型参数本身是个类型,它的定义了一系列的类型。这个元类型(meta-type)称作类型约束(type constraint)。

泛型函数GMin中,类型约束(constraints.Ordered)是从 constraints 包导入的。Ordered约束描述了可以被排序的一系列类型,可排序意味着可比较(< ,<=,> 等操作符)。这个类型约束可以确保传给GMin的参数值都是可比较的。这也就是说GMin 函数体用到的类型参数可用作比较。

Go 语言中,类型约束必须是接口(interface)。一个接口类型可以作为值类型,也可以作为元类型。接口定义了方法集,所以显然类型约束也需要实现对应的方法。但是constraints.Ordered是个接口,而操作符 < 不是一个方法。

为了解决这个问题,我们扩展了接口。

最新 Go 规格规定,接口定义了一个方法集(method set)。任何类型实现了这些方法,也实现了接口。

方法集
方法集

换个角度看,接口定义了类型集(type set),实现了方法的类型,都可以算作类型集的一员。

类型集
类型集

这两个角度引出同一个结果:对于每个方法集,我们都可以想象成实现对应方法的类型集,也是被接口定义的类型集。

不过,对于我们而言,类型集的视角比方法集的视角更具优势:我们可以显式地增加类型到类型集,从而以新的方式控制类型集。

我们扩展了接口的语法以达到目的。比如,interface{int|string|bool} 定义了包含了intstringbool 的一个类型集。

类型集
类型集

换种说法就是只有int,string,bool 才能满足这个接口。

我们看下 constraints.Ordered 的定义:

type Ordered interface {
    Integer|Float|~string
}

这个声明说的是,Ordered接口是所有的整形、浮点型、字符串类型的集合。竖线表示并集。IntegerFloat也是定义在constraints包的接口类型。注意的是Ordered接口是没有定义任何方法的。

对于类型约束,我们通常不关心具体的类型,如string。我们感兴趣的是所有的string类型。这就是~的作用。表达式~string意味着底层是string类型的所有类型的集合。包括string本身和其他所有像 type MyString string 这种自定义的类型。

当然我们依然希望在接口中定义方法,并且我们希望向后兼容。在 Go 1.18 一个接口可以像之前一样包含方法和内嵌其他接口,不同的是,它还能内嵌非接口类型,并集,和底层类型集。

当我们使用类型约束,接口规定的类型集明确哪些类型参数是被允许的。在泛型的函数体,如果参数是 P,约束是 C, 对 P 的操作必须是适用于所有 C 类型集。

被用作约束的接口可能会定义接口名(比如 Ordered),或者是字面量的形式用在参数列表。比如

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

这里 S 必须是个切片,切片元素的类型不做限制。

因为这很常见,所以用作约束条件的接口 interface{} 可以省略,也就是简化成:

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

因为空接口在类型参数和普通代码中很常见,Go 1.18 预声明了 any 作为空接口的别名。有了它,上面的代码我们可以写作:

[S ~[]E, E any]

接口作为类型集是一种强大的机制,也是 Go 的类型约束的关键。现在,使用新语法的接口只能用作类型约束。但是不难想象,明确了类型约束的接口将会在泛型中发挥多大的用处。

类型推断 (Type inference)

最新的主要语言特性是类型推断。从某个角度上说,这是最复杂的语言变更,但是它让人们在写代码调用泛型函数时,使用比较自然的风格。

函数参数类型推断

使用类型参数(形参)需要传递类型参数(实参),这会使代码冗余。回到我们上文提过的 GMin 泛型函数:

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

类型参数 T 用作定义 x、 y。正如我们早前说的,调用时可以传入具体的类型。

var a, b, m float64

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

在很多场景编译器可以从普通参数推断出 T 的类型参数。这可以使代码更短,同时保持清晰。

var a, b, m float64

m = GMin(a, b) // 没有类型参数

根据函数实参来推断形参的类型的机制,称之为函数参数类型推断(function argument type inference)

函数参数类型推断仅用于函数参数,不作用于函数返回值或者只在函数体的类型参数。比如,它不适用于像 MakeT[T any]() T 只使用 T 作为结果的函数。

约束类型推断(Constraint type inference)

Go 语言支持另一种类型推断,约束类型推断。为了描述这些,我们来看看另一个例子:

// Scale 返回一个 s 的复制,s 的所有元素乘以 c
// 这个实现是有问题的
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
}

这个泛型函数用于所有整形的切片。

假设我们有一个底层是 []int32Point 类型,同时有着自定义方法。

type Point []int32

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

既然 Point 是一个整形的切片,我们可以把它传给 Scale 函数:

// ScaleAndPrint 把 Point 的元素乘以 2,再打印
func ScaleAndPrint(p Point) {
    r := Scale(p, 2)
    fmt.Println(r.String()) // 无法编译通过
}

然而这编译不会通过,并报类似这种错误 r.String undefined (type []int32 has no field or method String)

问题在于, Scale 方法返回值是 []E, E 是切片元素的类型。当我们调用 Scale(Point) 时,底层类型是 []int32,我们返回的类型是 []int32,而不是 Point。这并不是我们预期的。

为了解决这个问题,我们增加 Scale 函数的切片参数的类型参数。

func Scale[S ~[]EE 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的底层类型是 S 而不是 []E,返回值是 S。既然 S 的约束是整数,效果和以前一样:第一个参数必须是整形切片。唯一的区别是,函数体我们把make([]E, len(s))改成make(S, len(s))

如果参数是普通切片,新旧函数的表现是一样的,但如果我们传递的参数是 Point,新的函数返回值是 Point。这是符合我们预期的。使用新版本的 ScaleScaleAndPrint 可以编译通过并如我们预期地运行。

公平地问一句:为什么不显示地指明类型参数还能成功调用 Scale? 也就是说,为什么我们可以写Scale(p, 2),而不是写Scale[Point, int32](p, 2)?我们的新版Scale函数有两个参数:SE。调用Scale没有传递任何类型参数,编译器的函数参数类型推断推断出 S 的类型是 Point。但是函数还有一个类型参数E,用于约束乘数c。对应的函数值是 2,由于 2 无类型的常量,函数参数类型推断机制无法检测出E的当前类型(最多推断出 2 的默认类型 int,但这是错误的)。相反,编译器推断E是切片的元素类型的过程,称作约束类型推断。

约束类型推断是从类型参数约束推导出类型参数。当一个类型参数是由另一个类型参数定义的时候,会用到它。当类型参数中的一个是已知的,约束会通过它推断出其他参数的类型。

这适用的其中一个场景是,当一个约束使用 ~type 定义某种类型,然后这个约束被用在另一个类型约束上。比如在 Scale 中,S~[]E,E 是定义在另一个参数上。如果我们知道 S 的类型,就可以推断出 E 的类型。S是一个切片类型,E是切片的元素。

实践中的类型推断

类型推断的细节非常复杂,但是使用起来很简单:只有成功或失败。如果推断成功,类型参数可以忽略,此时可以像调用普通函数一样调用泛型函数。如果推断失败,编译器会报错,我们据此调整就行。

增加类型推断时,我们试着在推断能力和复杂性之间取得平衡。我们想要确保,编译器推断类型时,不会出现意外的类型。我们注意在推断不出时报错,而不是推断出错误的类型。可能目前不是十全十美,但是我们会将来还会进一步优化。结果将是更多的程序无需指明类型参数。目前已经不用指明类型参数的程序,将来也不用。

结论

泛型是 1.18 的重大新特性。这些新语言变更需要经过生产环境的测试。这需要更多的人编写和使用泛型代码。我们相信,这个特性实现良好且质量高。然而,不像 Go 的其他方面,我们无法实际生产经验来支持这种信念。因此,虽然我们鼓励在合适的地方使用泛型,但也请在生产环境中也请小心谨慎。

抛开谨慎的说法,我们也很激动有泛型可用,希望它能使 Go 开发者更加高效。

引用

[1] An Introduction To Generics(https://go.dev/blog/intro-generics)

分类:

后端

标签:

Golang

作者介绍

小马别过河
V1