go1.18泛型支持

分享:  

go1.18 泛型支持

关于泛型编程

首先什么是泛型呢?

Generic programming is a style of computer programming in which algorithms are written in terms of types to-be-specified-later that are then instantiated when needed for specific types provided as parameters.

泛型编程有啥好处呢?

  • cleaner code and simpler API (not always)
  • improve code exectution performance (not always)

没有泛型的日子

如何应付的

go1.18之前苦于没有范型编程,开发人员一般会这么做:

  • go编译器对内置类型有一定的范型支持,比如new、make、len、cap
  • go支持reflection和interace,通过这两个一定程度上可以模拟范型的能力
  • go支持//go:generate,通过自定义工具可以生成一些“重复”代码

痛点依然在

即便是通过反射、interface来模拟也把风险从编译时类型安全推到了运行时检查部分,生成代码也会有大量重复性代码……所以痛点依然存在。

go1.18中终于解决了这个问题,虽然现在来看还没那么尽善尽美,但是总算在路上了。

go泛型知识点

go1.19当前范型设计实现,也还没完全实现提案type parameters proposal,这个提案也并非未来go泛型实现的天花板,会一步步完善。尽管还不尽善尽美,但是将来go泛型编程应该有较大的应用场景。现在有些库已经在用泛型重写了。

自定义泛型:

1.18支持了自定义范型(customized generics),这个提法是为了与内置的泛型支持区分开。所说的内置泛型,指的是类似new、make、len、cap这样的一些函数,或者map[k]v这样的数据结构类型,这些有泛型的思想和支持。

但是我们所说的泛型主要是指自定义的泛型类型、函数、方法。

基础知识:

  • 泛型类型 type Lockable[T any]
  • 泛型方法 func(l *Lockable[T]) Do(f func(*T)) {…}
  • 泛型函数 func Equal[T comprable](a, b T) bool { return a == b}
  • 如果类型参数列表中有多个,如[a any, b, c, _ comparable],它们的顺序没有影响的

接口表示:

  • tilde form:~T,波浪号+类型,表示类型集合,表示所有underlying type为T的类型
  • term form:T1 | T2 | …. | Tn,类型的联合

接口嵌套:

  • 1.18之前接口内可以嵌入任意数量函数、任意类型名(只要类型名为接口名即可)

  • 1.18中放松了嵌入类型名的限制,可以是

    • 任意类型的字面量,只要不是类型参数名即可,比如string、其他接口名
    • 无名接口定义
    • tilde形式
    • term形式

    而类型参数的constraint其实就是interaface,这里的增强大大增强了constraint描述的范畴,如所有的int、uint、float,或者string

  • 下面是些合法的接口定义

    type L interface {
    	Run() error
    	Stop()
    }
    
    type M interface {
    	L
    	Step() error
    }
    
    type N interface {
    	M
    	interface{ Resume() }
    	~map[int]bool
    	~[]byte | string
    }
    
    type O interface {
    	Pause()
    	N
    	string
    	int64 | ~chan int | any
    }
    

    在一个接口A中嵌入另一个接口B,相当于把B递归的展开把方法全部作为A的方法,比如接口0相当于这样,其中的不是方法名的部分,~map[int]bool…int64|~chan int|any,可以看做不同的term union form。

    注意interface { int; uint } 表示底层类型同时是int和uint的,而interface{int | uint}表示底层类型或者是int或或uint的,是两种完全不同的概念。

    type O interface {
    	Run() error
    	Stop()
    	Step() error
    	Pause()
    	Resume()
    	~map[int]bool
    	~[]byte | string
    	string
    	int64 | ~chan int | any
    }
    
  • 如果constraint中只包含一个元素,而且它是type element,那么可以省略外层的interface{},比如[T interface{~int}]可以简化为[T ~int]

  • 但是上述简化,有时也会遇到解析问题,比如[T *int],这里表示的是啥意思呢?是underlying type为 int的范型类型?还是把int当做一个常量解释为一个Tint这么大的数组?现在确实是当做数组的。编译器当然可以解决这个问题,但是得做些额外的处理,后面可能会优化吧。

    weired,那现在如何化解这个问题?

    • 可以用完整形式,用interface裹起来
    • 在最后加一个逗号结尾[T *int,],类型参数列表最后允许加逗号的,换行的话也要用逗号连接

    我擦,就不要用这种破坏可读性的方式来写,直接用interface{}包起来!

    在看个奇葩的[A int, B *A],我擦这里的A到底是啥?I don’t know!

    • 尽管类型参数的constraint是一个接口,但是不代表类型参数就可以像普通接口变量那样可以有动态值、可以断言,我们把它理解成一个类型、把constraint理解成一种限制就可以了,不要总想着它是一个接口值的变量(实际上也不是)。

