[ Go泛型简介 | 青训营笔记]

90 阅读5分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 6 天

Go 泛型简介

Go 1.18版本中引入了Generics:

  • 函数和类型的类型参数(type parameters)

  • 接口定义的类型集合

    接口现在不只是定义一组方法,而是可以定义一组类型

  • 类型推断

Type Parameters

[P, Q constraint1, R constraint2]

使用方括号来声明一个类型参数列表。

如何使用泛型?

以一个最简单的min函数为例:

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

通过将其中的类型float64替换为类型参数,可以得到如下的一个Generic min函数

func min[T constraints.Ordered] (x, y T) T{
    if x < y {
        return x 
    }
    return y
}
// 使用
m := min[int](2, 3)
//也可以省略掉[int]让其自动推导

关于其中的constraints.Ordered,可以参考golang.org/x/exp/const…,它定义了一些泛型类型,Ordered包括了整数、浮点数和字符串类型。

泛型的实例化(Instantiation)

  1. 使用类型实参(args)替换掉类型形参(para)(Substitute type arguments for type parameters)
  2. 检查类型实参是否实现给定的约束

如果上述步骤2不成功那么实例化也就是不成功的。

可以将一个实例化的泛型函数赋值给一个普通的非泛型函数,示例如下:

fmin := min[float64]
m := fmin(2.72, 3.14)

泛型类型(以二叉树为例)

type Tree[T interface{}] struct{
    left, right * Tree[T]
    data    T
}
func (t *Tree[T]) Lookup(x T) * Tree[T]
var stringTree Tree[string]

Type Sets

对于一个普通的函数min

func min(x, y float64) float64

其中的float64告诉我们,何种参数/返回值类型在这里是合法的

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

而在泛型版本中,constraints.Ordered告诉我们,哪些类型参数在这里是合法的,同样的,constraint.Ordered保证了这里传入的类型可以使用operator<运算符来进行比较。

在文档中可以看到constraints.Ordered的定义如下:

type Ordered interface {
    Integer | Float | ~string
}

其中Interger和Float是在前面定义的类型集合。

Type constraints

在Go语言中,type constraints必须是interfaces,在接口中,可以定义一系列需要使用的方法,从另一个视角来看,有一些类型实现了这些方法,那么接口也定义了一系列的类型(无限多的,只要满足该接口)的集合。即这两种视角可以认为是等价的。

从type set的视角来看,相比于方法定义的接口而言,有更好的灵活性,因为我们可以显式的添加一个类型到该集合中去。

可以使用如下的语法来定义一个类型集合:

interface{
    int|string|bool
}

即,上面的min函数可以改成:

func min[T interface{~float64|~int|~string}] (x, y T) T

换种角度来看,可以说是float64intstring这三种类型实现了该接口。

  • ~string前面的波浪号表示:我们不只关心string这一特定的类型,而是所有底层类型为string的类型都可以。

    type MyStr string
    var s1, s2 MyStr = "str1", "str2"
    

    对于上述的s1和s2,如果不采用~string,而是采用string来定义接口的约束,那么无法使用这种定义下的min函数

type constraints的两种功能

  1. 一个type constraints定义了合法类型的集合
  2. 如果该类型集合中的所有类型都支持同一个操作,那么这个操作就可以被使用。

具名的type constraints

[S interface{~[]E}, E interface{}]
//也可写成这样 -语法糖
[S ~[]E, E any]

上面定义了一个类型参数E,以及对应的一个E的slice类型的类型参数S

Type Inference

即类型推导,我们可以省略泛型实例化时的参数,而从实参中推导出来。

func min[T constraints.Ordered] (x, y T) T
var a, b float64
m: = min(a,b)   // 不需要写成 min[float64](a,b)

使用中存在的一些问题

以如下一个函数为例:

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
}
type Point []int
func (p Point) String() string
func ScaleAndPrint(p Point){
    r := Scale(p, 2)
    fmt.Println(r.String()) // 编译不通过
}

存在的问题是,即便我们为Point类型定义了String()方法,从Scale函数的返回值中得到的是 []int而不是Point

修正的方式如下所示:

func Scale[S ~[]E, E constraints.Integer](s S, sc E) S

使用泛型的Guideline

Write code, don't design types.

如果在写代码时,从设计泛型类型开始,那么方向可能就错了。应该从编写函数开始,然后在需要的时候再添加泛型参数。

一些类型参数可能派上用场的地方:

  • 用于处理某种类型slice、maps、channels的函数

  • 某种通用的数据结构(例如上述的泛型二叉树Tree)

    • 相比于使用interface{},可以在编译期间检查类型的正确性

    • 在使用过程中,如果需要一个函数,尽量使用传入函数作为参数而不要使用类型的方法

      以比较为例,虽然可以通过constraints来限制必须提供compare方法,但是如果想要使用一些简单类型例如int32时,就不得不自定义一个新类型并为其定义一个方法,而传入一个compare函数要更加方便一些。

  • 当一个方法对于所有类型来说,看起来都一样时

在什么时候不使用泛型

  • 当只是需要在该类型参数上调用一个方法时

    例如使用io.Reader接口

    //  good
    func ReadFour(r io.Reader) ([]byte, error)
    // bad
    func ReadFour[T io.Reader](r T)([]byte, error)
    
  • 当一个通用方法对于每个类型的实现并不一样时

  • 当对于每个类型,操作都不一样时

    • 使用反射,例如json处理和标准库的一些函数

总的建议:

使用泛型来减少重复模板

即当在编写代码时发现需要复制另一段代码,但仅需要修改其参数类型时,可以考虑使用泛型。


References:

  1. go.dev/blog/intro-…
  2. GopherCon 2021: Robert Griesemer & Ian Lance Taylor - Generics!