十年磨一剑 go 1.18泛型

·  阅读 16133
十年磨一剑 go 1.18泛型

泛型引入了抽象,无用的抽象带来复杂性。

什么是泛型

泛型程序设计(generic programming)是程序设计语言的一种风格或范式。泛型允许程序员在强类型程序设计语言中编写代码时使用一些以后才指定的类型,在实例化时作为参数指明这些类型。各种程序设计语言和其编译器、运行环境对泛型的支持均不一样。Java和C# 称之为泛型(generics)ML、Scala 和 Haskell 称之为参数多态(parametric polymorphism);C++ 和 D称之为模板(template)。具有广泛影响的1994年版的《Design Patterns》一书称之为参数化类型(parameterized type)。

为什么需要泛型

考虑这么一个需求,实现一个函数,这个函数接受2个int的入参,返回两者中数值较小的。需求是非常简单的,我们可以不假思索的写下如下的代码:

func Min(a,b int) int {
    if a < b {
        return a
    }
    return b
}
复制代码

看起来很美好,但是这个函数有局限性,入参只能用int类型,如果需求做了拓展,需要支持对两个float64的入参做判断,返回两者中较小的。

众所周知,go是一个强类型的语言,且不像c那样在算术表达式里有隐式的类型转换(例如隐式的int转bool,float转int),所以上述这个函数就不能满足需求场景的,不过要支持这个拓展的需求也是很简单的,改成如下的代码然后使用MinFloat64即可:

func Min(a,b int) int {
    if a < b {
        return a
    }
    return b
}
func MinFloat64(a,b float64) float64 {
    if a < b {
        return a
    }
    return b
}
复制代码

但是如果需求又做了拓展,需要支持对两个int64类型的。同理也很简单,如下:

func Min(a,b int) int {
    if a < b {
        return a
    }
    return b
}
func MinFloat64(a,b float64) float64 {
    if a < b {
        return a
    }
    return b
}
func MinInt64(a,b int64) int64 {
    if a < b {
        return a
    }
    return b
}
复制代码

但是如果需求又做了拓展......然后我们就一直加哇加哇,然后最终就变的像下图一样了(ps:go离泛型就差一个sublime...)

不知道大家有没有发现,一旦需求做了拓展,我们都需要也跟着做一些变更,一直做着重复事情,而且通过看函数原型,我们发现只有类型声明这里不一致,当然函数名也是不一致,因为golang也是不支持函数重载(function overloading) 的,如果golang支持了函数重载,我们这里不一致的也就只剩下类型了(ps:函数重载其实也是泛型的一种实现,在编译时通过将类型参数信息加入函数符号里,就实现了编码时的调用同名函数,但是在运行时因为类型信息也不会有二义性)。

那么有没有一种手段可以减少我们重复的工作量呢?在需求做了拓展后,也能在不改动原有代码的基础上做支持,也就是提高代码的可复用性,而这就是泛型的使命。

before go1.18 泛型

在没有泛型前,开发者们是如何实现"泛型的"。

  1. copy & paste

这是我们最容易想到的方式,也是我们在前文介绍的方式,看起来是一种很笨的方式,但是结合实际情况,大多数情况下你可能只需要两三个类型的实现,过早的去优化,可能会带来更多的问题,go proverbs里有一句就很符合这个场景。

“A little copying is better than a little dependency.”(一点复制好过一点依赖)

优点:无需额外的依赖,代码逻辑简单。

缺点:代码会有一些臃肿,且灵活性有缺失。

  1. interface

比较符合OOP的思路,面向接口编程则容易想到这种途径,不过像我们上述的取两数min场景就不能用interface去满足了,可应用的场景比较单一,考虑有下边这样一个接口。

type Inputer interface {
    Input() string
}
复制代码

对于Inputer接口,我们可以定义有多种实现,比如

type MouseInput struct{}

func (MouseInput) Input() string {
    return "MouseInput"
}

type KeyboardInput struct{}
func (KeyboardInput) Input() string {
    return "KeyboardInput"
}
复制代码

