Go 泛型简明教程

Last Modified: 2023/12/01

什么是泛型

泛型是一种编写与特定类型无关的代码的方式。在千呼万唤中,终于在 Go 1.18 开始支持泛型。既然是千呼万唤,想必很多人都需要该特性,所以如果你还不知道,不妨看一下这个简明教程。

为什么需要泛型

试想有一个需求,要求在找数组中是否包含某个元素,在 Go 1.18 之前,你是怎么做的?以 Int 数组为例,你应该是这样做的:

func ContainsInt(arr []int, val int) bool {
	for _, v := range arr {
		if v == val {
			return true
		}
	}
	return false
}

注: 严格来说 arr 是 slice,不是数组,使用数组更容易表达,更容易带入其它语言。

这个有什么问题吗?看起来没问题,但是如果有一天你需要支持 int8,你该如何,于是你又轻车熟路的写下了下面的代码:

func ContainsInt8(arr []int8, val int8) bool {
	for _, v := range arr {
		if v == val {
			return true
		}
	}
	return false
}

这个函数和之前的函数除了接收的类型不同,具体实现可以说是一模一样,这显然不够 DRY 不够 CLEAN 啊。如果我们能定义一个函数同时支持 int 和 int8 该有多好?

这不正是泛型的要解决的根本问题吗? 那么接下来就让我们使用泛型来重构之前的代码。

使用泛型重构

func Contains[T interface{int|int8}](arr []T, val T) bool {
  // 实现和之前的一模一样,篇幅关系直接省略
}

可以看到 Contains 函数的类型不再是具体的 int 或者 int8,而是一个泛型 T。T 则进一步被 interface{int|int8} 所约束。这个类型约束的含义是: T 只能是 int 或者 int8。

Go 语言是独一无二的,Go 语言中泛型形参定义也是与众不同,其他语言中通常使用尖括号,但是 Go 却使用了 [],好与不好,大家自行评判。

调用 Contains 时,可以指定具体的类型,某些情况下也可以依赖类型推断。

// 不依赖类型推断,调用时指定具体的类型
Contains[int]([]int{1, 2}, 1)
Contains[int8]([]int8{1, 2}, 1)
// 依赖类型推断,根据参数类型,能够自动推断出T的类型
Contains([]int{1, 2}, 1)
Contains([]int8{1, 2}, 1)

类型约束

类型约束必须是 interface,interface 有两种视角:

  • 第一种视角将 interface 看成方法的集合,任何类型只要实现了这些方法便实现了该 interface;
  • 另一种视角将 interface 看成类型的集合,集合中的每个类型均实现了 interface 中定义的方法。当然 interface 也可以不包含任何方法,例如上面的 interface{int|int8} 便不包含任何方法。

将 interface 看成类型集合更符合“泛型约束”。一个类型是否满足约束,就要看这个类型是否在 interface 规定的类型集合中。

由于类型约束的写法有点啰嗦,因此 Go 提供了语法糖,可以使用下面的简化写法:

func Contains[T int|int8](arr []T, val T) bool {
  // ...
}

我们知道 Go 是支持类型别名的,如果我们给 int 定义一个别名:type MyInt int,传入 MyInt 类型的参数,编译器就会抱怨 Myint 不符合类型约束。这显然不是我们想要的丫,因为 MyInt 本质上也是 int。

为了解决这个问题,Go 提供了一个新的符号 ~~int 表示任何底层类型为 int 的类型都可以满足约束。于是我们进一步将泛型方法优化如下:

func Contains[T ~int|~int8](arr []T, val T) bool {
  // ...
}
type MyInt int
type MyInt2 MyInt
// 由于 MyInt 和 MyInt2 底层类型都是 int,因此都可以使用 Contains 方法
Contains([]MyInt{1, 2}, 1)
Contains([]MyInt2{1, 2}, 1)

让 Contains 支持更多的类型

除了 int 和 int8,还有 int16, int32 和 int64,让我们把他们都写上看看。

func Contains[T ~int|~int8|~int16|~int32|~int64](arr []T, val T) bool {
  // ...
}

这还不算太糟,但是如果再算上 uint、uint8、uint16、uint32、uint64 和 uintptr,那必然是又臭又长。

前面说到类型约束其实是一个 interface,那么就让我们自定义一个 interface 类型来表代替这个又臭又长的写法:

type Signed interface {
	~int|~int8|~int16|~int32|~int64
}
func Contains[T Signed](arr []T, val T) bool {
  // ...
}

如果要再支持 uint 系列类型,只需要再定义一个 Unsigned 类型,然后将 Signed 和 Unsigned 组合起来就可以了。

type Unsigned interface {
	~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}
// 再修改下 Contains 方法
func Contains[T Signed|Unsigned](arr []T, val T) bool {
  // ...
}

注:其实我这里定义 Signed 和 Unsigned 类型只是为了讲解泛型的用法,其实这两个类型在 golang.org/x/exp/constraints 包中已经定义了,只需要导入这个包,这两个类型就可以直接使用了。

能更进一步吗?string 也是支持比较的,如果要支持 string 类型呢?相信已经难不到你了,只需要将 string 加入类型约束即可

func Contains[T Signed|Unsigned|~string](arr []T, val T) bool {
	// ...
}

到这里我们停下来想一下,Contains 方法的目的为了找一个数组中是否包含某个值,任何支持比较的类型都应该得到支持应该很合理吧? Go 语言中正好有这么一个 interface 叫 comparable,我们可以直接使用这个 comparable 类型。

这里有个有趣的问题,可比较类型的值可以使用 ==!= 运算符,但却不一定支持 >< 运算符。支持 >< 运算符的类型是可排序的类型。需要注意要区分可比较和可排序。

更完美的实现

这便是我们使用 comparable 的终极实现。

func Contains[T comparable](arr []T, val T) bool {
	// ...
}

小结

我们借用 Contains 方法的实现,在一步一步改进的过程中逐步介绍了 Go 泛型的基本概念和基本用法。以此为引,让大家感受泛型的实例和魅力,如果对我的文章感兴趣可以移步 verytools 查看我的更多文章。

有问题吗?点此反馈!

温馨提示:反馈需要登录