泛型

泛型在Go1.18开始支持。

泛型(Generics)是编程语言中一种强大的​​代码抽象工具​​,它允许你编写​​独立于具体类型​​的通用代码,同时保持​​类型安全​​。简单来说,泛型让你可以定义​​“类型占位符”​​,并在使用时指定具体的类型。

像是函数中可以定义形参(parameter),这里的形参类似于占位符并没有具体的值,具体的值是在函数调用的时候传入的,叫做实参(argument)。泛型就是把这个概念推广到了类型上,称之为类型形参(type parameter)类型实参(type argument)

// 假设 T 是类型形参,在定义函数时它的类型是不确定的,类似占位符 
func Add(a T, b T) T { return a + b }

在上面这段伪代码中, T 被称为 类型形参(type parameter), 它不是具体的类型,在定义函数时类型并不确定。因为 T 的类型并不确定,所以我们需要像函数的形参那样,在调用函数的时候再传入具体的类型。这样我们不就能一个函数同时支持多个不同的类型了吗?在这里被传入的具体类型被称为 类型实参(type argument):

// [T=int]中的 int 是类型实参,代表着函数Add()定义中的类型形参 T 全都被 int 替换
Add[T=int](100, 200)  
// 传入类型实参int后,Add()函数的定义可近似看成下面这样:
func Add( a int, b int) int {
    return a + b
}

// 另一个例子:当我们想要计算两个字符串之和的时候,就传入string类型实参
Add[T=string]("Hello", "World") 
// 类型实参string传入后,Add()函数的定义可近似视为如下
func Add( a string, b string) string {
    return a + b
}

通过引入 类型形参类型实参 这两个概念,我们让一个函数获得了处理多种不同类型数据的能力,这种编程方式被称为 泛型编程。

1 为什么需要泛型

GO是静态编译语言,也就是在使用变量的时候必须定义好类型。但这就带来了在编写通用操作的时候,比如函数:

由于函数形参类型写死了,如果我们想传入 floatstring 类型都会报错,这个时候我们要么多写几个同样逻辑的函数,要么就使用接口。

相对于使用 interface{},泛型类型参数的巨大优势在于,T 的最终类型在编译时就会被推导出来。为 T 定义一个类型约束,完全消除了运行时检查。如果用作 T 的类型不满足类型约束,代码就不会编译通过。

多写几个函数导致代码冗余且不好维护;使用接口性能差,且使用麻烦。所以为了解决这些问题就推出了泛型,但泛型也不是万能的,在思考是否使用泛型的时候,可以参考下面这条经验:

如果你经常要分别为不同的类型写完全相同逻辑的代码,那么使用泛型将是最合适的选择

2 泛型

通过上面的伪代码,我们实际上已经对Go的泛型编程有了最初步也是最重要的认识—— 类型形参 和 类型实参。而Go1.18也是通过这种方式实现的泛型,但是单纯的形参实参是远远不能实现泛型编程的,所以Go还引入了非常多全新的概念:

  • 类型形参 (Type parameter)

  • 类型实参(Type argument)

  • 类型形参列表( Type parameter list)

  • 类型约束(Type constraint)

  • 实例化(Instantiations)

  • 泛型类型(Generic type)

  • 泛型接收器(Generic receiver)

  • 泛型函数(Generic function)

  • 等等等等。

3 类型形参、类型实参、类型约束和泛型类型

当你需要很多结构一样,只是类型不一样的话,就可以使用泛型来定义。

  • T就是上面介绍的类型形参

    • 在函数名、类型名或方法接收器后的方括号 []中声明。

    • 使用大写字母开头的标识符(约定如 T, K, V)表示类型占位符。

    • 多个类型形参由逗号隔开

  • 后面的 int | float32 | string 为类型约束,限制哪些类型可以被使用。

  • []T表示这是一个切片类型,所以类型是一个切片,且类型名为 Slice[T]

类型定义中带 类型形参 的类型,称之为泛型类型(Generic type)

泛型类型不能直接拿来使用,必须传入类型实参(Type argument) 将其确定为具体的类型之后才可使用。而传入类型实参确定具体类型的操作被称为 实例化(Instantiations) :

