go语言泛型

泛型编程的中心思想是对具体的、高效的算法进行抽象,以获得通用的算法,然后这些算法可以与不同的数据表示法结合起来,产生各种各样有用的软件。
简单了说是将算法与类型解耦,实现算法更广泛的复用。

 泛型程序设计(generic programming)是程序设计语言的一种风格或范式。泛型允许程序员在强类型程序设计语言中编写代码时使用一些以后才指定的类型,在实例化时作为参数指明这些类型。

func AddInt8(a int8, b int8) int8 {
	return a + b
}

func AddInt16(a int16, b int16) int16 {
	return a + b
}

func AddInt32(a int32, b int32) int32 {
	return a + b
}

func AddInt64(a int64, b int64) int64 {
	return a + b
}

大家看到上面的代码有没有问题,感觉逻辑都是a+b,但是写了多次,如果有几十种类型,是不是要写对应几十个方法?

(1) 泛型

 
 泛型是一种编程范式,这种范式与特定的类型无关,泛型允许在函数和类型的实现中使用某个类型集合中的任何一种类型。

(2) Go泛型

在Go 1.18版本中,引入了对使用参数化类型的泛型代码的新支持。泛型是自Go语言开源以来对Go做出的一次最大的变更。

  1. 函数和类型新增对**类型形参(type parameters)的支持。
  2. 将接口类型定义为类型集合,包括没有方法的接口类型。
  3. 支持类型推导,大多数情况下,调用泛型函数时可省略类型实参(type arguments)。

(2.1) 类型形参(Type Parameters)

让我们先看一个用于浮点值的基本的、非泛型的Min函数:

func Min(x, y float64) float64 {
    if x < y {
        return x
    }
    return y
}

通过添加一个类型形参列表来使这个函数泛型化,以使其适用于不同的类型。
在这个例子中,我们添加了一个仅有一个类型形参T的类型形参列表,并用T替换float64的使用。

// 如果提示 Unresolved type 'constraints'   
// 1. 修改 go.mod文件 把go版本设置成 go 1.18
// 2. 使用最新的goland
// 3. 清理一下编译器的缓存  file->Invalidate Caches / Restart 
func GMin[T constraints.Ordered](x, y T) T {
    if x < y {
        return x
    }
    return y
}

向GMin函数提供类型实参,在本例中是int,称为实例化(instantiation)。
实例化分两步进行。
首先,编译器在整个泛型函数或泛型类型中把所有的类型形参替换成它们各自的类型实参。
第二,编译器验证每个类型实参是否满足各自的约束条件。我们很快就会知道这意味着什么,但是如果第二步失败,实例化就会失败,程序也会无效。

(2.2) 类型具化(instantiation)

func Sort[Elem interface{ Less(y Elem) bool }](list []Elem) {
}

type book struct{}
func (x book) Less(y book) bool {
        return true
}

func main() {
    var bookshelf []book
    Sort[book](bookshelf) // 泛型函数调用
}

根据 Go 泛型的实现原理,上面的泛型函数调用 Sort[book](bookshelf)会分成两个阶段:
第一个阶段就是具化(instantiation)。
形象点说,具化(instantiation)就好比一家生产“排序机器”的工厂根据要排序的对象的类型,将这样的机器生产出来的过程。我们继续举前面的例子来分析一下,整个具化过程如下:
1、工厂接单:Sort[book],发现要排序的对象类型为 book;
2、模具检查与匹配:检查 book 类型是否满足模具的约束要求(也就是是否实现了约束定义中的 Less 方法)。
如果满足,就将其作为类型实参替换 Sort 函数中的类型形参,结果为 Sort[book],如果不满足,编译器就会报错;
3、生产机器:将泛型函数 Sort 具化为一个新函数,这里我们把它起名为 booksort,其函数原型为 func([]book)。本质上 booksort := Sort[book]。

第二阶段是调用(invocation)。
一旦“排序机器”被生产出来,那么它就可以对目标对象进行排序了,这和普通的函数调用没有区别。这里就相当于调用 booksort(bookshelf),整个过程只需要检查传入的函数实参(bookshelf)的类型与 booksort 函数原型中的形参类型([]book)是否匹配就可以了。

Sort[book](bookshelf)

<=>

具化:booksort := Sort[book]
调用:booksort(bookshelf)

(3) 泛型困境

The Generic Dilemma

 泛型和其他特性一样不是只有好处,为编程语言加入泛型会遇到需要权衡的两难问题。语言的设计者需要在编程效率编译速度运行速度三者进行权衡和选择,编程语言要选择牺牲一个而保留另外两个。
go-generics-dilemma

以 C、C++ 和 Java 为例,介绍它们在设计上的不同考量:
 
 拖慢编程效率:不实现泛型,不会引入复杂性,需要程序员重复实现 AddInt、AddInt64 等;
代表: C语言

 拖慢编译速度:通过增加编译器负担为每个类型实例生成一份单独的泛型函数的实现;
 代表:C++使用编译期间类型特化实现泛型,编译器的实现变得非常复杂,泛型展开会生成的大量重复代码,也会导致最终的二进制文件膨胀和编译缓慢,往往需要链接器来解决代码重复的问题;
 
 拖慢运行速度:像 Java 的泛型实现方案那样,通过隐式的装箱和拆箱操作消除类型差异。
 代表: Java 在 1.5 版本引入了泛型,它的泛型是用类型擦除实现的。Java 的泛型只是在编译期间用于检查类型的正确,为了保证与旧版本JVM的兼容,类型擦除会删除泛型的相关信息,导致其在运行时不可用。编译器会插入额外的类型转换指令,Java类型的装箱和拆箱会降低程序的执行效率;

go-generics-and-programming-languages

Go 1.18 发布说明中给出了一个结论:Go 1.18 编译器的性能要比 Go 1.17 下降 15% 左右。
Go使用的是编译时对泛型做的处理。但go使用的是一个hybrid方案。

参考资料

[1] Go泛型介绍[译]
[2] Type Parameters Proposal
[3] 聊聊最近大热的Go泛型
[4] Go 1.18中值得关注的几个变化
[5]Go泛型是怎么实现的?
[6] Go 1.18 泛型全面讲解:一篇讲清泛型的全部
[7] 为什么 Go 语言没有泛型