Golang 泛型介绍

泛型介绍

泛型是一种编写独立于所使用的特定类型的代码的方法。现在可以编写函数和类型(Functions and types)来使用一组类型中的任何一种。

泛型为语言添加了三个重要的东西:

  • 1 函数和类型的类型参数。
  • 2 将接口类型定义为类型集,包括没有方法的类型。
  • 3 类型推断,它允许在调用函数时在许多情况下省略类型参数。

1、类型参数(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](2, 3)

GMin提供类型参数(在本例中为int)称为实例化(instantiation)。实例化分两个步骤进行。首先,编译器在泛型函数或类型中替换它们各自的类型形参(type parameters )的所有类型实参(type arguments )。其次,编译器验证每个类型参数是否满足各自的约束。我们将很快了解这意味着什么,但是如果第二步失败,则实例化失败并且程序无效。

成功实例化之后,我们就有了一个非泛型函数,可以像调用其他函数一样调用它。例如,在类似的代码中

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

实例GMin[float64]产生的实际上是我们原始的浮点Min函数,我们可以在函数调用中使用它。

类型参数也可以与类型一起使用。

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的值。泛型类型可以有方法,如本例中的Lookup。为了使用泛型类型,它必须被实例化;Tree[string]是一个使用类型实数(type argument )string实例化Tree的例子。

2、类型集合(Type sets)

让我们更深入地了解可用于实例化类型参数的类型实参。

普通函数的每个值形参都有一个类型;该类型定义了一组值。例如,如果我们有一个float64类型,如上面的非泛型函数Min,则允许的参数值集合是可由float64类型表示的浮点值集合。

类似地,类型参数列表对每个类型参数都有一个类型。因为类型形参本身就是一个类型,所以类型形参的类型定义了类型集。这种元类型称为类型约束(type constraint)。

在泛型GMin中,类型约束是从constraints包中导入的。Ordered约束描述了所有类型的集合,这些类型的值可以排序,换句话说,可以与<操作符(或<=>等)进行比较。该约束确保只有具有可排序值的类型才能传递给GMin。这也意味着在GMin函数体中,该类型形参的值可用于与<操作符的比较。

在Go中,类型约束必须是接口(interfaces)。也就是说,接口类型可以用作值类型,也可以用作元类型。接口可以定义方法,因此显然我们可以表达需要提供某些方法的类型约束。但constraints.Ordered也是一个接口类型,<操作符不是一个方法。

为了做到这一点,我们以一种新的方式来看待接口。

直到最近,Go规范还说接口定义了一个方法集,它大致是接口中枚举的方法集。任何实现了所有这些方法的类型都实现了那个接口。

在这里插入图片描述
但是另一种看待这个的方式是说接口定义了一组类型,也就是实现那些方法的类型。从这个角度来看,作为接口类型集元素的任何类型都实现了接口。

在这里插入图片描述
这两个视图导致相同的结果:对于每一组方法,我们可以想象实现这些方法的相应类型集,这就是被接口定义的类型集。

但是,就我们的目的而言,类型集视图比方法集视图有一个优势:我们可以显式地向集合添加类型,从而以新的方式控制类型集。

我们已经扩展了接口类型的语法,以使其工作。例如,interface{ int|string|bool }定义了包含intstringbool类型的类型集。

在这里插入图片描述
换句话说,该接口只满足intstringbool类型。
现在让我们看看 constraints.Ordered的实际定义:

type Ordered interface {
    
    
    Integer|Float|~string
}

该声明表明Ordered接口是所有整数、浮点和字符串类型的集合。竖条表示类型的联合(在本例中是类型集)。
IntegerFloat是在constraints包中定义的类似的接口类型。注意,Ordered接口没有定义任何方法。

对于类型约束,我们通常不关心具体的类型,比如string;我们对所有字符串类型都感兴趣。这就是~标记的作用。表达式~string表示底层类型为string的所有类型的集合。这包括string类型本身,以及用type MyString string定义声明的所有类型。

当然,我们仍然希望在接口中指定方法,并且希望向后兼容。在Go 1.18中,接口可以像以前一样包含方法和嵌入接口,但它也可以嵌入非接口类型、联合和底层类型集。

当用作类型约束时,由接口定义的类型集精确地指定允许作为各自类型参数的类型实数的类型。在泛型函数体中,如果操作数的类型是具有约束C的类型实数P,则如果C的类型集中的所有类型都允许操作,则操作数也被允许操作(目前这里有一些实现限制,但普通代码不太可能遇到它们)。

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

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

这里S必须是一个切片类型,它的元素类型可以是任何类型。

由于这是一种常见的情况,对于处于约束位置的接口,可以省略interface{},我们可以简单地这样写:

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

因为空接口在类型参数列表中很常见,在普通的Go代码中也是如此,所以Go 1.18引入了一个新的预先声明的标识符any作为空接口类型的别名。这样,我们就得到了这个习惯代码:

[S ~[]E, E any]

接口作为类型集是一种强大的新机制,也是在Go语言中实现类型约束的关键。目前,使用新语法形式的接口只能用作约束。但是,不难想象显式类型约束接口在一般情况下是多么有用。

3、类型推理(Type inference)

