Go 泛型讨论及应用(非语法)

911 阅读6分钟

我正在参与掘金创作者训练营第6期, 点击了解活动详情

Golang 的泛型是 Go 1.18 正式加入的功能, 目前最新的Go版本已经是 1.19, 泛型已经处于可用的状态, 下面我们一块来看看Go泛型都有什么, 以及能用在哪儿. 以下内容更多的是我的理解, 并非完整的泛型语法介绍.

背景

Go语言有一个非常吸引人的特性, 就是简单, 上手容易, 为此, Go语言核心团队保持了相当的克制, 很少为语言增加新语法特性, 哪怕是有少许的冗余代码, 也一直秉承 "Go1兼容" 的承诺, 不破坏语言的向下兼容性. 但是, 随着Golang 越来越普及, 用Golang开发的项目也越来越多, 越来越大, 代码重复问题逐渐显现, 越来越为开发者诟病, Gopher们对泛型的呼声也越来越高, 泛型已经是势在必行了, 于是在Go1.18, 泛型正式出炉(其实在1.17已经有了实验特性), 为Go增加了新鲜的活力.

开始.

类型参数 Type Parameters

我们看一个最简单的泛型例子


func Max[T constraints.Ordered](a, b T) T {
   if a > b {
      return a
   }
   return b
}

constraints.Ordered 来自 golang.org/x/exp Golang官方的实验包, 这个函数跟普通的函数的区别是在Max后有一个 [T constraints.Ordered], 这里声明了一个 泛型类型T, T的具体说明在这里 constraints.Ordered, 我们暂且先认为这个说明的含义是: T是可比较大小的类型, 然后我们传入的参数 a, b 的类型都是 T, 他们也叫做 类型参数 , 整个泛型函数表达的含义就是: 有两个可比较的参数传进来, 返回其中最大的那个.

下面我们来使用这个函数

max := Max[int](1, 2)
log.Println(max)

这里我们看到, 在调用泛型函数的时候, 在方法名后边加入了一个类型说明, 这就是在告诉Max函数, 我传进来的是两个int类型的参数, 这两个参数都符合可比较, 这段代码在编译期就会把 Max[int] 编译成 func Max(a, b int) int 这种类型, 如果我们传入的参数是float类型, 那么我们不需要修改Max的函数声明, 只是在调用的时候, 告诉他参数是float即可, 大大减少了重复代码量.

从这里看出, 泛型的加入带来的第一个好处, 就是当遇到逻辑一样, 只是参数类型不同的功能的时候, 我们可以使用泛型大大减少代码量, 少写重复的代码.

类型集合 Type Sets

通过上边的案例, 我们会发现一个问题, 就是这样写出来的泛型T, 不能给其他的函数使用, 只能自己在函数内使用, 还有就是 constraints.Ordered是怎么实现的呢? 所以泛型还提供了 类型集合 这个东西

type Signed interface {
   ~int | ~int8 | ~int16 | ~int32 | ~int64
}

在之前, type + interface 是定义一个接口, 某个 struct只要是实现了接口里定义的方法, 就是实现了这个接口, 这里接口的本质是约束, 约束了这些 struct 都有相同的方法, 同样的道理, 我们也可以用上边的方式定义类型的约束, 也就是 int,int8,int16,int32,int64 这几个类型都属于泛型 Signed.

  • |可以理解为 或者
  • ~ 是说只要底层类型一致就符合条件, 比如 ~int表示, 只要底层是int类型, 就符合条件

Go也提供了一些默认的 类型集合

  • comparable 是可比较类型, 用在map的key的约束
  • golang.org/x/exp/constraints包 提供了一些预置的约束, 不过x/exp包的东西属于实验性的内容, 谨慎使用

类型推断

那么, 当我们想使用泛型的时候, 是不是每次都要做两个操作:

  1. 定义一个泛型方法, 做好类型参数的约束规则
  2. 在调用方法的时候, 指明传入的参数类型

可以说, 如果这样写肯定是没问题的, 但是Go为我们提供了更简便的写法, 通过编译器的类型推导, 我们可以 部分省略函数调用的时候的参数说明, 还是 上边Max函数那个例子, 我们也可以这样写

max := Max(1, 2)
log.Println(max)

这样在调用函数的时候, 就像普通函数调用一样, 不用再手动填上参数类型, 编译器就可以推导出来.

我们看一个复杂的例子

// 函数声明
func MAP[K comparable, V any, M map[K]V](k K, v V) M {
   m := make(M, 1)
   m[k] = v
   return m
}

// 函数调用
line1. m := MAP(1, "a")
line2. m := MAP[int](1, "a")
line3. m := MAP[int, string](1, "a")
line4. m := MAP[int, string, map[int]string](1, "a")

在函数声明阶段, 我们定义了三个类型参数,等到调用函数的时候, 我们这四种写法都是正确的, 原因就是 Go 的编译器帮我们推导了这些类型, 我们来分析下.

  • 传入参数 1 对应了 k, 可以把 K类型推导为int
  • 参数 "a"对应了v, 可以把 V 类型推导为 string

这个时候, 编译器就会推导 map[K]V 也就是 Mmap[int]string类型, 所以我们在调用函数的时候, 可以省略[ ... ] 中的内容, 当然顺序都是从左往右和函数声明一一对应的.

应用

我在开发的过程中, 会遇到很多这种场景, 就是需要把数组转成map, 比如我要获取几个用户的用户信息, 调用了用户服务的 GetUserInfo(userIDs []int) 功能, 拿到的是用户信息的数组, 我渲染接口返回数据的时候, 期望这个数据是一个 userInfos[userID] 类型的map, 在泛型之前, 我会先创建一个map, 然后遍历数组, 赋值map, 有了泛型之后, 就可以这么干了

package main

import "log"

// 数组转map功能定义

type Mapper[K comparable] interface {
   GetKey() K
}

func ToMap[K comparable, V Mapper[K]](slice []V) map[K]V {
   m := make(map[K]V, len(slice))
   for _, v := range slice {
      m[v.GetKey()] = v
   }
   return m
}



// 业务逻辑, 使用[数组转map]功能
type user struct {
   Id   int
   Name string
}

func (i user) GetKey() int {
   return i.Id
}

func main() {

   items := []user{
      {Id: 1, Name: "Item 1"},
      {Id: 2, Name: "Item 2"},
      {Id: 3, Name: "Item 3"},
   }

   //itemsMap := ToMap[int, user](items)
   itemsMap := ToMap[int](items)

   log.Println(itemsMap[1].Name)

}

不论我是什么类型的数组, 我可以用 ToMap方法, 转成 map, 减少了很多重复的代码.

大家还有其他的应用, 欢迎在评论区一起讨论交流!