泛型receiver

看了上的例子,你一定会说,介绍了这么多复杂的概念,但好像泛型类型根本没什么用处啊?

是的,单纯的泛型类型实际上对开发来说用处并不大。但是如果将泛型类型和接下来要介绍的泛型 receiver 相结合的话,泛型就有了非常大的实用性了。

我们知道,定义了新的普通类型之后可以给类型添加方法。那么可以给泛型类型添加方法吗?答案自然是可以的,如下:

type MySlice[T int | float32] []T

func (s MySlice[T]) Sum() T {
    var sum T
    for _, value := range s {
        sum += value
    }
    return sum
}

这个例子为泛型类型 MySlice[T] 添加了一个计算成员总和的方法 Sum() 。注意观察这个方法的定义:

  • 首先看 receiver (s MySlice[T]) ,所以我们直接把类型名称 MySlice[T] 写入了 receiver

  • 然后方法的返回参数我们使用了类型形参 T (实际上如果有需要的话,方法的接收参数也可以实用类型形参)

  • 在方法的定义中,我们也可以使用类型形参 T (在这个例子里,我们通过 var sum T 定义了一个新的变量 sum )

对于这个泛型类型 MySlice[T] 我们该如何使用?还记不记得之前强调过很多次的,泛型类型无论如何都需要先用类型实参实例化,所以用法如下:

var s MySlice[int] = []int{1, 2, 3, 4}
fmt.Println(s.Sum()) // 输出:10

var s2 MySlice[float32] = []float32{1.0, 2.0, 3.0, 4.0}
fmt.Println(s2.Sum()) // 输出:10.0

该如何理解上面的实例化?首先我们用类型实参 int 实例化了泛型类型 MySlice[T],所以泛型类型定义中的所有 T 都被替换为 int,最终我们可以把代码看作下面这样:

type MySlice[int] []int // 实例化后的类型名叫 MyIntSlice[int]

// 方法中所有类型形参 T 都被替换为类型实参 int
func (s MySlice[int]) Sum() int {
    var sum int
    for _, value := range s {
        sum += value
    }
    return sum
}

float32 实例化和用 int 实例化同理,此处不再赘述。

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

  • 为每种类型写一个实现

  • 使用 接口+反射

而有了泛型之后,我们就能非常简单地创建通用数据结构了。接下来用一个更加实用的例子 —— 队列 来讲解

基于泛型的队列

队列是一种先入先出的数据结构,它和现实中排队一样,数据只能从队尾放入、从队首取出,先放入的数据优先被取出来。

// 这里类型约束使用了空接口,代表的意思是所有类型都可以用来实例化泛型类型 Queue[T] (关于接口在后半部分会详细介绍)
type Queue[T interface{}] struct {
    elements []T
}

// 将数据放入队列尾部
func (q *Queue[T]) Put(value T) {
    q.elements = append(q.elements, value)
}

// 从队列头部取出并从头部删除对应数据
func (q *Queue[T]) Pop() (T, bool) {
    var value T
    if len(q.elements) == 0 {
        return value, true
    }

    value = q.elements[0]
    q.elements = q.elements[1:]
    return value, len(q.elements) == 0
}

// 队列大小
func (q Queue[T]) Size() int {
    return len(q.elements)
}

为了方便说明,上面是队列非常简单的一种实现方法,没有考虑线程安全等很多问题

Queue[T] 因为是泛型类型,所以要使用的话必须实例化,实例化与使用方法如下所示:

var q1 Queue[int]  // 可存放int类型数据的队列
q1.Put(1)
q1.Put(2)
q1.Put(3)
q1.Pop() // 1
q1.Pop() // 2
q1.Pop() // 3

var q2 Queue[string]  // 可存放string类型数据的队列
q2.Put("A")
q2.Put("B")
q2.Put("C")
q2.Pop() // "A"
q2.Pop() // "B"
q2.Pop() // "C"

var q3 Queue[struct{Name string}]
var q4 Queue[[]int] // 可存放[]int切片的队列
var q5 Queue[chan int] // 可存放int通道的队列
var q6 Queue[io.Reader] // 可存放接口的队列
// ......

动态判断变量的类型

使用接口的时候经常会用到类型断言或 type switch 来确定接口具体的类型,然后对不同类型做出不同的处理,如:

var i interface{} = 123
i.(int) // 类型断言

// type switch
switch i.(type) {
    case int:
        // do something
    case string:
        // do something
    default:
        // do something
    }
}

那么你一定会想到,对于 value T 这样通过类型形参定义的变量,我们能不能判断具体类型然后对不同类型做出不同处理呢?答案是不允许的,如下:

func (q *Queue[T]) Put(value T) {
    value.(int) // 错误。泛型类型定义的变量不能使用类型断言

    // 错误。不允许使用type switch 来判断 value 的具体类型
    switch value.(type) {
    case int:
        // do something
    case string:
        // do something
    default:
        // do something
    }

    // ...
}

