likes
comments
collection
share

Go 1.18 泛型解读

作者站长头像
站长
· 阅读数 22

本文旨在介绍 Go 1.18 中泛型的语法和使用规则,希望读完本文能够看懂泛型代码并使用。

什么是泛型?

泛型(Generics)是指在定义函数、接口或类的时候,不预先指定具体的类型,而在使用的时候再指定类型的一种特性。

为什么需要泛型?

首先考虑这个需求:实现两数之和的函数。

func Add(a, b int) int {
    return a + b
}

这个函数虽然简单,但是无法支持 int 类型之外的和,当需求变成对浮点类型或字符串类型之和时,其中的一种解决办法就是 copy 和 paste 。

func Addfloat(a, b float32) float32 {
    return a + b
}
func Addstring(a, b string) string {
    return a + b
}

之前是如何实现“泛型”的

最容易想到的方式是上述的 copy 和 paste 的方式,除此之外还有:

  1. interface
type Inputer interface {
    Input() string
}
type MouserInput struct {}
func (MouseInput) Input() string {
    return "MouseInput"
}
type KeyboardInput struct {}
func (KeyboardInput) Input() string {
    return "KeyboardInput"
}

缺点:代码有些臃肿,场景单一。

  1. 类型断言
switch i.(type) {
    case int:
        // do something
    case string:
        // do something
    default:
        // do something    
}

缺点:耦合了大量的类型断言代码,失去了编译器的类型安全保障。

  1. 反射
func DeepEqual(x, y any) bool {
   if x == nil || y == nil {
      return x == y
   }
   v1 := ValueOf(x)
   v2 := ValueOf(y)
   if v1.Type() != v2.Type() {
      return false
   }
   return deepValueEqual(v1, v2, make(map[visit]bool))
}

缺点:运行时开销大,不安全,没有编译时的类型检查。

泛型编程

回顾一下函数的形参和实参概念。函数的形参类似于占位符,并没有具体的值,只有在传入实参之后才有具体的值。

// a,b 是形参
func Add(a int, b int) int {
    return a + b
}