这样我们在调用时,也就可以用不同的类型定义相同的接口,通过interface来调用相同的函数了。不过本质上interface和generic是两种设计思路,应用的场景也不太一样,这里只是举了一个共通的例子。

优点:无需额外的依赖,代码逻辑简单。

缺点:代码会有一些臃肿,且应用的场景较单一。

  1. reflect

reflect(反射)在运行时动态获取类型,golang runtime将使用到的类型都做了存储,对于用户层golang则提供了非常强大的反射包,牺牲了性能,但是提供更多的便捷性,帮助程序员在可以在静态语言里使用一些动态的特性,本质上reflect和generic是两种截然不同的设计思路,反射在运行时发挥作用,而泛型则在编译时发挥作用,runtime无须感知到泛型的存在,像gorm框架就大量用到了反射。reflect包就内置了DeepEqual的实现,用来判断了两个入参是否相等。

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))
}
复制代码

优点:代码简单,使用方便。

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

(ps:用过反射的基本都遇到过panic,运行时的类型保障,reflect包里就存在着大量的类型检查,不符合的直接panic,对这里存疑,reflect包和map/slice这些不太一样,比较偏用户场景,为什么不用error,要用panic,猜测是go team认为在静态语言里类型不match是非常严重的场景?)

  1. code generator

代码生成,大家接触比较多的可能就是thrift/grpc的代码生成,将idl转换成对应的语言源代码。在这里的code generator概念上会不太一样,概念上可能会类似之前的php/jsp,写一份通用的模板,在模板内预置一些变量,然后使用工具将预置的变量做填充,生成最终的语言代码(ps:好像和泛型也比较像,哈哈哈),go在1.5时也引入了go generator工具,一般会结合text/template包来使用,在go code generator里有比较火第三方工具:github.com/cheekybits/… generator来写两数之Min,会是下边这样的风格:

package main

import "github.com/cheekybits/genny/generic"

//go:generate genny -in=$GOFILE -out=gen-$GOFILE gen "T=int,float32,float64"
type T generic.Type

func MinT(a, b T) T {
   if a < b {
      return a
   }
   return b
}
复制代码

执行go generator会生成如下代码:

// This file was automatically generated by genny.
// Any changes will be lost if this file is regenerated.
// see https://github.com/cheekybits/genny

package main

func MinInt(a, b int) int {
   if a < b {
      return a
   }
   return b
}

func MinFloat32(a, b float32) float32 {
   if a < b {
      return a
   }
   return b
}

func MinFloat64(a, b float64) float64 {
   if a < b {
      return a
   }
   return b
}
复制代码

优点:代码比较干净,因为是使用前去生成,也可以利用到静态检查的能力,安全且无运行时开销。

缺点:需要针对性的写模板代码,然后使用工具生成最终代码后才能在工程中使用,且依赖第三方的构建工具,因为涉及多份类型的源代码生成,工程里的代码里会变多,导致最终构建出的二进制也会较大。

go 1.18 泛型

go泛型的路程也是非常曲折的...

简述时间作者
[Type Functions]2010年Ian Lance Taylor
Generalized Types2011年Ian Lance Taylor
Generalized Types v22013年Ian Lance Taylor
Type Parameters2013年Ian Lance Taylor
go:generate2014年Rob Pike
First Class Types2015年Bryan C.Mills
Contracts2018年Ian Lance Taylor, Robert Griesemer
Contracts2019年Ian Lance Taylor, Robert Griesemer
Redundancy in Contracts(2019)‘s Design2019年Ian Lance Taylor, Robert Griesemer
Constrained Type Parameters(2020, v1)2020年Ian Lance Taylor, Robert Griesemer
Constrained Type Parameters(2020, v2)2020年Ian Lance Taylor, Robert Griesemer
Constrained Type Parameters(2020, v3)2020年Ian Lance Taylor, Robert Griesemer
Type Parameters2021年Ian Lance Taylor, Robert Griesemer

从2010年开始设计,其中在发展过程中提出的Contracts(合约)的方案,一度被认为会是泛型的实现,不过在2019年,也因为设计过于复杂做了废弃,直到2021年才确定了最终的基本方案开始实现,并在2021年8月的golang 1.17 里做了beta版的实现,在2022年1月的golang 1.18里做了实装,真正意义上的十年磨一剑(ps:Ian Lance Taylor太牛了)。