Slice[T] 只是类型名,必须传入类型实参示例化才能使用。

  • KEY和VALUE是类型形参

  • int|string 是KEY的类型约束float32|float64 是VALUE的类型约束

  • KEY int|string, VALUE float32|float64 整个一串文本因为定义了所有形参所以被称为类型形参列表

  • Map[KEY, VALUE] 是泛型类型,类型的名字就叫 Map[KEY, VALUE]

  • var a MyMap[string, float64] = xx 中的string和float64是类型实参,用于分别替换KEY和VALUE,实例化出了具体的类型 MyMap[string, float64]

3.1 其他泛型类型

所有类型定义都可使用类型形参,所以下面这种结构体以及接口的定义也可以使用类型形参:

类型形参是可以互相套用的,也就是一个形参列表中的形参可以相互使用,如下

3.2 注意事项

  1. 定义泛型类型的时候,基础类型不能只有类型形参,如下:

  1. 当类型约束的一些写法会被编译器误认为是表达式时会报错。如下:

  1. 匿名结构体/匿名函数不能使用泛型。

  1. 泛型不允许像接口一样断言或使用 type switch来确认泛型形参的具体类型。

3.3 类型约束的两种写法

能最小化就最小化类型。

4 泛型recever

我们可以在类型上定义泛型,那可以为泛型类型定义方法嘛?答案是可以的。

通过泛型receiver,泛型的实用性一下子得到了巨大的扩展。在没有泛型之前如果想实现通用的数据结构,诸如:堆、栈、队列、链表之类的话,我们的选择只有两个:

  • 为每种类型写一个实现

  • 使用 接口+反射

而有了泛型之后,我们就能非常简单地创建通用数据结构了。

目前不支持泛型方法,只能通过泛型recever的方法间接使用。

5 泛型函数

带类型形参的函数被称为泛型函数。

和泛型类型一样,泛型函数也是不能直接调用的,要使用泛型函数的话必须传入类型实参之后才能调用。

6 类型接口

有时候使用泛型编程时,我们会书写长长的类型约束,如下:

理所当然,这种写法是我们无法忍受也难以维护的,而Go支持将类型约束单独拿出来定义到接口中,从而让代码更容易维护:

这段代码把类型约束给单独拿出来,写入了接口类型 IntUintFloat 当中。需要指定类型约束的时候直接使用接口 IntUintFloat 即可。

不过这样的代码依旧不好维护,而接口和接口、接口和普通类型之间也是可以通过 | 进行组合

6.1 ~:指定底层类型

上面定义的 Slie[T] 虽然可以达到目的,但是有一个缺点:

这里发生错误的原因是,泛型类型 Slice[T] 允许的是 int 作为类型实参,而不是 MyInt (虽然 MyInt 类型底层类型是 int ,但它依旧不是 int 类型)。

为了从根本上解决这个问题,Go新增了一个符号 ~ ,在类型约束中使用类似 ~int 这种写法的话,就代表着不光是 int ,所有以 int 为底层类型的类型也都可用于实例化。

使用 ~ 对代码进行改写之后如下:

限制:使用 ~ 时有一定的限制:

  1. ~后面的类型不能为接口

  2. ~后面的类型必须为基本类型

6.2 从方法集到类型集

上面的例子中,我们学习到了一种接口的全新写法,而这种写法在Go1.18之前是不存在的。如果你比较敏锐的话,一定会隐约认识到这种写法的改变这也一定意味着Go语言中 接口(interface) 这个概念发生了非常大的变化。

是的,在Go1.18之前,Go官方对接口(interface) 的定义是:接口是一个方法集(method set)

An interface type specifies a method set called its interface

就如下面这个代码一样, ReadWriter 接口定义了一个接口(方法集),这个集合中包含了 Read() 和 Write() 这两个方法。所有同时定义了这两种方法的类型被视为实现了这一接口。

但是,我们如果换一个角度来重新思考上面这个接口的话,会发现接口的定义实际上还能这样理解:

我们可以把 ReaderWriter 接口看成代表了一个 类型的集合,所有实现了 Read() Writer() 这两个方法的类型都在接口代表的类型集合当中

通过换个角度看待接口,在我们眼中接口的定义就从 方法集(method set) 变为了 类型集(type set)。而Go1.18开始就是依据这一点将接口的定义正式更改为了 类型集(Type set)

An interface type defines a type set (一个接口类型定义了一个类型集)

6.3 空接口

空接口 interface{} 。因为,Go1.18开始接口的定义发生了改变,所以 interface{} 的定义也发生了一些变更:

空接口代表了所有类型的集合

所以,对于Go1.18之后的空接口应该这样理解:

  1. 虽然空接口内没有写入任何的类型,但它代表的是所有类型的集合,而非一个 空集

  2. 类型约束中指定 空接口 的意思是指定了一个包含所有类型的类型集,并不是类型约束限定了只能使用 空接口 来做类型形参

因为空接口是一个包含了所有类型的类型集,所以我们经常会用到它。于是,Go1.18开始提供了一个和空接口 interface{} 等价的新关键词 any ,用来使代码更简单:

实际上 any 的定义就位于Go语言的 builtin.go 文件中(参考如下), any 实际上就是 interaface{} 的别名(alias),两者完全等价

6.4 comparable(可比较) 和 可排序(ordered)

对于一些数据类型,我们需要在类型约束中限制只接受能 !===对比的类型,如map:

所以Go直接内置了一个叫 comparable 的接口,它代表了所有可用 != 以及 == 对比的类型:

comparable 比较容易引起误解的一点是很多人容易把他与可排序搞混淆。可比较指的是 可以执行 !=`` == 操作的类型,并没确保这个类型可以执行大小比较(>,<,<=,>=)。如下:

而可进行大小比较的类型被称为 Orderd 。目前Go语言并没有像 comparable 这样直接内置对应的关键词,所以想要的话需要自己来定义相关接口,比如我们可以参考Go官方包golang.org/x/exp/constraints如何定义:

6.5 接口分类

Go1.18开始将接口分为了两种类型

  • 基本接口(Basic interface)

  • 一般接口(General interface)

6.5.1 基本接口(Basic interface)

接口定义中如果只有方法的话,那么这种接口被称为基本接口(Basic interface)。这种接口就是Go1.18之前的接口,用法也基本和Go1.18之前保持一致。基本接口大致可以用于如下几个地方:

最常用的,定义接口变量并赋值

基本接口因为也代表了一个类型集,所以也可用在类型约束中

6.5.2 一般接口(General interface)

如果接口内不光只有方法,还有类型的话,这种接口被称为 一般接口(General interface) ,如下例子都是一般接口:

一般接口类型不能用来定义变量,只能用于泛型的类型约束中。所以以下的用法是错误的:

这一限制保证了一般接口的使用被限定在了泛型之中,不会影响到Go1.18之前的代码,同时也极大减少了书写代码时的心智负担

6.6 泛型接口

所有类型的定义中都可以使用类型形参,所以接口定义自然也可以使用类型形参,观察下面这两个例子:

因为引入了类型形参,所以这两个接口是泛型类型。而泛型类型要使用的话必须传入类型实参实例化才有意义。所以我们来尝试实例化一下这两个接口。因为 T 的类型约束是 any,所以可以随便挑一个类型来当实参(比如string):

经过实例化之后就好理解了, DataProcessor[string] 因为只有方法,所以它实际上就是个 基本接口(Basic interface),这个接口包含两个能处理string类型的方法。像下面这样实现了这两个能处理string类型的方法就算实现了这个接口:

6.7 注意事项

  1. 用 | 连接多个类型的时候,类型之间不能有相交的部分(即必须是不交集):

但是相交的类型中是接口的话,则不受这一限制:

  1. 类型的并集中不能有类型形参

  1. 接口不能直接或间接地并入自己

  1. 接口的并集成员个数大于一的时候不能直接或间接并入 comparable 接口

  1. 带方法的接口(无论是基本接口还是一般接口),都不能写入接口的并集中:

7 泛型实现

一般来说,泛型有虚拟方法表(VMT)​​ 与 ​​单态化(Monomorphization) 的实现方式。

7.1 虚拟方法表(VMT)

虚拟方法表是多态性的一种经典实现方式。其核心思想是:​​在编译时无法确定具体调用哪个方法,需要在运行时通过查表决定。​

  • ​工作原理​​:当一个类型实现某个接口(或满足泛型约束)时,编译器会为其创建一个VMT。这个表存储了该类型所有方法的函数指针。对象实例中包含一个指向其VMT的指针。当通过接口或泛型约束调用方法时,运行时系统会通过这个指针找到VMT,再在VMT中查找对应方法的位置,最后进行间接调用。

  • ​性能开销​​:这个过程涉及多次内存访问(指针解引用)和间接调用,无法进行内联等优化,因此比直接调用慢

  • ​优势​​:代码体积小,编译速度快,因为只需要一份泛型函数的“通用”代码。