最后一个新的主要语言特性是类型推断。在某些方面,这是对语言最复杂的改变,但它很重要,因为它让人们在编写调用泛型函数的代码时使用自然的风格。

3.1 函数实参类型推断

有了类型参数,就需要传递类型实参,这可能会导致代码冗长。回到我们的通用GMin函数:

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

类型形参T用于指定普通非类型实参xy的类型。如前所述,可以用显式类型实参调用它:

var a, b, m float64

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

在许多情况下,编译器可以从普通参数中推断出T的类型参数。这使得代码更短,同时保持清晰。

var a, b, m float64

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

这通过将实参ab的类型与形参xy的类型进行匹配来实现。

这种从函数实参的类型推断出类型实参的推理称为函数实参类型推断(function argument type inference)。

函数实参类型推断仅适用于在函数形参中使用的类型形参,而不适用于仅在函数结果或函数体中使用的类型形参。例如,它不适用于像 MakeT[T any]() T这样的函数,因为它们只使用T作为结果。

3.2 约束类型推断

该语言支持另一种类型推断,约束类型推断(constraint type inference)。为了说明这一点,让我们从缩放整数切片的例子开始:

// 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类型,其中每个Point都是给出该点坐标的整数列表。这种类型自然会有一些方法。

type Point []int32

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

有时我们想缩放一个Point。由于Point只是整数的切片,我们可以使用前面写的Scale函数:

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

不幸的是,这不能编译,失败的错误,如r.String undefined (type []int32 has no field or method String)

问题就是Scale函数返回类型为[]E的值,其中E是参数slice的元素类型。当使用Point类型的值(其底层类型为[]int32)调用Scale时,返回的值类型为[]int32,而不是Point类型。这是从泛型代码的编写方式得出的,但这不是我们想要的。

为了解决这个问题,我们必须更改Scale函数,以便为切片类型使用类型参数。

// 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,它是slice实参的类型。我们对它进行了约束,使得底层类型是S而不是[]E,结果类型现在是S。由于E被约束为整数,其效果与之前相同:第一个参数必须是某种整数类型的切片。函数体的唯一变化是,现在我们在调用make时传递S,而不是[]E

如果使用普通切片调用,则新函数的作用与以前相同,但是如果使用Point类型调用,则返回的是Point类型的值。这就是我们想要的。在这个版本的Scale中,早期的ScaleAndPrint函数将按照我们的预期编译和运行。

但公平地问:为什么可以在不传递显式类型参数的情况下编写对Scale的调用?也就是说,为什么我们可以写Scale(p, 2)而不带类型参数,而不是必须写Scale[Point, int32](p, 2)?我们新的Scale函数有两个类型参数SE。在不传递任何类型参数的Scale调用中,上面描述的函数参数类型推断让编译器推断S的类型参数是Point。但是这个函数还有一个类型参数E它是乘法因子c的类型。对应的函数参数是2,因为2是一个无类型(untyped )常量,函数参数类型推断不能推断出E的正确类型(它最多可能推断出2的默认类型是int,这是不正确的)。相反,编译器推断E的类型参数是切片的元素类型的过程称为约束类型推断(constraint type inference)。

约束类型推断从类型参数约束推导类型参数当一个类型参数具有根据另一个类型参数定义的约束时,使用它。当其中一个类型参数的类型实参已知时,将使用约束来推断另一个类型参数的类型实参。

通常的情况是,当一个约束对某种类型使用~type形式,而该类型是使用其他类型参数编写的。我们在Scale的例子中看到了这一点。S~[]E,它后面是用另一个类型参数表示的类型[]E。如果我们知道S的类型参数,我们就可以推断出E的类型参数。S是切片类型,而E是该切片的元素类型。

这只是对约束类型推断的介绍。有关详细信息,请参阅提案文档语言规范

3.3 实践中的类型推理

类型推断如何工作的确切细节是复杂的,但使用它并不复杂:类型推断要么成功,要么失败。如果成功,则可以省略类型参数,并且调用泛型函数看起来与调用普通函数没有什么不同。如果类型推断失败,编译器将给出错误消息,在这种情况下,我们可以只提供必要的类型参数。

在向语言中添加类型推理时,我们试图在推理能力和复杂性之间取得平衡。我们希望确保当编译器推断类型时,这些类型不会令人惊讶。我们尽量小心地避免在未能推断出类型的情况下出错,而不是推断出错误类型。我们可能还没有完全正确,我们可能会在未来的版本中继续完善它。其结果是,可以编写更多不需要显式类型参数的程序。今天不需要类型参数的程序明天也不需要它们。

4、结尾

泛型是1.18中一个重要的新语言特性。这些新的语言变化需要大量的新代码,而这些代码还没有在生产环境中进行过重要的测试。只有当越来越多的人编写和使用泛型代码时,这种情况才会发生。我们相信这个功能实现得很好,质量也很高。然而,与Go的大多数方面不同,我们无法用现实世界的经验来支持这种信念。因此,虽然我们鼓励在有意义的地方使用泛型,但在生产环境中部署泛型代码时,请谨慎使用。

撇开这些谨慎不谈,我们很高兴有了可用的泛型,我们希望它们能让Go程序员更有效率。

22 March 2022

猜你喜欢

转载自blog.csdn.net/chinusyan/article/details/129817676