一切从函数的形参和实参说起

假设我们有个计算两数之和的函数:

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

这个函数很简单,但是它有个问题——无法计算 int 类型之外的和。如果我们想计算浮点或者字符串的和该怎么办?解决办法之一就是像下面这样为不同类型定义不同的函数

func AddFloat32(a float32, b float32) float32 {
    return a + b
}

func AddString(a string, b string) string {
    return a + b
}

可是除此之外还有没有更好的方法?答案是有的,我们可以来回顾下函数的 形参(parameter)实参(argument) 这一基本概念:

func Add(a int, b int) int {
    // 变量a,b是函数的形参   "a int, b int" 这一串被称为形参列表
    return a + b
}

Add(100,200) // 调用函数时,传入的100和200是实参

我们知道,函数的 形参(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
}

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

可能你会已奇怪,我通过 Go 的 接口+反射 不也能实现这样的动态数据处理吗?是的,泛型能实现的功能通过 接口+反射 也基本能实现。但是使用过反射的人都知道反射机制有很多问题:

  • 用起来麻烦

  • 失去了编译时的类型检查,不仔细写容易出错

  • 性能不太理想

而在泛型适用的时候,它能解决上面这些问题。但这也不意味着泛型是万金油,泛型有着自己的适用场景,当你疑惑是不是该用泛型的话,请记住下面这条经验:

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

Go的泛型

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

  • 类型形参 (Type parameter)

  • 类型实参(Type argument)

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

  • 类型约束(Type constraint)

  • 实例化(Instantiations)

  • 泛型类型(Generic type)

  • 泛型接收器(Generic receiver)

  • 泛型函数(Generic function)

首先从 泛型类型(generic type) 讲起。