7.2 单态化(Monomorphization)

-单态化则是一种更直接的方法,其核心是​​在编译时通过复制代码来消除运行时的不确定性​​。

  • ​工作原理​​:编译器会检查所有使用泛型函数的地方,对于每一个被用于实例化泛型参数的具体类型,都生成一份该类型的专用函数副本。例如,对于 max[int]max[float64],编译器会生成 max_intmax_float64两个函数。

  • ​性能优势​​:生成的代码是类型特定的,所有方法调用都是直接的静态调用。编译器可以对这些副本进行完整的优化,包括内联,从而获得与手写代码几乎相同的性能。

  • ​代价​​:会导致“代码膨胀”,即生成的二进制文件变大,并且编译时间更长

7.3 go实现

Go语言的设计哲学强调在编译速度和运行时性能之间取得平衡。因此,它没有纯粹采用某一种策略,而是根据类型的“内存布局”进行划分,采用了一种混合模式。其决策流程可以清晰地通过下图展示:

这种混合方法的好处是,大部分情况下(使用值类型)你能获得近乎原生的性能,而只在处理指针或接口时(通常对性能不那么敏感的场景)付出动态调度的代价。

值得注意的是,这些性能差异主要影响的是​​函数调用的开销​​。对于函数内部有大量复杂计算的情况,调用开销所占的比例可能很小,优化重点仍应放在算法和内部逻辑上。

性能建议:

  1. ​优先使用值类型​​:在性能关键的代码路径上,尽量使用值类型的泛型实例化(如 Container[int]),以触发单态化,获得最佳性能。

  2. ​理解指针/接口的开销​​:当泛型参数是指针或接口时,要有性能意识。如果发现这里成为瓶颈,可以考虑是否为具体类型手写函数。

  3. ​泛型并非万能​​:不要为了使用泛型而使用泛型。如果代码的逻辑对于不同类型确实有显著不同,那么使用接口和多态可能是更清晰、更自然的选择。

  4. ​性能优化顺序​​:始终遵循 ​​“先优化算法和内部逻辑,再担忧调用机制”​​ 的原则

7.4 Go实现单态化

Go语言泛型实现中的​​单态化​​,其核心思想确实是在编译阶段,为每个被实际使用的类型生成一份该泛型代码的​​特化副本​​,这些副本会作为独立的函数被放置在最终的可执行文件中。

不过,Go语言的实现比“为每个类型创建副本”更加精细和智能,它采用了一种名为 ​​GC Shape Stenciling​​ 的优化策略来避免生成大量重复的代码,从而在保持性能的同时控制编译后程序的体积。

GC Shape​​ 可以理解为垃圾回收器视角下类型的“内存布局”。它主要由类型的大小、内存对齐方式以及是否包含指针等特性决定。

  • ​不同的GC Shape​​:例如,intstruct { Name string }在内存中的布局完全不同(大小不同,后者包含指针),因此它们属于不同的GC Shape,编译器会为它们生成两份独立的泛型函数副本。

7.5 Go实现虚拟方法表

所有的指针类型在GO看来都属于一个 GC shape,所以只会生成一个指针副本,那么只有一个副本在运行时是如何知道调用的方法是什么样的呢?

为了在这份通用代码中区分不同具体类型,编译器会为每一个不同指针类型生成一个​​字典​​,并在调用时将其作为隐藏参数传入。函数内部通过查询这个字典来执行类型特定的操作(如方法调用)。这个过程会引入类似于接口调用的间接开销。

  1. ​编译时生成字典​​:对于每一个具体的类型实例化(如 Process[*bytes.Buffer]),编译器会生成一个对应的​​静态字典​​。这个字典包含了操作该类型所需的所有元数据,例如类型的大小、对齐方式,以及​​该类型必须实现的方法的函数指针表​​。

  2. ​隐藏参数传递​​:当调用泛型函数时,编译器会悄无声息地将对应类型的字典作为一个​​隐藏参数​​传递给函数。

  3. ​运行时查询字典​​:在泛型函数内部,每当需要进行类型特定的操作(如调用一个方法),生成的通用代码就会​​查询传入的字典​​,找到所需方法的具体地址,然后进行间接调用。

最后更新于