虽然 type switch 和类型断言不能用,但我们可通过反射机制达到目的:

func (receiver Queue[T]) Put(value T) {
    // Printf() 可输出变量value的类型(底层就是通过反射实现的)
    fmt.Printf("%T", value)

    // 通过反射可以动态获得变量value的类型从而分情况处理
    v := reflect.ValueOf(value)

    switch v.Kind() {
    case reflect.Int:
        // do something
    case reflect.String:
        // do something
    }

    // ...
}

这看起来达到了我们的目的,可是当你写出上面这样的代码时候就出现了一个问题:

你为了避免使用反射而选择了泛型,结果到头来又为了一些功能在在泛型中使用反射。

当出现这种情况的时候你可能需要重新思考一下,自己的需求是不是真的需要用泛型(毕竟泛型机制本身就很复杂了,再加上反射的复杂度,增加的复杂度并不一定值得)

当然,这一切选择权都在你自己的手里,根据具体情况斟酌。

泛型函数

在介绍完泛型类型和泛型 receiver 之后,我们来介绍最后一个可以使用泛型的地方——泛型函数。有了上面的知识,写泛型函数也十分简单。假设我们想要写一个计算两个数之和的函数:

func Add(a int, b int) int {
    return a + b
}

这个函数理所当然只能计算 int 的和,而浮点的计算是不支持的。这时候我们可以像下面这样定义一个泛型函数:

func Add[T int | float32 | float64](a T, b T) T {
    return a + b
}

上面就是泛型函数的定义。

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

它和普通函数的点不同在于函数名之后带了类型形参。这里的类型形参的意义、写法和用法因为与泛型类型是一模一样的,就不再赘述了。

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

Add[int](1,2) // 传入类型实参int,计算结果为 3
Add[float32](1.0, 2.0) // 传入类型实参float32, 计算结果为 3.0

Add[string]("hello", "world") // 错误。因为泛型函数Add的类型约束中并不包含string

或许你会觉得这样每次都要手动指定类型实参太不方便了。所以Go还支持类型实参的自动推导:

Add(1, 2)  // 1,2是int类型,编译请自动推导出类型实参T是int
Add(1.0, 2.0) // 1.0, 2.0 是浮点,编译请自动推导出类型实参T是float32

自动推导的写法就好像免去了传入实参的步骤一样,但请记住这仅仅只是编译器帮我们推导出了类型实参,实际上传入实参步骤还是发生了的。

匿名函数不支持泛型

在 Go 中我们经常会使用匿名函数,如:

fn := func(a, b int) int {
    return a + b
}  // 定义了一个匿名函数并赋值给 fn

fmt.Println(fn(1, 2)) // 输出: 3

那么 Go 支不支持匿名泛型函数呢?答案是不能——匿名函数不能自己定义类型形参

// 错误,匿名函数不能自己定义类型实参
fnGeneric := func[T int | float32](a, b T) T {
    return a + b
}

fmt.Println(fnGeneric(1, 2))

但是匿名函数可以使用别处定义好的类型实参,如:

func MyFunc[T int | float32 | float64](a, b T) {

    // 匿名函数可使用已经定义好的类型形参
    fn2 := func(i T, j T) T {
        return i*2 - j*2
    }

    fn2(a, b)
}

既然支持泛型函数,那么泛型方法呢?

既然函数都支持泛型了,那你应该自然会想到,方法支不支持泛型?很不幸,目前 Go 的方法并不支持泛型,如下:

type A struct {
}

// 不支持泛型方法
func (receiver A) Add[T int | float32 | float64](a T, b T) T {
    return a + b
}

最主要的原因是和接口的设计冲突。

如果成员方法可传的类型参数是动态的,很难去判断这个结构体的泛型方法是否实现了某种泛类型签名的接口。

但是因为 receiver 支持泛型,所以如果想在方法中使用泛型的话,目前唯一的办法就是曲线救国,迂回地通过 receiver 使用类型形参:

type A[T int | float32 | float64] struct {
}

// 方法可以使用类型定义中的形参 T
func (receiver A[T]) Add(a T, b T) T {
    return a + b
}

// 用法:
var a A[int]
a.Add(1, 2)

var aa A[float32]
aa.Add(1.0, 2.0)

小结

讲完了泛型类型、泛型 receiver、泛型函数后,Go 的泛型算是介绍完一半多了。在这里我们做一个概念的小结:

  1. Go 的泛型(或者或类型形参)目前可使用在3个地方

    • 泛型类型 - 类型定义中带类型形参的类型

    • 泛型 receiver - 泛型类型的 receiver

    • 泛型函数 - 带类型形参的函数

  2. 为了实现泛型,Go 引入了一些新的概念:

    • 类型形参

    • 类型形参列表

    • 类型实参

    • 类型约束

    • 实例化 - 泛型类型不能直接使用,要使用的话必须传入类型实参进行实例化

后面将介绍 Go 引入泛型后对接口做出的重大调整。