Add(1,2// 1,2是实参

现在延伸一下形参和实参的概念,如果给变量的类型引入形参和实参的概念,比如下面这样:

// T 是变量类型的形参,类似于占位符,没有具体的类型
func Add(a T, b T) T {
    return a + b
}

// 伪代码
// 在调用函数时传入具体的类型,这样一个函数就能支持多种类型,这里 T=int 是变量类型的实参,
Add[T=int](1,2)
Add(T=string)("hello", "world")
// 这样就相当于定义了一个int和string相加的函数
func Add(a int, b int) int {
    return a + b
}
func Add(a string, b string) string {
    return a + b
}

通过引入变量类型的形参和实参让一个函数获得了支持不同类型数据的能力,这种编程方式被称为泛型编程。而 Go 1.18 也是通过这种方式实现了泛型,把变量类型的形参称为类型形参(type parameter),变量类型的实参称为类型实参(type argument)

泛型

泛型类型

在 Go 1.18 中引入了很多关于泛型的概念,可以从下面简单的例子中了解一下。

type Slice[T int | string | float32] []T

其中 ~ 操作符表示在实例化泛型时,不仅可以直接使用对应的实参类型,如果实参的底层类型在类型约束中,也可以使用。比如 ~int 就表示不仅支持 int 类型,还支持所有以 int 为底层类型的类型。

type Int int
type Slice1[T int] []T
type Slice2[T ~int] []T
var s1 Slice1[Int] // 错误
var s2 Slice2[Int] // 正确

// 使用 ~ 需要注意一下, ~ 后面的类型不能为接口,且必须为基本类型。
type Slice3[T ~Int] // 错误
type Slice4[T ~error] // 错误

与以往的类型定义不同,泛型类型在类型名称后面新增了中括号。其中的解释为:

  • 新定义的类型为 Slice[T] 。

  • T为类型形参(type parameter),表示在定义 Slice 的时候具体的类型并不确定,类似于给变量的类型引入形参的概念。

  • int | string | float32为类型约束(type constraint),表示 Slice 可以取这三种类型任意一种,其中 | 表示取并集的意思。

  • T int | string | float32定义了所有的参数形参,称为类型参数列表(type parameter list)

定义的泛型类型必须传入类型实参(type argument)确定好具体类型后才能被使用,这个过程称为实例化(instantiations),如下所示:

var s Slice[int]=[]int{1,2,3}

以上引入的新概念可以用下图概括:

常见的泛型类型

  • slice
type Slice[T int | float32] []T
var s1 Slice[int] = []int{1,2,3}
s2 := Slice[float32]{1.0,2.0,3.0}
  • map
type Map[K string, V int | float32] map[K]V
var m1 Map[string, int] = map[string]int{
    "age": 18,
}
m2 := Map[string, float32]{
    "weight": 75.5,
}
  • struct
type Mystruct[T int | float32]struct{
    value T
}
var ms1 = Mystruct[int]{
    value: 100,
}
ms2 := Mystruct[float32]{
    value: 1.1,
}

互相套用

  • 类型形参的套娃。
type Mystruct[T int | float32, S []T] struct{
    data S
    value T
}
var ms = Mystruct[int, []int]{
    data: []int{1,2,3},
    value: 1,
}
  • 泛型类型也可以套娃。
// 先定义一个泛型类型Slice[T]
type Slice[T int | string | float32 | float64] []T
// 基于Slice[T],定义一个只接受int和string的泛型类型IntAndStringSlice[T]
type IntAndStringSlice[T int|string] Slice[T] 
// 基于IntAndStringSlice[T],定义一个只接受int的泛型类型IntSlice[T]
type IntSlice[T int] IntAndStringSlice[T] 

几种语法错误

  • 基础类型不能只有类型形参
// 错误,类型形参不能单独使用
type Slice[T int | string] T
type Map[K int | float32, V K] map[K]V
  • 指针必须配合interface{}使用
// 错误,T *int会被编译器误认为是表达式 T乘以int,而不是int指针
type Slice[T *int] []T
// 正确
type Slice[T interface{*int | *float32}] []T
var s Slice[*int]
  • 匿名结构体不支持泛型
// 错误
s := struct[T int | float32] {
    Value T
}[int]{
    Value:1,
}

泛型 receiver

从上面的例子上看,单纯的泛型类型对开发来说用处不大,但是如果将泛型类型和泛型 receiver 相结合的话,就有了很大的实用性了。

新定义的普通类型可以添加方法,同样的也可以为泛型类型添加方法。

type Slice[T int | float32] []T
func (s Slice[T]) Sum() T {
   var sum T
   for _, v := range s {
      sum += v
   }
   return sum
}

// 实例化
var s Slice[int] = []int{1, 2, 3}
fmt.Println(s.Sum())

通过泛型 receiver ,就可以方便的实现通用的数据结构,如栈,队列,链表等等。

// 栈的一种简单实现方法
type Stack[T int | string | []int | chan int] struct {
   element []T
}
// 入栈
func (s *Stack[T]) Push(a T) {
   s.element = append(s.element, a)
}
// 出栈
func (s *Stack[T]) Pop() T {
   var v T
   if len(s.element) == 0 {
      return v
   }
   v = s.element[len(s.element)-1]
   s.element = s.element[:len(s.element)-1]
   return v
}
// 栈的大小
func (s Stack[T]) Size() int {
   return len(s.element)
}

var s Stack[int] // 实例化
s.Push(1) // 1
s.Pop()   // 1
var s1 Stack[string] // 存放string的栈
var s2 Stack[[]int] // 存放int切片的栈
var s3 Stack[chan int]// 存放存放int通道的栈

泛型函数

泛型函数和普通函数的区别在于函数名之后是否带了类型形参。

func Add[T int | float32 | float64](a T, b T) T {
    return a + b
}

Add[int](1,2) // 实例化
Add(1.0,1.0) // 自动推导类型
  • 匿名函数不支持泛型
// 错误,匿名函数不能自己定义类型形参
fn := func[T int | float32](a,b T) T {
    return a + b
}

// 但可以使用别处定义好的类型形参
func MyFunc[T int | float32 | float64](a, b T) {
    fn := func(i T, j T) T {
        return i + j
    }

    fn(a, b)
}
  • 虽然支持泛型函数,但不支持泛型方法
type A struct{
}
// 错误,不支持泛型方法
func (receiver A) Add[T int | float32 | float64](a T, b T) T {
    return a + b
}

// 但可以曲线救国,通过 receiver 使用类型形参
type A[T int | float32 | float64] struct {
}

func (receiver A[T]) Add(a T, b T) T {
    return a + b
}

a.Add(1, 2)

接口

如果想定义一个可以支持多种类型的切片,那么下面这种写法则显得太长且难以维护。

type Slice[T int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64 | float32 | float64] []T

因此支持将类型约束单独提出来当做一个接口,通过多个接口的组合,完成类型约束同时又方便维护。

type Int interface {
    int | int8 | int16 | int32 | int64
}

type Uint interface {
    uint | uint8 | uint16 | uint32
}

type Float interface {
    float32 | float64
}

type Slice[T Int | Uint | Float] []T

新的定义

上面这种写法在 Go 1.18 以前是不支持的,以往接口的定义是一个方法集(method set)

An interface type specifies a method set called its interface

任何实现了这组方法集的类型都实现了这个接口。

Go 1.18 泛型解读

比如下面这个 ReadWriter 接口是一个方法的集合,包含了 Read() 和 Write()两个方法,所有同时实现了这两种方法的类型都实现了该接口。

type ReadWriter interface {
    Read(p []byte) (n int, err error)
    Write(p []byte) (n int, err error)
}

但是如果把 ReadWriter 接口看作一个类型的集合,所有实现了这两种方法的类型都在接口代表的类型集中。于是 Go 1.18 就把接口的定义从方法集(method set)变成了类型集(type set)

An interface type defines a type set* *

任何实现了这组方法集的类型所构成的类型所构成的集合。

Go 1.18 泛型解读

并集,交集和空集

并集前面已经简单介绍了,使用 | 就是取类型的并集。如果一个接口有多行类型定义,那么取它们之间的 交集。当多个类型的交集为空时,这个接口代表的类型集就是一个空集

type I1 interface{ // 代表的类型集为int和string的并集
    int | string
}
type I2 interface{ // 代表的类型集为string和float32的并集
    string | float32
}
type I3 interface{ // 代表的类型集取I1和I2的交集,即string
    I1
    I2
}
type I4 interface { // 类型int和float32没有交集,类型集为空
    int
    float32 
}

空接口,any 和 comparable

根据 Go 1.18 对接口的新定义,空接口 interface{} 代表了所有类型的集合,而非一个空集。为了更方便地使用空接口, Go 1.18 提供了一个和空接口等价的新关键词 any

// 所有类型都可当做类型实参,等价于type Slice[T interface{}] []T
type Slice[T any] []T 
// 下面都正确
var s1 Slice[int]
var s2 Slice[chan int]
var s3 Slice[map[string]float32]
var s4 Slice[interface{}] 

此外,考虑到一些数据类型的特性,在类型约束中只能接受 != 和 == 的类型,因此内置了一个 comparable 的接口。

// 错误,map的键必须支持!=和==
type Map[KEY any, VALUE any] map[KEY]VALUE 
// 正确,comparable代表了所有可用 != 以及 == 对比的类型
type Map[KEY comparable, VALUE any] map[KEY]VALUE 

接口的两种类型

在接口有了新定义后,Go 1.18 进一步将接口分为两种类型:基本接口(basic interface)一般接口(general interface)

基本接口是指接口内只包含方法,和 Go 1.18 之前的版本保持一致,本文不再介绍。

type Writer interface {
   Write(p []byte) (n int, err error)
}

一般接口是指接口内包含了方法和类型。

下面这个一般接口的含义是:所有以 string 或 []rune 为底层类型,且实现了 Read() 和 Write() 这两个方法的类型都在 ReadWriter 代表的类型集中。

// ReadWriter 接口既有方法也有类型,所以是一般接口
type ReadWriter interface {  
    ~string | ~[]rune
    Read(p []byte) (n int, err error)
    Write(p []byte) (n int, err error)
}
// 类型 StringReadWriter 实现了接口 ReadWriter
type StringReadWriter string 
func (s StringReadWriter) Read(p []byte) (n int, err error) {
    // ...
}
func (s StringReadWriter) Write(p []byte) (n int, err error) {
 // ...
}
// 类型 BytesReadWriter 因底层类型为 []byte ,不在 ReadWriter 中,所以没有实现该接口
type BytesReadWriter []byte 
func (s BytesReadWriter) Read(p []byte) (n int, err error) {
 ...
}
func (s BytesReadWriter) Write(p []byte) (n int, err error) {
 ...
}

泛型接口

因为类型的定义都可以使用类型参数,所以接口的定义也可以使用类型参数。

type DataProcessor1[T any] interface {
    Process(oriData T) (newData T)
    Save(data T) error
}

type DataProcessor2[T any] interface {
    int | ~struct{ Data interface{} }
    Process(data T) (newData T)
    Save(data T) error
}

在该例子中,这两个接口都是用类型形参,所以是泛型接口。而想要使用泛型接口,就必须传入类型实参进行实例化。

DataProcessor1[string]
DataProcessor2[string]

因为 DataProcessor1[string] 只有方法,所以是基本接口,包含了两个能处理 string 类型的方法。

type MyStruct struct{
}
func (m MyStruct) Process(s string) (newData string){
    // ...
}
func (m MyStruct) Savc(s string) error{
    // ...
}

// 正确,Mystruct 实现了接口 DataProcessor1[string]
var processor1 DataProcessor1[string] = MyStruct{}
processor1.Process("hello,world")
processor1.Save("hello,world")

// 错误,Mystruct 没有实现接口 DataProcessor1[int]
var processor2 DataProcessor1[int] = MyStruct{}

而 DataProcessor2[string] 既包含了类型又包含了方法,所以是一般接口。经过实例化后,表示的意思是:只有实现了 Process(string) 和 Save(string) 这两个方法,且以 int 或 struct{ Dara interface{} } 为底层类型的类型才算是实现了该接口。

// 错误,虽然 MyByte 实现了 DataProcessor2[string] 的两个方法,但其底层类型为 []byte,所以没有实现该接口
type MyByte []byte
func (m MyByte) Process(s string) (newData string){
    // ...
}
func (m MyByte) Save(s string) error{
    // ...
}

//正确,MyStruct 实现了两个方法,且底层类型为 Data interface{},实现了该接口
type MyStruct struct{
    Data interface{}
}
func (m MyStruct) Process(s string) (newData string){
    // ...
}
func (m MyStruct) Save(s string) error{
    // ...
}

// 错误,因为 DataProcessor2[string] 是一般接口,只能用于类型约束,不能用于变量定义
var processor DataProcessor2[string]

// 正确,实例化后可以作为类型约束
type Processor[T DataProcessor2[string]] []T

func Do[T DataProcessor2[string]](val T){
    val.Process("hello,world")
}
s := MyStruct{}
Do(s)

接口定义的一些约束

  • 使用 | 时,类型之间不能有交集,即不能有重叠的部分
type Int int
// 错误,Int的底层类型时int,和~int有重叠
type _ interface{
    ~int | Int
}
// 正确,底层类型为int,和int不重叠
type _ interface{
    int | Int
}
  • 接口的并集成员大于一时,不能并入 comparable 接口
// 正确,只有一个类型,可以使用 comparable
type _ interface{
    comparable
}
// 错误,使用并集时,不能使用 comparable
type _ interface{
    []int | comparable
}
  • 无论是基本接口还是一般接口,只要带方法的接口,都不能写入接口的并集中
type DataProcessor[T any] interface {
    ~string | ~[]byte

    Process(data T) (newData T)
    Save(data T) error
}

// 错误,实例化之后的 DataProcessor[string] 是带方法的一般接口,不能写入类型并集type _ interface {
    ~int | ~string | DataProcessor[string] 
}
  • 无法使用约束定义之外的方法