泛型类型

在json里有number类型,在golang的encoding/json库遇到interface{}类型里默认就会用float64去解析json的number类型,这就会导致在面对大整数时会丢失精度,而实际上的Number类型应该对应到golang里的多个类型,包括int32、int64、float32和float64等,如果按照golang的语法,在泛型里我们可以这么标识Number类型。

type Number[T int32|int64|float32|float64] T
复制代码

但是很遗憾。。。目前golang还不支持这种写法,在编译时会有如下报错:

 cannot use a type parameter as RHS in type declaration
 //RHS:right hand side(在操作符的右侧)
复制代码

报错的意思就是还不支持单独使用类型形参作为泛型类型,需要结合struct、slice和map等类型来使用,关于这个问题的讨论可以详见: github.com/golang/go/i… Lance Taylor大佬做个回复:意思就是这是目前go1.18泛型已知的一个问题,具体大概会在go 1.19 进行尝试。

我们尝试定义一个泛型Number切片类型,并实例化使用:

package main

type Numbers[T int32 | int64 | float32 | float64] []T

func main() {
   var a = Numbers[int32]{1, 2, 3}
   println(a)
}
复制代码
  • T就是类型形参(type parameter) ,这个关键字并不是固定的,我们可以起任何一个名字,它的作用就是用来占位的,标识这里有一个类型,但是具体的类型依赖于后边的类型约束。
  • int32|int64|float32|float64 这一串用“或标识符|”分隔的类型列表就是类型约束(type constraint) ,它约束了T的实际类型类型,我们也会这个类型列表叫做 类型形参列表(type parameter list)
  • 而这里定义的类型就是Numbers[T],被称为泛型类型(generic type) ,泛型类型在定义时会带有形参
  • 而这里定义的[]T被称为定义类型(defined type)
  • 在main函数里的Numbers[int32]就是对泛型类型做了实例化(Instantiation) ,泛型只有在实例化后才能使用,其中这里的int32就是具体实例化的类型,必须是类型约束中定义的类型,叫做类型实参(type argument)

这里实际上是实例化了一个长度为3,元素依次是1,2,3的int32的切片,同样的,我们也可以按如下这种方式定义,float32也在我们的类型形参列表内。

var b = Numbers[float32]{1.1, 2.1, 3.1}
复制代码

上述是只有一个形参的泛型类型,我们来看几个复杂的泛型类型。

  1. 多个类型形参
type KV[K int32 | float32,V int8|bool] map[K]V//(多个类型形参的定义用逗号分隔)
var b = KV[int32, bool]{10: true}
复制代码

上述我们定义了KV[K,V]这个泛型类型,KV是类型形参,K的类型约束是int32|float32V的类型约束是 int8|boolK int32 | float32,V int8|bool则是KV类型的类型形参列表,KV[int32, bool]则是泛型类型的实例化,其中int32K的实参,boolV的实参。

  1. 嵌套的形参
type User[T int32 | string, TS []T | []string] struct {
   Id     T
   Emails TS
}
var c = User[int32, []string]{
   Id:     10,
   Emails: []string{"123@qq.com", "456@gmail.com"},
}
复制代码

这段个类型看起来会比较复杂,但是golang有一条限制:任何定义的形参,在使用时都需要有按顺序一一对应的实参。上述我们定义了struct{Id T Email TS}这个泛型类型,TTS是类型形参,T的类型约束是int32|stringTS的类型约束是 []T|[]string,也就是说,我们在这里定义的TS形参的类型约束里使用了前置定义的T形参,这种语法golang也是支持的。

  1. 形参传导的嵌套
type Ints[T int32|int64] []T
type Int32s[T int32] Ints[T]
复制代码

这里我们定义了Ints类型,形参是int32|int64,又基于Ints类型,定义了Int32s类型,就是我们第二行的这个代码,初看起来可能会比较懵,但是拆开来看:

Int32s[T]这个泛型类型,T是类型形参,T的类型约束是int32,Ints[T]则是这里的定义类型,这里的定义类型又是一个泛型类型,而实例化这个泛型类型的方式就是使用实参T来进行实例化,注意T在这里是Int32s的形参,确是Ints的实参。

泛型函数

仅有泛型类型并不能发挥泛型真正的作用,泛型最强大的作用是结合函数来使用,回到我们最开始的那个例子,取两数之min,在有泛型的情况下,我们可以写出这样的代码:

package main


func main() {
   println(Min[int32](10, 20))
   println(Min[float32](10, 20))
}

func Min[T int | int32 | int64 | float32 | float64](a, b T) T {
   if a < b {
      return a
   }
   return b
}
复制代码

上述我们定义了Min泛型函数,包含泛型T类型,有对应的类型约束,在实际调用时,我们分别用int32/float32去做了形参实例化,来调用不同类型的泛型函数。

上述在使用起来也会有不方便的地方,我们在调用时还需要显示的去指定类型,才能使用泛型函数,golang对这种情况支持了自动类型推导(auto type inference) ,可以简化我们的写法 我们可以像下述这种方式去调用Min函数。

Min(10, 20)//golang里会把整数字面量推导为int,所以这里实际实例化的函数为Min[int]
Min(10.0, 20.0)//浮点数字面量推导为float64,所以这里调用的实例化函数为Min[float64]
复制代码

有了泛型函数,一些常见的操作,比如集合操作取交/并/补/差集合也可以很简单的写出来了,在之前第三方的lib一般都是用反射来实现的,比如:github.com/thoas/go-fu…

结合泛型类型和泛型函数,就是使用泛型receiver,可以构造高级一点的集合数据结构了,比如在其他语言里比较常见的栈(stack)

package main

import (
   "fmt"
)

type Stack[T interface{}] struct {
   Elems []T
}

func (s *Stack[T]) Push(elem T) {
   s.Elems = append(s.Elems, elem)
}

func (s *Stack[T]) Pop() (T, bool) {
   var elem T
   if len(s.Elems) == 0 {
      return elem, false
   }
   elem = s.Elems[len(s.Elems)-1]
   s.Elems = s.Elems[:len(s.Elems)-1]
   return elem, true
}

func main() {
   s := Stack[int]{}
   s.Push(10)
   s.Push(20)
   s.Push(30)
   fmt.Println(s)
   fmt.Println(s.Pop())
   fmt.Println(s)
}
//输出:
//{[10 20 30]}
//30 true
//{[10 20]}
复制代码

上述我们定义了Stack[T]这个泛型类型,我们使用空接口:interface{}做泛型约束,空接口的含义是不限制具体的类型,也就是可以用所有的类型进行实例化。实现了Pop和Push操作,有了泛型,像其他语言里常见的队列、优先队列、Set等高级数据结构也可以比较简单的实现(像之前一些第三方的lib一般都是用反射来实现的)。

这里指的一提的是泛型并不支持直接使用我们之前常用的类型断言(type assert)。

func (s *Stack[T]) Push(elem T) {
   switch elem.(type) {
   case int:
      fmt.Println("int push")
   case bool:
      fmt.Println("bool push")
   }
   s.Elems = append(s.Elems, elem)
}

//cannot use type switch on type parameter value elem (variable of type T constrained by any)
复制代码

如果想获取一个泛型类型的实际类型,可以通过转换到interface{}来实现(当然也可以用反射来实现)。

func (s *Stack[T]) Push(elem T) {
   var a interface{}
   a = elem
   switch a.(type) {
   case int:
      fmt.Println("int push")
   case bool:
      fmt.Println("bool push")
   }
   s.Elems = append(s.Elems, elem)
}
复制代码

interface

golang里有基础类型和复合类型这两类内置类型。

基础数据类型包括:布尔型、整型、浮点型、复数型、字符型、字符串型、错误类型。

复合数据类型包括:指针、数组、切片、字典、通道、结构体、接口。

通过将基础类型和复合类型做组合,我们可以定义出非常多的泛型,但是大量的类型会导致类型约束写的非常长,拿number来举例:

type Numbers[T int|int8|int16|int32|int64|float32|float64] []T
复制代码

定义类型约束

golang支持用interface来预定义类型约束,这样我们在使用时就可以复用已有的类型约束,如下:

type Number interface {
   int | int8 | int16 | int32 | int64 | float32 | float64
}

type Numbers[T Number] []T
复制代码

内置类型可以自由组合形成泛型,同理,接口也可以跟接口组合,接口也可以跟内置类型组合来形成泛型。

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

type UInt interface {
   uint | uint8 | uint16 | uint32 | uint64
}

type IntAndUInt interface {
   Int | UInt
}

type IntAndString interface {
   Int | string
}
复制代码

同样的golang为了方便我们使用也内置了两个接口,分别是any和comparable。

any

// any is an alias for interface{} and is equivalent to interface{} in all ways.
type any = interface{}
复制代码

any其实是非常简单的,其实就是空接口(interface{})的别名,空接口我们在上边也用到过,空接口是可以用作任意类型,用any可以更方便我们的使用,而且从语义上看,any的语义也会比interface{}的语义更加清晰。

comparable

// comparable is an interface that is implemented by all comparable types
// (booleans, numbers, strings, pointers, channels, arrays of comparable types,
// structs whose fields are all comparable types).
// The comparable interface may only be used as a type parameter constraint,
// not as the type of a variable.
type comparable interface{ comparable }
复制代码

golang内置了比较类型,是上述注释中提到的这些内置类型的组合,也是为了方便使用的,值得一提的是comparable是支持==和!=操作,但是像比较大小的>和<是不支持的,需要我们自己实现这种ordered类型。

func Min[T comparable](a, b T) T {
   if a < b {
      return b
   }
   return a
}
//invalid operation: a < b (type parameter T is not comparable with <)
复制代码

当然我们可以自己实现一份比较类型:

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

type Unsigned interface {
        ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}

type Integer interface {
        Signed | Unsigned
}

type Float interface {
        ~float32 | ~float64
}

// Ordered is a constraint that permits any ordered type: any type
// that supports the operators < <= >= >.
// If future releases of Go add new ordered types,
// this constraint will be modified to include them.
type Ordered interface {
        Integer | Float | ~string
}
复制代码

而这正是golang官方拓展包的实现:pkg.go.dev/golang.org/…

interface集合操作

  1. 并集

我们上边在用的一直都是并集操作,也就是用竖线分隔的多个类型:

type Float interface {
        float32 | float64
}
复制代码

上述的Float类型约束就支持float32/float64的实例化。

  1. 交集

同样的interface也支持交集操作,将类型分别写到多行,最终interface定义的类型约束就是这几行约束的交集:

type Float interface {
        float32 | float64
}
type Float32 interface {
        Float
        float64
}
复制代码

这里我们定义的Float32就Float和float64的交集,而Float是float32|float64,所以Float32最终其实只定义了float32这一个泛型约束(属于是)。

  1. 空集

通过空的交集我们可以定义出空的interface约束,比如

type Null interface {
    float32
    int32
}
复制代码

上述我们定义的Null就是float32和int32的交集,这两个类型的交集为空,所以最终定义出的这个Null就是一个空的类型约束,编译器不会阻止我们这样使用,但是实际上并没有什么意义。

~符号

在上边的Ordered类型约束的实现里,我们看到了~这个操作符,这个操作符的意思是,在实例化泛型时,不仅可以直接使用对应的实参类型,如果实参的底层类型在类型约束中,也可以使用,说起来可能比较抽象,来一段代码看一下

package main

type MyInt int

type Ints[T int | int32] []T

func main() {
   a := Ints[int]{10, 20} //正确
   b := Ints[MyInt]{10, 20}//错误
   println(a)
   println(b)
}
//MyInt does not implement int|int32 (possibly missing ~ for int in constraint int|int32)
复制代码

所以为了支持这种新定义的类型但是底层类型符合的方便使用,golang增加了新的~字符,意思是如果底层类型match,就可以正常进行泛型的实例化。所以可以改成如下的写法:

