十年磨一剑 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:函数重载其实也是泛型的一种完成,在编译时经过将类型参数信息加入函数符号里,就完成了编码时的调用同名函数,可是在运转时由于类型信息也不会有二义性)。

十年磨一剑 go 1.18泛型

那么有没有一种手法能够减少咱们重复的工作量呢?在需求做了拓宽后,也能在不改动原有代码的根底上做支撑,也便是进步代码的可复用性,而这便是泛型的使命。

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…

发表回复

提供最优质的资源集合

立即查看 了解详情