类型参数作用域:

  • 参考这里:http://localhost:55556/generics/555-type-constraints-and-parameters.html#:~:text=Go specification says,of the type.
  • 举个例子:type G[S ~[]E, E int] struct{},这里的E后面有作为了S的声明,对于函数、方法也是类似的。就是说一个类型参数(比如E)的作用域从这个类型、函数、方法定义开始就有效,直到定义结束。所以这里的E是有效的,对于S它自然要找E在哪定义的,怎么找,在当前scope里面找,因为specification这么定义的,它当然在这个scope里找定义了。跟从左往右、从右往左这种表面上的顺序无关。

类型参数实例化、类型推导:

  • 其实包括泛型类型中的类型参数实例化,和泛型函数、泛型方法中的类型参数实例化

实例化时参数列表省略问题:

  • 泛型类型中:省略类型参数列表不能省略,要写完整的
  • 泛型函数、泛型方法:当可以推断时,可以部分省略或完全省略

实例化时传递的实参问题:

  • 基本接口类型any、error可以作为类型参数的实例化参数。
  • 如果一个类型参数A的constraint满足另一个类型参数B的constraint,那么可以传递A的实例化作为B对应的类型参数的实例化参数

类型参数上的操作

  • 看这里吧:http://localhost:55556/generics/777-operations-on-values-of-type-parameter-types.html,实在是费解,这么多特殊规则,谁能记得住怎么写

  • 有些操作是有效的,有些则是无效的。通俗地说,某个操作是否有效,要看其对类型参数对constraint所表示的type set中每个类型是否都有效,都有效才算是有效的。

  • go自定义泛型不是通过c++模版那样重复生成代码实现的,这也是和代码生成的不同之处。有一条principle rule就是:在go里面,每个有类型的表达式计算都必须有一个指定的类型,这里的类型可以是普通类型,也可以是类型参数。这条原则很关键,比方说typeset包含多个候选类型参数值,函数体里对值的操作表达式对应的类型需要有一个核心类型来表示。比如下面这个:

    // 如果T是chan int,那么从c读到的是int,如果T是chan bool,那么从c读初的是bool,
    // 一个是int,一个是bool,不能统一到同一个类型,这就叫core type missing,
    // 此时定义一个类型参数作个衔接就可以了。
    func read[T chan int|chan bool](c T) {
    	_ = <-c
    
    }
    
    // 改成这样就可以了
    func read[T chan E, E int|bool](c T) {
      _ = <-c
    }
    

    这里的限制多写写可能会更好理解,多看多学吧,理解go泛型还真有点费解,哈哈哈 🙂

go泛型技术细节

go1.18中的generics(范型)实现思路:

  1. stenciling(蜡印)这种方式会为范型函数的每种用到的类型参数实例化一个函数,这种好处是会减少函数调用开销,缺点是会导致binary尺寸过大,也可能会导致一些当前无法预见的问题。
  2. dictionary(字典)这种方式不会像stenciling那样为每个类型参数实例化一个,而是就生成一个函数实例,但是多给它传入一个dictionary指针参数,这个指针参数里包含了实际用到的类型参数(parameterized type)对应的具体类型参数(concrete type),有了这个信息就能知道实参的size、alignment等信息,这样在安排内存、栈帧、函数参数、返回值的时候就知道该如何组织了,后续的就没什么复杂的了。这种方式的一个问题,是需要考虑如何优化调用的性能开销。
  3. gcshape,它描述的是在内存allocator、garbage collector视角看起来描述信息长得类似的type,这种type相同的可以实例化一个,这样的话可能有多次实例化,就暗含了stenciling的思想,虽然还是多了传字典,但是省了因为底层type不一样时所要做的额外工作。

本文小结

本文简单总结了go泛型编程的一些知识点、注意事项,以及简要介绍了go泛型的设计实现原理。内容没有展开太多,感兴趣的可以参考文章末了列出的参考文献。

参考内容

  • 泛型编程:https://en.wikipedia.org/wiki/Generic_programming

  • 初识go泛型:http://localhost:55556/generics/444-first-look-of-custom-generics.html

  • 类型参数限制:http://localhost:55556/generics/555-type-constraints-and-parameters.html

  • 类型参数实例化:http://localhost:55556/generics/666-generic-instantiations-and-type-argument-inferences.html

  • 对类型参数值的操作:http://localhost:55556/generics/777-operations-on-values-of-type-parameter-types.html

  • go泛型目前的状态:http://localhost:55556/generics/888-the-status-quo-of-go-custom-generics.html

  • go1.18 generics设计实现:https://github.com/golang/proposal/blob/master/design/generics-implementation-dictionaries-go1.18.md