type MyStruct1 struct {
    // ...
}
func (t MyStruct1) Method() {}

type MyStruct2 struct {
    // ...
}
func (t MyStruct2) Method() {}

type MyConstraint interface {
    MyStruct1 | MyStruct2
}

// 错误,MyConstraint 不包含 .Method() 方法
func MyFunc[T MyConstraint](t T) {
    t.Method() 
}

// 如果想调用正确,需要把接口定义为下面形式
type MyConstraint interface {
    MyStruct1 | MyStruct2
    Method()
}
  • 无法使用成员变量
type MyStruct1 struct {
    name string
}

type MyStruct2 struct {
    name string
}

type MyConstraint interface {
    MyStruct1 | MyStruct2
}

// 错误,虽然这两个类型都包含了一个name成员,但也无法以任何方式在泛型函数当中直接使用
func MyFunc[T MyConstraint](t T) {
    fmt.Println(t.Name) 
}

几种语言中泛型的比较:

  • 在 Java 中,泛型的实现基于“类型擦除”。代码中的泛型信息仅在代码编译时被 Java 编译器用作类型检查,编译后则被“擦除”,而不会保留到运行阶段,也就是说,Java 代码运行时无法获取到泛型信息。

  • 在 C++ 模版中,模板定义在编译时保留在内存中。每当源代码中需要模板类型的实例化时,编译器都会组合模板定义和模板参数,创建新类型。

  • 在 Go 中,为每种泛型和泛型形参单独编译新类型并将其实例化。Go 允许编译器将相似的实例化基于 interface 编译为单个实现。相较于之前的 interface + reflect 的方式,Go 的泛型会将部分工作从 runtime 移动到编译器,例如可以提前确定类型并生成对应代码,使得 runtime 消耗变少。

