3
头图

Go 自从 1.18 版本正式推出泛型之后至今也超过半年了,但是笔者发现在实际业务开发中,大家没有如想象中那么广泛地使用泛型。于是决定简单撰一文,尽可能简单地讲解 Go 的泛型代码的写法。

Go 泛型的作用

Go 语言在推出之后,要求支持泛型的呼声就一直不绝于耳。Go 在 1.17 版实验性地推出,并且在 1.18 正式发布。泛型要解决的问题以及适用的场景是所谓的 ”DRY“(Don't Repeat Yourself),也就是说,一份代码,请不要重复写多遍,系统中的每一部份的实现都应该只有一份代码。

下面我会给几个例子,说明 Go 泛型应该如何使用以达到 DRY 原则。


泛型函数

实现一个泛型函数

我先给出一个最简单的实现:将任意类型转换为 JSON 格式的 string 并输出:

func ToJSON[T any](v T) string {
    b, _ := json.Marshal(v)
    return string(b)
}

相比标准的 Go 泛型代码,上面的这个函数多了一些奇怪的参数:

  • [T any]: 函数的名字前面多了这一段,这段是使用中括号包围起来的。这一段就是 Go 的泛型声明。我们需要注意的是,与 C++ 的泛型使用尖括号 <> 包围不同,Go 泛型的声明是使用中括号 [] 包围的

    • T: 表示在后面的函数中,需要使用一个泛型类型,在代码中,开发者将这个类型命名为 “T”。当然你想命名为其他名字也可以,并且大小写目前暂时没有强制的约束。只是为了便于区分,大家习惯性用大写
    • any: 在 Go 1.17 之后,如果在普通场景下,any 等同于 interface{};在泛型声明的中括号范围内,这个 any 也表示 “任意类型”。但请注意,在泛型声明中的 any 并不等于 interface{}
  • (v T): 这是在函数入参段,呼应前面已经声明的 T。这里就表示一个类型为 T 的入参, 参数名为 v

调用的时候,其实也很简单:

m := map[string]string{"msg": "Hello, generics!"}
fmt.Println(ToJSON(m))
// 输出: {"msg":"Hello, generics!"}

泛型类型的约束

泛型化的数据类型

前面我们看了一个极为简单的泛型函数例子,但那个例子其实意义不大,底层调用的 json.Marshal 实际上也只是使用 any 来实现。接下来我们给一个更加有实际意义的例子:取绝对值。

我们知道,在 Go 中有很多中数字类型,除了 float64, float64, int, uint 之外,还有包括 8、16、32、64 位的四组有符号/无符号整型类型。因为 Go 没有宏,在泛型推出之前,我们只能为一个类型定义一个函数,还不能重名。

有了泛型之后,这个问题也就迎刃而解。不过这里需要涉及的知识点一下子就多了起来,让我们一点一点来讲。

首先,我们需要声明一个泛型类型,并且定义这个泛型类型是包含了哪些真实类型的:

type Number interface{
    float64 | float32 | int | uint | int8 | uint8 | int16 | uint16 | int32 | uint32 | int64 | uint64
}

这段代码看着也很晕是吧?笔者也很想吐槽:对 Go 泛型的定义,借用了 interface{} 这个关键字。但是与真正的 “接口” 不同的是,“接口” 的定义内容是函数,而泛型类型的定义内容是数据类型。而 | 标识则也简单易懂,表示 “或” 的意思。上面的定义应该很好理解,就表示 Number 代表了下面那一长串的类型中的任意一种都行。

话说,在泛型 interface 的定义中,是可以再进一步定义方法的。但是这种应用场景笔者目前还没遇到,所以就不展开讲了。

这样,一个泛型化的数据类型就定义完成了,接着我们可以参照上面的例子实现函数:

func Abs[N Number](n N) N {
    if n >= N(0) {
        return n
    }
    return -n
}

可以看到,函数的出参也是一个泛型 N,这表示函数的出参与入参类型相同,都是 Number 类型。

非基础类型

前面的定义其实有一个缺陷。我们知道,一个类型经过 type 转换之后,就变成了 “不同” 的类型。比如 byte 虽然实际上是使用 int8 实现,但除非经过强制类型转换,在 Go 代码中是视为不同类型的。如果我们传入的参数是一个 byte 类型,那是无法通过 Number 类型检查的。为此,Go 泛型声明中还适用一个符号 ~,表示同事包含由指定基础类型派生出的其他类型。此时,我们可以将上面的 Number 改写为:

type Number interface{
    ~float64 | ~float32 | ~int | ~uint | ~int8 | ~uint8 | ~int16 | ~uint16 | ~int32 | ~uint32 | ~int64 | ~uint64
}

这样,诸如 byterune 等类型也能顺利使用 Abs 函数了。

但是,如果每次我定义一个数字类型的时候都要写这么一长串总归不是个事儿。好在官方的 golang.org/x/exp/constraints 提供了一些常用定义,我们可以进一步简化为:

type Number interface {
    constraints.Float | constraints.Integer
}

泛型的隐式类型判断/显式类型指定

前面的例子中调用一个泛型函数的时候,Go 编译器实际上在底层会为这个类型专门生成一个函数入口。但是我们在 ToJSON 函数的调用中,并没有传递任何与类型有关的关键字,Go 编译器似乎也没有报错。Go 语言中,编译器在编译泛型代码的时候,会根据入参猜测函数类型。我们写一个调用例子:

    fmt.Println(Abs(-1))

这个调用是可以正常编译通过的。这是因为在 Go 中,一个整型数字如果未做任何类型约束,那么会被默认编译为 int 类型。但我们换一个方法:

    var res int64
    res = Abs(-1)
    fmt.Println(res)

尝试编译,会获得错误:

./main.go:xx:yy: cannot use Abs(-1) (value of type int) as int64 value in assignment

根据笔者的试验,Go 似乎暂时无法根据出参的类型来修改入参泛型类型。在这种情况下,我们就必须显式地指定泛型函数的类型了。比如上述例子,我们可以写为:

    res := Abs[int64](-1)
    fmt.Println(reflect.TypeOf(res), res)
    // 输出: int64 1

在这里,中括号再次发挥了泛型的作用。如果你对泛型不熟悉的话,粗看可能会有点 “地铁老人手机.jpg” 的感觉,因为中括号在传统上一般是与字典、数组等类型相绑定的。但实际上,在各种 IDE 的加持下,我相信你很快就能够适应这种写法了。

多个泛型参数

泛型也支持多个泛型参数。比如说,实现一个支持传入任意类型数字的(极为粗略的)加法函数,我们可以这么定义:

func Add[T1, T2 Number](a T1, b T2) float64 {
    return float64(a) + float64(b)
}

如果把函数定义为 Add[T Number](a, b T) float64,那么在调用泛型函数的时候,a 和 b 的类型必须相同,否则报类型错误。但如果改为上述实现,那么 a 和 b 是完全允许不同的。比如:

    a := int(-2)
    b := float64(0.5)
    fmt.Println(Add(a, b))
    // 输出: -1.5

Go 泛型接收器

前面,我们用泛型约束来修饰一个函数。而 Go 的泛型也可以用来修饰类型。最典型的用法,就是用来声明一个 “集合” 类型。在 Go 中 “集合” 一般是用 map 来实现的,可以直接定义 map,也可以用一个 struct 内嵌一个 map。这两种方法,我们可以这样定义:

type Collection[T comparable] map[T]struct{}

或:

type Collection[T comparable] struct {
    m map[T]struct{}
}

这两种声明方式没有本质上的差别。有了前面泛型函数的经验之后,相信读者很快就能了解这两个定义所表达的意思。这里同样是分别定义了一个类型 T。但与前面 any 不同,这里用到了另外一个类型 comparable。这是 Go 内置的除了 any 之外的另外一个泛型标识符,代表所有能够作 == 比较的类型。这也很好理解,如果是 any 类型,那么是无法作为 map 的 key 的,在编译阶段无法通过。

泛型方法

泛型接收器的方法写法,我们还是用上面的第一个 Collection 来举一个例子:

type Collection[T comparable] map[T]struct{}

func (c Collection[T]) Has(key T) bool {
    _, exist := c[key]
    return exist
}

泛型接收器的实例化

泛型方法的泛型标识符作用于接收器类型上,Collection[T] 实际上就对应着前文的定义。T 与类型定义中的 [T comparable] 声明一一对应,不需要(也没办法)再重新定义 T 的类型约束。

调用泛型接收器的方法呢,首先得把泛型接收器给实例化了。和函数一样,Go 编译器也能基于入参进行实际类型的推断, 或者是显式地声明类型(当没有入参的时候):

    col := Collection[string]{}

调用呢,因为在实例化的时候就已经限定了泛型约束,因此调用这个实例的方法的时候,就再也不用指定泛型约束了:

    if col.Has("Hello!") { /* do something */ }

但是后续的事情就比较遗憾了——Go 支持泛型函数,支持泛型化的类型,但是不支持泛型接收器再定义方法。换句话说,不支持诸如 func (t SomeType[T1]) DoSomething[T2 any]() 的方法。


其他

Go 1.21 推出之后,官方内置了部分泛型包,包括 cmpmapsslices,提供了很多非常方便的工具,非常好用。如果读者还不方便使用 go 1.21, 那么也可以用 Go 的官方实验包 golang.org/x/exp/mapsgolang.org/x/exp/slices, cmp 包可能就得自己封装了。

此外,官方实验包 golang.org/x/exp/constraints 则提供了几个非常实用的泛型类型,开发者可以在实际操作中使用。笔者常用的几个泛型类型为:

  • any: 内置类型,正如前文所说, 就是任意类型
  • comparable: 内置类型,表示所有可以作 == 比较的类型,非常适合用来做 map 的 key 类型。需要注意的是,诸如 bool, reflect.Type 之类也符合这一类。
  • cmp.Ordered: 这个类型在 comparable 的基础上,多了一层限制,就是可以进行 >< 比较,适合用来做为树结构类型的 key。需要注意的是 string 也是可以做大小比较的。
  • constraints.Float, constraints.Integer, 分别代表所有的浮点数和整型类型, 笔者经常 constraints.Float | constraints.Integer

结语

读过上文,相信读者就能了解 Go 泛型的绝大部分应用方法了。目前 Go 泛型还并不能达到玩出花的程度,也有更多的 Go 泛型提案,各位可以多看看~~

扩展阅读:


本文章采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。

原作者: amc,原文发布于腾讯云开发者社区,也是本人的博客。欢迎转载,但请注明出处。

原文标题:《三分钟, 让你学会 Go 泛型》

发布日期:2023-10-25

原文链接:https://cloud.tencent.com/developer/article/2351389

CC BY-NC-SA 4.0 DEED.png


amc
927 声望228 粉丝

电子和互联网深耕多年,拥有丰富的嵌入式和服务器开发经验。现负责腾讯心悦俱乐部后台开发