Go语言基础(11)——go1.18范型

149 阅读6分钟

Go1.18范型

Go从1.18正式开始支持范型,但Go对范型的支持还是较为保守,很多我们在Java中能够用范型实现的操作用Go范型都无法实现,但总的来说,Go1.18还是在范型的道路上迈进了一大步,至少我们可以开始用范型定义一些基本的数据结构,比如栈、链表等。

范型类型

Go的范型用struct来定义,这跟Java的class类型,Java可以在class上使用[T]来定义一个T类型的范型,Go也一样在struct上使用[]来定义范型类型。

type list[T any] struct {
   s []T
}

上面的代码定义了一个范型的list类型,范型参数是T,用any进行修饰,表示这个范型T可以是任意类型,Go1.18开始支持any这个关键词,可以等同于interface{}。接下来我们给这个list添加一些列表基本的操作方法

func (l *list[T]) Add(t ...T) {
   l.s = append(l.s, t...)
}

func (l *list[T]) Remove(index ...int) map[int]T {
   r := make(map[int]T, len(index))
   for _, i := range index {
      if i < 0 || i >= len(l.s) { // 越界
         continue
      }
      r[i] = l.s[i]
      if i == 0 {
         l.s = l.s[1:len(l.s)]
      } else if i == len(l.s)-1 {
         l.s = l.s[0:i]
      } else {
         l.s = append(l.s[0:i], l.s[i+1:len(l.s)]...)
      }
   }
   return r
}

func (l *list[T]) Get(index int) T {
   return l.s[index]
}

func (l *list[T]) MGet(index ...int) []T {
   r := make([]T, 0, len(index))
   for _, i := range index {
      r = append(r, l.s[i])
   }
   return r
}

// Sub list[start, end)
func (l *list[T]) Sub(start, end int) *list[T] {
   newL := NewList[T](0)
   newL.s = l.s[start:end]
   return newL
}

func (l *list[T]) ForEach(fn func(index int, t T)) {
   for i, t := range l.s {
      fn(i, t)
   }
}

func (l *list[T]) Filter(fn func(T) bool) *list[T] {
   newL := NewList[T](0)
   l.ForEach(func(_ int, t T) {
      if fn(t) {
         newL.Add(t)
      }
   })
   return newL
}

在定义类型的方法时,我们也需要指定一下范型参数,但这时不需要指定范型参数的类型any,因此这已经在定义struct时就已经指定了,在定义struct的字段和方法时就可以将范型T当成一个已知类型来使用了。当然,这时可以不只一个范型参数,也可以使用N个范型参数,在[]中用,隔开即可,每个范型参数都必须有一个类型,例如[T any, R any]

范型函数

上面在定义范型类型时,我们也为范型类型定义了一些方法,这些方法可以使用类型定义的范型参数。那么为啥还要介绍一下范型函数呢?在Java中,由于所有函数都必须定义成某个类型的方法(kotlin也是),因此Java的泛型方法可以再单独定义类型没有定义的泛型参数,例如Java的Stream库中,可以将一个任何集合类型转化为其他集合类型的map函数public inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R>,但Go的范型方法不能再定义范型参数了,而只能使用struct定义的范型参数。因此如果我们想为list这个struct定义一个map方法是无法实现的,除非将用另一个struct将其包一层,比如下面这样

type listMapReduce[T any, R any] struct {
   *list[T]
}

func NewListMapReduce[T any, R any](l *list[T]) *listMapReduce[T, R] {
   return &listMapReduce[T, R]{list: l}
}

func (mr *listMapReduce[T, R]) Map(fn func(T) R) *list[R] {
   rl := NewList[R](len(mr.s))
   mr.ForEach(func(_ int, t T) {
      rl.Add(fn(t))
   })
   return rl
}

func (mr *listMapReduce[T, R]) Reduce(fn func(T, R) R) R {
   var r R
   mr.ForEach(func(_ int, t T) {
      r = fn(t, r)
   })
   return r
}

由于范型struct的方法无法再定义其他范型参数,因此如果想要再定义一个范型参数的话,就只能再创建一个struct,在struct上定义另一个范型参数,这样新的struct的方法就可以同时使用T和R这两个范型参数了。

除此之外,也可以直接将Map和Reduce定义成单独的函数而不是struct的方法,这时因为Go的函数是可以和struct一样,定义任意多个范型参数的。

func Map[T any, R any](l *list[T], fn func(T) R) *list[R] {
   rl := NewList[R](len(l.s))
   l.ForEach(func(_ int, t T) {
      rl.Add(fn(t))
   })
   return rl
}

func Reduce[T any, R any](l *list[T], fn func(T, R) R) R {
   var r R
   l.ForEach(func(_ int, t T) {
      r = fn(t, r)
   })
   return r
}

Go范型的两种实现原理

上一篇文章我们介绍到范型一般有两种实现方案,一种是像c++一样,在编译期为每种范型实参都生成一个类型;别一种是在运行时才去处理范型。前者的优势是运行时的性能几乎不受影响,但编译性能会被劣化;而后者的优势是编译期性能几乎不受影响,运行时性能受影响。Go的范型实现参考了这两种方案:为了编译尽量减小引入范型对编译性能的影响,Go对接口或指针类型的范型使用第二种方案,而对基本数据类型,由于基本数据类型是Go预定义的,因此可以直接使用,并且参数列表也可以直接使用,这样会像c++一样,为每个传入范型实参的实例都创建一个副本。

Go范型对上下界的支持

上一篇文章中我们还介绍了范型会有不变、协变和逆变的特性,但由于Go其实是不支持类型的继承的,因此Go的范型也只有不变的特性,根本就不需要支持协变和逆变。

上面的例子我们的范型类型都是使用的any作为范型的限定条件,也就是说在指定范型实参时可以传入任意类型,这也就导致了我们在使用范型形参时,只能将其当成interface{}来使用。有时候我们可能需要泛型形参是某些类型或接口,让我们在使用范型形参时也能调用形参的一些字段或方法,比如在定义一个sum泛型函数时,我们肯定需要传入的函数实参都是可以进行加法运算的。

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

func Sum[T Number](num ...T) T {
   var r T
   for _, n := range num {
      r += n
   }
   return r
}

func TestGenericSum() {
   fmt.Println(Sum[int](1, 2, 5))
   fmt.Println(Sum[float32](1.1, 2.2, 5.5))
}

go1.18扩展了接口,接口不但可以定义函数,还可以定义范型的类型,范型形参的限定可以使用自定义的接口,限定条件在接口中使用|作并集,交集使用多行来表示。上面的代码限定了Sum函数的范型实参只能使用Number接口类型的,而Number接口类型只能是用|定义的这些数字类型。

Go能够对类型进行重命名,重命名的类型将和原类型不再是一种类型(虽然可以强转),Go范型对重命名的类型也不支持限定,例如下面的代码

type Int int
fmt.Println(Sum[Int](1, 2, 3))

Sum函数会报错Cannot use Int as the type Number,为了解决这种问题,Go提供了~符号,添加~符号的接口,将能够接收被重命名的范型实参。

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

上面的代码我们在int前面加了一个~符号,表示定义的Number的接口不仅能表示int,还能表示int重命名的类型,这样再调用Sum[Int]将能正常编译和运行