泛型
泛型
泛型,或者更学术化的名称 —— 参数化多态(Parameterized Polymorphism),是指通过类型参数化来实现代码的复用与灵活性。在许多编程语言中,参数化多态是一个重要的概念,它使得函数或数据结构可以处理不同类型的数据,而无需为每种类型编写单独的代码。最初的 Go 是没有泛型这一说法的,但自从诞生以来,社区关于 Go 呼声最高的事情就是希望加入泛型,终于 Go 语言于 2022 年在 1.18 版本加入了对泛型的支持。
设计
Go 语言当初在设计泛型时,考虑过了以下的方案
- stenciling :单态化,比较典型的像 C++,Rust 这种,为每一个用到的类型都生成一份模板代码,这种的性能是最好的,完全没有任何运行时开销,性能等于直接调用,缺点就是大幅度拖慢编译速度(相较于 Go 自己而言),同时由于为每一个类型生成了代码,也会导致编译的二进制文件体积膨胀。
- dictionaries :它只会生成一套代码,同时在编译时生成一个类型字典存储在只读数据段,它存储了所有会用到的类型信息,在调用函数时,则会根据字典去查询类型信息。这种方式不会拖慢编译速度,也不会造成体积膨胀,但是会造成巨大的运行时开销,泛型的性能很差。
上面两个方法代表着两种极端,Go 语言最终选择的实现方案是 Gcshape stenciling ,它是一个折中的选择,对于同种内存形状(形状怎么看由内存分配器决定)的类型会使用单态化,为其生成同一份代码,比如 type Int int
与 int
其实是同一个类型,所以共用一套代码,但是对于指针而言,所有的指针类型都是同一个内存形状,比如 *int
, *Person
都是一个内存形状,它们是无法共用一套代码的,为此,Go 同时也会使用字典在运行时获取类型信息,所以 Go 的泛型也存在运行时开销。
引子
先来看一个简单的例子。
func Sum(a, b int) int {
return a + b
}
这是一个功能十分简单的函数,作用就是将两个 int
类型的整数相加并返回结果,倘若想要传入两个 float64
类型的浮点数求和的话,显然是不可以的,因为类型不匹配。一种解决办法就是再定义一个新的函数,如下
func SumFloat64(a, b float64) float64 {
return a + b
}
那么问题来了,如果开发一个数学工具包,计算所有数字类型的两数之和,难道要每一个类型都要编写一个函数吗?显然是不太可能的,或者可以使用 any
类型加反射来判断,如下
func SumAny(a, b any) (any, error) {
tA, tB := reflect.ValueOf(a), reflect.ValueOf(b)
if tA.Kind() != tB.Kind() {
return nil, errors.New("disMatch type")
}
switch tA.Kind() {
case reflect.Int:
case reflect.Int32:
...
}
}
但是这样写很繁琐,而且性能低下。但是 Sum
函数的逻辑都是一模一样的,都只不过是将两个数相加而已,这时候就需要用到了泛型,所以为什么需要泛型, 泛型是为了解决执行逻辑与类型无关的问题,这类问题不关心给出的类型是什么,只需要完成对应的操作就足够。
语法
泛型的写法如下
func Sum[T int | float64](a, b T) T {
return a + b
}
类型形参:T 就是一个类型形参,形参具体是什么类型取决于传进来什么类型
类型约束:int | float64
构成了一个类型约束,这个类型约束内规定了哪些类型是允许的,约束了类型形参的类型范围
类型实参:Sum[int](1,2)
,手动指定了 int
类型,int
就是类型实参。
第一种用法,显式的指明使用哪种类型,如下
Sum[int](2012, 2022)
第二种用法,不指定类型,让编译器自行推断,如下
Sum(3.1415926, 1.114514)
这是一个泛型切片,类型约束为 int | int32 | int64
type GenericSlice[T int | int32 | int64] []T
这里使用时就不能省略掉类型实参
GenericSlice[int]{1, 2, 3}
这是一个泛型哈希表,键的类型必须是可比较的,所以使用 comparable
接口,值的类型约束为 V int | string | byte
type GenericMap[K comparable, V int | string | byte] map[K]V
使用
gmap1 := GenericMap[int, string]{1: "hello world"}
gmap2 := make(GenericMap[string, byte], 0)
这是一个泛型结构体,类型约束为 T int | string
type GenericStruct[T int | string] struct {
Name string
Id T
}
使用
GenericStruct[int]{
Name: "jack",
Id: 1024,
}
GenericStruct[string]{
Name: "Mike",
Id: "1024",
}
这是一个泛型切片形参的例子
type Company[T int | string, S []T] struct {
Name string
Id T
Stuff S
}
//也可以如下
type Company[T int | string, S []int | string] struct {
Name string
Id T
Stuff S
}
使用
Company[int, []int]{
Name: "lili",
Id: 1,
Stuff: []int{1},
}
提示
在泛型结构体中,更推荐这种写法
type Company[T int | string, S int | string] struct {
Name string
Id T
Stuff []S
}
SayAble
是一个泛型接口,Person
实现了该接口。
type SayAble[T int | string] interface {
Say() T
}
type Person[T int | string] struct {
msg T
}
func (p Person[T]) Say() T {
return p.msg
}
func main() {
var s SayAble[string]
s = Person[string]{"hello world"}
fmt.Println(s.Say())
}
泛型接口
泛型接口可以提供更好抽象约束能力,下面是一个例子
func PrintObj[T fmt.Stringer](s T) {
fmt.Println(s.String())
}
type Person struct {
Name string
}
func (p Person) String() string {
return fmt.Sprintf("Person: %s", p.Name)
}
func main() {
PrintObj(Person{Name: "Alice"})
}
也可以将非泛型接口作为泛型的类型形参
func Write[W io.Writer](w W, bs []byte) (int, error) {
return w.Write(bs)
}
泛型断言
我们可以使用泛型来对 any
类型进行类型断言,比如下面一个函数就可以断言所有的类型。
func Assert[T any](v any) (bool, T) {
var av T
if v == nil {
return false, av
}
av, ok := v.(T)
return ok, av
}
类型集
在 1.18 以后,接口的定义变为了类型集 (type set)
,含有类型集的接口又称为 General interfaces
即通用接口。
An interface type defines a type set
类型集只能用于泛型中的类型约束,不能用作类型声明,类型转换,类型断言。类型集作为一个集合,就会有空集,并集,交集,接下来将会讲解这三种情况。
并集
接口类型 SignedInt
是一个类型集,有符号整数类型的并集就是 SignedInt
,反过来 SignedInt
就是它们的超集。
type SignedInt interface {
int8 | int16 | int | int32 | int64
}
基本数据类型如此,对待其它通用接口也是如此
type SignedInt interface {
int8 | int16 | int | int32 | int64
}
type UnSignedInt interface {
uint8 | uint16 | uint32 | uint64
}
type Integer interface {
SignedInt | UnSignedInt
}
交集
非空接口的类型集是其所有元素的类型集的交集,翻译成人话就是:如果一个接口包含多个非空类型集,那么该接口就是这些类型集的交集,例子如下
type SignedInt interface {
int8 | int16 | int | int32 | int64
}
type Integer interface {
int8 | int16 | int | int32 | int64 | uint8 | uint16 | uint | uint32 | uint64
}
type Number interface {
SignedInt
Integer
}
例子中的交集肯定就是 SignedInt
,
func Do[T Number](n T) T {
return n
}
Do[int](2)
DO[uint](2) //无法通过编译
空集
空集就是没有交集,例子如下,下面例子中的 Integer
就是一个类型空集。
type SignedInt interface {
int8 | int16 | int | int32 | int64
}
type UnsignedInt interface {
uint8 | uint16 | uint | uint32 | uint64
}
type Integer interface {
SignedInt
UnsignedInt
}
因为无符号整数和有符号整数两个肯定没有交集,所以交集就是个空集,下方例子中不管传什么类型都无法通过编译。
Do[Integer](1)
Do[Integer](-100)
空接口
空接口与空集并不同,空接口是所有类型集的集合,即包含所有类型。
func Do[T interface{}](n T) T {
return n
}
func main() {
Do[struct{}](struct{}{})
Do[any]("abc")
}
不过我们一般会使用 any
来作为泛型形参,因为 inerface{}
不好看。
底层类型
当使用 type
关键字声明了一个新的类型时,即便其底层类型包含在类型集内,当传入时也依旧会无法通过编译。
type Int interface {
int8 | int16 | int | int32 | int64 | uint8 | uint16 | uint | uint32 | uint64
}
type TinyInt int8
func Do[T Int](n T) T {
return n
}
func main() {
Do[TinyInt](1) // 无法通过编译,即便其底层类型属于Int类型集的范围内
}
有两种解决办法,第一种是往类型集中并入该类型,但是这毫无意义,因为 TinyInt
与 int8
底层类型就是一致的,所以就有了第二种解决办法。
type Int interface {
int8 | int16 | int | int32 | int64 | uint8 | uint16 | uint | uint32 | uint64 | TinyInt
}
使用 ~
符号,来表示底层类型,如果一个类型的底层类型属于该类型集,那么该类型就属于该类型集,如下所示
type Int interface {
~int8 | ~int16 | ~int | ~int32 | ~int64 | ~uint8 | ~uint16 | ~uint | ~uint32 | ~uint64
}
修改过后就可以通过编译了。
func main() {
Do[TinyInt](1) // 可以通过编译,因为TinyInt在类型集Int内
}
注意点
泛型不能作为一个类型的基本类型
以下写法是错误的,泛型形参 T 是不能作为基础类型的
type GenericType[T int | int32 | int64] T
虽然下列的写法是允许的,不过毫无意义而且可能会造成数值溢出的问题,所以并不推荐
type GenericType[T int | int32 | int64] int
泛型类型无法使用类型断言
对泛型类型使用类型断言将会无法通过编译,泛型要解决的问题是 类型无关 的,如果一个问题需要根据不同类型做出不同的逻辑,那么就根本不应该使用泛型,应该使用 interface{}
或者 any
。
func Sum[T int | float64](a, b T) T {
ints,ok := a.(int) // 不被允许
switch a.(type) { // 不被允许
case int:
case bool:
...
}
return a + b
}
匿名结构不支持泛型
匿名结构体是不支持泛型的,如下的代码将无法通过编译
testStruct := struct[T int | string] {
Name string
Id T
}[int]{
Name: "jack",
Id: 1
}
匿名函数不支持自定义泛型
以下两种写法都将无法通过编译
var sum[T int | string] func (a, b T) T
sum := func[T int | string](a,b T) T{
...
}
但是可以 使用 已有的泛型类型,例如闭包中
func Sum[T int | float64](a, b T) T {
sub := func(c, d T) T {
return c - d
}
return sub(a,b) + a + b
}
不支持泛型方法
方法是不能拥有泛型形参的,但是 receiver
可以拥有泛型形参。如下的代码将会无法通过编译
type GenericStruct[T int | string] struct {
Name string
Id T
}
func (g GenericStruct[T]) name[S int | float64](a S) S {
return a
}
类型集无法作为类型实参
只要是带有类型集的接口,都无法当作类型实参。
type SignedInt interface {
int8 | int16 | int | int32 | int64
}
func Do[T SignedInt](n T) T {
return n
}
func main() {
Do[SignedInt](1) // 无法通过编译
}
类型集中的交集问题
对于非接口类型,类型并集中不能有交集,例如下例中的 TinyInt
与 ~int8
有交集。
type Int interface {
~int8 | ~int16 | ~int | ~int32 | ~int64 | ~uint8 | ~uint16 | ~uint | ~uint32 | ~uint64 | TinyInt // 无法通过编译
}
type TinyInt int8
但是对于接口类型的话,就允许有交集,如下例
type Int interface {
~int8 | ~int16 | ~int | ~int32 | ~int64 | ~uint8 | ~uint16 | ~uint | ~uint32 | ~uint64 | TinyInt // 可以通过编译
}
type TinyInt interface {
int8
}
类型集无法直接或间接的并入自身
以下示例中,Floats
直接的并入了自身,而 Double
又并入了 Floats
,所以又间接的并入了自身。
type Floats interface { // 代码无法通过编译
Floats | Double
}
type Double interface {
Floats
}
comparable
接口无法并入类型集
同样的,也无法并入类型约束中,所以基本上都是单独使用。
func Do[T comparable | Integer](n T) T { //无法通过编译
return n
}
type Number interface { // 无法通过编译
Integer | comparable
}
type Comparable interface { // 可以通过编译但是毫无意义
comparable
}
方法集无法并入类型集
任何包含方法的接口,都不能并入类型集合
type I interface {
int | fmt.Stringer // cannot use fmt.Stringer in union (fmt.Stringer contains methods)
}
但是它们可以做交集,不过这样做没有任何意义
type I interface {
int
fmt.Stringer
}
使用
数据结构是泛型最常见的使用场景,下面借由两个数据结构来展示下泛型如何使用。
队列
下面用泛型实现一个简单的队列,首先声明队列类型,队列中的元素类型可以是任意的,所以类型约束为 any
type Queue[T any] []T
总共只有四个方法 Pop
,Peek
,Push
,Size
,代码如下。
type Queue[T any] []T
func (q *Queue[T]) Push(e T) {
*q = append(*q, e)
}
func (q *Queue[T]) Pop(e T) (_ T) {
if q.Size() > 0 {
res := q.Peek()
*q = (*q)[1:]
return res
}
return
}
func (q *Queue[T]) Peek() (_ T) {
if q.Size() > 0 {
return (*q)[0]
}
return
}
func (q *Queue[T]) Size() int {
return len(*q)
}
在 Pop
和 Peek
方法中,可以看到返回值是 _ T
,这是具名返回值的使用方式,但是又采用了下划线 _
表示这是匿名的,这并非多此一举,而是为了表示泛型零值。由于采用了泛型,当队列为空时,需要返回零值,但由于类型未知,不可能返回具体的类型,借由上面的那种方式就可以返回泛型零值。也可以声明泛型变量的方式来解决零值问题,对于一个泛型变量,其默认的值就是该类型的零值,如下
func (q *Queue[T]) Pop(e T) T {
var res T
if q.Size() > 0 {
res = q.Peek()
*q = (*q)[1:]
return res
}
return res
}
堆
上面队列的例子,由于对元素没有任何的要求,所以类型约束为 any
。但堆就不一样了,堆是一种特殊的数据结构,它可以在 O(1) 的时间内判断最大或最小值,所以它对元素有一个要求,那就是必须是可以排序的类型,但内置的可排序类型只有数字和字符串,所以在堆的初始化时,需要传入一个自定义的比较器,比较器由调用者提供,并且比较器也必须使用泛型,如下
type Comparator[T any] func(a, b T) int
下面是一个简单二项堆的实现,先声明泛型结构体,依旧采用 any
进行约束,这样可以存放任意类型
type Comparator[T any] func(a, b T) int
type BinaryHeap[T any] struct {
s []T
c Comparator[T]
}
几个方法实现
func (heap *BinaryHeap[T]) Peek() (_ T) {
if heap.Size() > 0 {
return heap.s[0]
}
return
}
func (heap *BinaryHeap[T]) Pop() (_ T) {
size := heap.Size()
if size > 0 {
res := heap.s[0]
heap.s[0], heap.s[size-1] = heap.s[size-1], heap.s[0]
heap.s = heap.s[:size-1]
heap.down(0)
return res
}
return
}
func (heap *BinaryHeap[T]) Push(e T) {
heap.s = append(heap.s, e)
heap.up(heap.Size() - 1)
}
func (heap *BinaryHeap[T]) up(i int) {
if heap.Size() == 0 || i < 0 || i >= heap.Size() {
return
}
for parentIndex := i>>1 - 1; parentIndex >= 0; parentIndex = i>>1 - 1 {
// greater than or equal to
if heap.compare(heap.s[i], heap.s[parentIndex]) >= 0 {
break
}
heap.s[i], heap.s[parentIndex] = heap.s[parentIndex], heap.s[i]
i = parentIndex
}
}
func (heap *BinaryHeap[T]) down(i int) {
if heap.Size() == 0 || i < 0 || i >= heap.Size() {
return
}
size := heap.Size()
for lsonIndex := i<<1 + 1; lsonIndex < size; lsonIndex = i<<1 + 1 {
rsonIndex := lsonIndex + 1
if rsonIndex < size && heap.compare(heap.s[rsonIndex], heap.s[lsonIndex]) < 0 {
lsonIndex = rsonIndex
}
// less than or equal to
if heap.compare(heap.s[i], heap.s[lsonIndex]) <= 0 {
break
}
heap.s[i], heap.s[lsonIndex] = heap.s[lsonIndex], heap.s[i]
i = lsonIndex
}
}
func (heap *BinaryHeap[T]) Size() int {
return len(heap.s)
}
使用起来如下
type Person struct {
Age int
Name string
}
func main() {
heap := NewHeap[Person](10, func(a, b Person) int {
return cmp.Compare(a.Age, b.Age)
})
heap.Push(Person{Age: 10, Name: "John"})
heap.Push(Person{Age: 18, Name: "mike"})
heap.Push(Person{Age: 9, Name: "lili"})
heap.Push(Person{Age: 32, Name: "miki"})
fmt.Println(heap.Peek())
fmt.Println(heap.Pop())
fmt.Println(heap.Peek())
}
输出
{9 lili}
{9 lili}
{10 John}
有泛型的加持,原本不可排序的类型传入比较器后也可以使用堆了,这样做肯定比以前使用 interface{}
来进行类型转换和断言要优雅和方便很多。
对象池
原版对象池只能用 any
类型,每次取出来都要进行类型断言,用泛型简单改造后,就可以省去这个工作。
package main
import (
"bytes"
"fmt"
"sync"
)
func NewPool[T any](newFn func() T) *Pool[T] {
return &Pool[T]{
pool: &sync.Pool{
New: func() interface{} {
return newFn()
},
},
}
}
type Pool[T any] struct {
pool *sync.Pool
}
func (p *Pool[T]) Put(v T) {
p.pool.Put(v)
}
func (p *Pool[T]) Get() T {
var v T
get := p.pool.Get()
if get != nil {
v, _ = get.(T)
}
return v
}
func main() {
bufferPool := NewPool(func() *bytes.Buffer {
return bytes.NewBuffer(nil)
})
for range 100 {
buffer := bufferPool.Get()
buffer.WriteString("Hello, World!")
fmt.Println(buffer.String())
buffer.Reset()
bufferPool.Put(buffer)
}
}
小结
go 的一大特点就是编译速度非常快,编译快是因为编译期做的优化少,泛型的加入会导致编译器的工作量增加,工作更加复杂,这必然会导致编译速度变慢,事实上当初 go1.18 刚推出泛型的时候确实导致编译更慢了,go 团队既想加入泛型又不想太拖累编译速度,开发者用的顺手,编译器就难受,反过来编译器轻松了(最轻松的当然是直接不要泛型),开发者就难受了,现如今的泛型就是这两者之间妥协后的产物。