• Go泛型详解


    引子

    如果我们要写一个函数分别比较2个整数和浮点数的大小,我们就要写2个函数。如下:

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

    2个函数,除了数据类型不一样,其他处理逻辑完全一言。那有没有方法能一个函数完成上面的功能呢?有,那就是泛型。

    func min2[T int | float64](x, y T) T {
    	if x < y {
    		return x
    	}
    	return y
    }
    

    泛型

    官网文档:https://go.dev/blog/intro-generics
    泛型为该语言添加了三个新的重要功能:

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

    类型参数(Type Parameters)

    现在允许函数和类型具有类型参数。类型参数列表看起来与普通参数列表类似,只是它使用方括号而不是圆括号。
    在这里插入图片描述

    package main
    
    import (
    	"fmt"
    	"golang.org/x/exp/constraints"
    )
    
    func GMin[T constraints.Ordered](x, y T) T {
    	if x < y {
    		return x
    	}
    	return y
    }
    
    func main() {
    	x := GMin[int](2, 3)
    	fmt.Println(x) // 输出结果为2
    }
    
    

    其中constraints.Ordered是自定义类型(这里不展示源码)。
    理解不了的,可以暂时把constraints.Ordered替换为 int | float64

    向 GMin 提供类型参数(在本例中为 int)称为实例化(instantiation)。实例化分两步进行。

    • 首先,编译器在整个泛型函数或类型中将所有类型实参替换为其各自的类型参数。
    • 其次,编译器验证每个类型参数是否满足各自的约束。

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

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

    全部代码为

    package main
    
    import (
    	"fmt"
    	"golang.org/x/exp/constraints"
    )
    
    func GMin[T constraints.Ordered](x, y T) T {
    	if x < y {
    		return x
    	}
    	return y
    }
    
    func main() {
    	fmin := GMin[float64] // 相当于func GMin(x, y float64) float64{...}
    	m := fmin(2.71, 3.14)
    	fmt.Println(m) // 输出结果为2.71
    }
    
    

    实例化 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] 是使用类型参数 string 实例化 Tree 的示例。

    类型集(Type sets)

    类型参数列表中的每个类型参数都有一个类型。由于类型参数本身就是一种类型,因此类型参数的类型定义了类型集。这种元类型称为类型约束

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

    在 Go 中,类型约束必须是接口。也就是说,接口类型可以用作值类型(value type),也可以用作元类型(meta-type)。

    1. 接口作为值类型:

    当接口用作值类型时,它定义了一组方法,任何实现了这些方法的类型都可以赋值给这个接口变量。这是接口最常见的用法。

    例如:

    type Stringer interface {
        String() string
    }
    
    type Person struct {
        Name string
    }
    
    func (p Person) String() string {
        return p.Name
    }
    
    var s Stringer = Person{"Alice"} // Person 实现了 Stringer 接口
    fmt.Println(s.String()) // 输出: Alice
    

    在这个例子中,Stringer 接口被用作值类型,Person 类型实现了 String() 方法,因此可以赋值给 Stringer 类型的变量。

    1. 接口作为元类型(meta-type):

    当接口用作元类型时,它定义了一组类型约束,用于泛型编程。

    例如:

    type Ordered interface {
        int | float64 | string
    }
    
    func Min[T Ordered](a, b T) T {
        if a < b {
            return a
        }
        return b
    }
    
    fmt.Println(Min(3, 5))       // 输出: 3
    fmt.Println(Min(3.14, 2.71)) // 输出: 2.71
    fmt.Println(Min("a", "b"))   // 输出: a
    

    在这个例子中,Ordered 接口被用作元类型,它定义了一组可以进行比较操作的类型(整数、浮点数和字符串)。Min 函数使用这个接口作为类型约束,可以接受任何满足 Ordered 约束的类型作为参数。

    它们不仅可以定义对象的行为(作为值类型),还可以定义类型集合(作为元类型),从而在保持语言简洁性的同时,大大增强了代码的表达能力和复用性。

    直到最近,Go 规范还说接口定义了一个方法集,大致就是接口中枚举的方法集。任何实现所有这些方法的类型都实现该接口。
    在这里插入图片描述
    但看待这个问题的另一种方式是说接口定义了一组类型,即实现这些方法的类型。从这个角度来看,作为接口类型集元素的任何类型都实现该接口。
    在这里插入图片描述
    这两种视图导致相同的结果:对于每组方法,我们可以想象实现这些方法的相应类型集,即由接口定义的类型集。

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

    我们扩展了接口类型的语法来实现这一点。例如,interface{ int|string|bool } 定义了包含 int、string 和 bool 类型的类型集。
    在这里插入图片描述
    另一种说法是,该接口仅由 int、string 或 bool 满足。

    现在让我们看看constraints.Ordered的实际定义:

    type Ordered interface {
        Integer|Float|~string
    }
    

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

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

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

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

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

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

    因为这是常见的情况,所以对于约束位置的接口,可以省略封闭的interface{},我们可以简单地编写(Go 语言中泛型的语法糖和类型约束的简化写法):

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

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

    [S ~[]E, E any]
    

    在使用类型约束时,如果省略了外层的interface{}会引起歧义,那么就不能省略。例如:

    type IntPtrSlice1 [T * int][]T             // 错误,这里会把*误会为乘号
    type IntPtrSlice2[T *int,] []T             // 正确,只有一个类型约束时可以添加`,`
    type IntPtrSlice3[T interface{ *int }] []T // 正确,使用interface{}包裹
    type IntPtrSlice4[T *int, T2 *int] []T     // 正确
    

    只有IntPtrSlice1是语法错误的,IntPtrSlice2-4语法正确

    类型推断(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) // no type argument
    

    这是通过将参数 a 和 b 的类型与参数 x 和 y 的类型进行匹配来实现的。
    这种从函数参数的类型推断出参数类型的推断称为函数参数类型推断

    约束类型推断

    该语言支持另一种类型推断,即约束类型推断。为了描述这一点,让我们从缩放整数切片的示例开始:

    // 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 只是整数切片,因此我们可以使用之前编写的 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) 的错误。

    完整代码见:https://gitee.com/qingfeng-169/blog-code/blob/master/csdn/generics/demo1/main.go

    问题在于Scale(p, 2)相当于Scale[int32](p, 2),Scale 函数返回 []E 类型的值,其中 E 是参数切片的元素类型(这里是int32)。当我们使用 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,它是切片参数的类型。我们对其进行了约束,使基础类型为 S 而不是 []E,结果类型现在为 S。由于 E 被约束为整数,因此效果与之前相同:第一个参数必须是某种整数类型的切片。函数体的唯一变化是,现在当我们调用 make 时,我们传递 S,而不是 []E。

    有助于理解的代码:https://gitee.com/qingfeng-169/blog-code/blob/master/csdn/generics/demo2/main.go
    完整代码见:https://gitee.com/qingfeng-169/blog-code/blob/master/csdn/generics/demo3/main.go
    有助于理解的代码:https://gitee.com/qingfeng-169/blog-code/blob/master/csdn/generics/demo4/main.go

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

    约束类型推断从类型参数约束中推导出类型实参。当一个类型参数具有根据另一类型参数定义的约束时使用它。当这些类型参数之一的类型参数已知时,约束用于推断另一个类型参数的类型参数。

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

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

  • 相关阅读:
    [附源码]java毕业设计基于ssm的电子网上商城
    对于JVM,你掌握多少?
    Mysql高级语句(视图表 、存储过程、条件语句、循环语句)
    Vue的详细教程--入门
    gitlab之cicd的gitlab-runner集成-dockerfile构建环境
    Django系列1-Django概述
    【MySQL】A01、性能优化-参数监控&分析
    5G(3)5G NR的物理资源
    【Vue3源码】2. 响应式原理 上 - reactive源码实现
    Qt之Model/View架构
  • 原文地址:https://blog.csdn.net/weixin_37909391/article/details/140362192