type Ints[T ~int | ~int32] []T
复制代码

interface的变化

go复用了interface关键字来定义泛型约束,那么对interface的定义自然也就有了变化,在go1.18之前,interface的定义是:go.dev/doc/go1.17_…

An interface type specifies a method set called its interface
复制代码

对interface的定义是method set(方法集) ,也确实是这样的,在go1.18前,interface就是方法的集合。

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

上述ReadWriter这个类型就是定义了Read和Write这两个方法,但是我们不妨反过来看待问题,有多个类型都实现了ReadWrite接口,那我们就可以把ReadWrite看成是多个类型的集合,而这个类型集合里的每一个类型都实现了ReadWrite定义的这两个方法,这里拿我们上边的空接口interface{}来举例,因为每个类型都实现了空接口,所以空接口就可以用来标识全部类型的集合,也就是我们前文介绍的any关键字。

所以结合上述我们介绍的用interface来定义泛型约束的类型集合,go1.18中,interface的定义换成了:go.dev/ref/spec#In…

An interface type defines a type set.
复制代码

对interface是type set(类型集) ,对interface的定义从方法集变成了类型集。接口类型的变量可以存储接口类型集中的任何类型的值。而为了golang承诺的兼容性,又将interface分成了两种,分别是

  1. 基本接口(basic interface)
  1. 一般接口(general interface)

两种interface

基本接口

如果接口定义里只有方法没有类型(也是在go1.18之前接口的定义,用法也是基本一致的),那么这种接口就是基本接口(basic interface)

  • 基本接口可以定义变量,例如最常用的error,这个跟go1.18之前的定义是一致的
// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
   Error() string
}

var err error
复制代码
  • 基本接口也可以作为类型约束,例如
package main

import (
   "bytes"
   "io"
   "strings"
)

type ReadOrWriters[T io.Reader | io.Writer] []T

func main() {
   rs := ReadOrWriters[io.Reader]{bytes.NewReader([]byte{}), bytes.NewReader([]byte{})}
   ws := ReadOrWriters[io.Writer]{&strings.Builder{}, &strings.Builder{}}
}
复制代码

一般接口

只要接口里包含类型约束(无论是否包含方法),这种接口被称为 一般接口(General interface) ,如下例子都是一般接口

  • 一般接口不能用来定义变量(限制一般接口只能用在泛型内,同时不影响go1.18前的接口定义)
package main

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

func main() {
   var i Int
}
//interface contains type constraints
复制代码
  • 一般接口只能用来定义类型约束

一些有意思的设计

  1. 为什么选用了方括号[]而不是其他语言里常见的尖括号<>

是为了和map,slice这些「内置泛型」保持一致,这样用起来会更协调。golang官方也回答了他们为什么选择了[],而不是<>,因为尖括号会导致歧义:

When parsing code within a function, such as v := F<T>, at the point of seeing the < it's ambiguous whether we are seeing a type instantiation or an expression using the < operator. Resolving that requires effectively unbounded lookahead. In general we strive to keep the Go parser simple.

当解析一个函数块中的代码时,类似v := F<T> 这样的代码,当编译器看到< 符号时,它搞不清楚这到底是一个泛型的实例化,还是一个使用了小于号的表达式。解决这个问题需要有效的无界lookahead。但我们现在更希望让 Go 的语法解析保持足够的简单。

总结

以上我们介绍了泛型的基本概念以及为什么需要泛型,在go1.18以前大家也都有各自的“泛型”实现方式,下一篇文章我们会解析golang泛型的实现原理。go对泛型的支持还是非常谨慎的,目前的功能也不是很丰富,回到最开始的那句话,泛型引入了抽象,无用的抽象带来复杂性,所以在泛型的使用上也要非常慎重。

引用

  1. go.dev/ref/spec
  1. go.googlesource.com/proposal/+/…
  1. go.dev/doc/go1.17_…
  1. go.googlesource.com/proposal/+/…
  1. golang3.eddycjy.com/posts/gener…
  1. segmentfault.com/a/119000004…
分类:
后端
分类:
后端
收藏成功!
已添加到「」, 点击更改