总结

这次 Go 1.18 泛型带来了许多新的概念,也增加了很多零散的限制约束,但是并没有改变标准库,完全向后兼容。泛型的加入可以让一些公共库的代码更加优雅。比如在 提案 中,提供了一些有意思的例子:

package slices

// 使用映射函数把切片类型为T1映射为类型T2,其中切片 s 的类型为 any
func Map[T1, T2 any](s []T1, f func(T1) T2) []T2 {
        r := make([]T2, len(s))
        for i, v := range s {
                r[i] = f(v)
        }
        return r
}

s := []int{1, 2, 3}
// 现在floats 变成了[]float64{1.0, 2.0, 3.0}.
floats := slices.Map(s, func(i int) float64 { return float64(i) })
package maps

// 函数 Keys 把 map 的键返回到一个切片中,其中键的类型必须是 comaparable,值的类型可以为 any
func Keys[K comparable, V any](m map[K]V) []K {
        r := make([]K, 0, len(m))
        for k := range m {
                r = append(r, k)
        }
        return r
}

// 现在 k 是一个[]int{1, 2}或[]int{2, 1}的切片
k := maps.Keys(map[int]int{1:2, 2:4})

如果看完之后疑惑该不该使用泛型时,可以参考下面的经验:

最后再推荐一下@张盛宇写的:

参考资料

《The Go Programming Language Specification 》

《An Introduction To Generics 》

《Go 1.18的那些事——工作区、模糊测试、泛型》

《Go 1.18 泛型全面讲解:一篇讲清泛型的全部》

《Go 泛型系列(一): Go 1.18 泛型原理及实战》

《Go1.18发布:对比Java、C#、Go语言中的泛型》

《十年磨一剑 go 1.18泛型》

《Go1.18 泛型说明》