Golang 泛型入门(译)

原文: https://go.dev/doc/tutorial/generics

本教程介绍 Go 语言泛型(Generics)的基础知识. 通过使用泛型, 你可以声明并使用一些特殊的函数(functions)或者数据类型(types), 它们可以对某些类型的集合中任意一个类型生效.

在本篇教程中, 你将会声明两个“非泛型(non-generics)”的函数, 然后将其中相同的逻辑迁移到使用泛型实现的单个函数中.

你将会按照以下几个小节, 循序渐进地进行学习:

  1. 为你的代码创建目录
  2. 添加非泛型函数
  3. 添加一个泛型函数来处理多种数据类型
  4. 在调用泛型函数时移除类型实参
  5. 声明一个类型约束(type constraint)

Note: 其他教程, 见 Tutorials

Note: 你可以根据喜好, 选择使用 Go playground 来编辑和运行你的代码.


前提条件

  • 已安装 Go 语言 1.18 或更新版本. 更多安装说明, 见 Installing Go
  • 代码编辑器. 任何你已有的文本编辑器都可以满足需求.
  • 命令行终端. Go 可以在 Linux 或 Mac 终端正常工作, 也可以在 Windows 的 PowerShell 或 cmd 中运行.


为你的代码创建目录

为了开始编写代码, 你需要先创建一个目录.

1.打开命令行, 进入到 Home 目录

在 Linux 或者 Mac 上执行如下命令:

1
$ cd

或在 Windows 上执行以下命令:

1
C:\> cd %HOMEPATH%

下文将仅使用 $ 表示命令提示符. 所有命令在 Windows 系统也都同样可以运行.

2.在命令提示符后面输入命令, 为代码创建一个目录

1
2
$ mkdir generics
$ cd generics

3.创建一个模块用于组织代码

1
2
$ go mod init example/generics
go: creating new go.mod: module example/generics

Note: 在生产代码中, 最好根据你的需求指定一个更具体的模块路径(module path). 更多详情, 请务必阅读 依赖管理.

下一步, 你将要添加一些和 map 类型相关的简单代码


添加非泛型函数

在本小节中, 你将要添加两个函数, 这两个函数的逻辑都是将 map 中的所有值累加并返回结果.

之所以需要添加两个函数, 而不是一个, 是因为这两个函数需要用于两个不同类型的 map: 一个存储的是 int64 类型的值, 一个存储的是 float64 类型的值.

编写代码

1.在刚刚创建好的目录中, 使用文本编辑器创建一个名为 main.go 的文件. 在这个文件中编写你的代码.
2.在 main.go 文件的首行, 粘贴如下 package(包) 声明:

1
package main

独立运行的程序(相较于库(library)来说)都需要在 main 包里面.

3.在 package 声明下方, 粘贴如下两个函数声明的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// SumInts 将 m 中的所有值进行累加.
func SumInts(m map[string]int64) int64 {
var s int64
for _, v := range m {
s += v
}
return s
}

// SumFloats 对 m 中的所有值进行累加.
func SumFloats(m map[string]float64) float64 {
var s float64
for _, v := range m {
s += v
}
return s
}

在这段代码中, 你进行了以下几个操作:

  • 声明了两个函数, 对 map 中的值进行累加并返回结果
    • SumFloats 接收一个 key 为 string 类型, 值为 float64 类型的 map
    • SumInts 接收一个 key 为 string 类型, 值为 int64 类型的 map

4.在 main.go 文件中, 紧跟着 package 声明的下方, 插入如下代码. 这段代码实现了一个 main 函数, 其中初始化了两个 map 对象, 并且在调用上一步中声明的两个函数时, 分别将这两个 map 对象作为参数传入.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func main() {
// 初始化一个值为整型的 map
ints := map[string]int64{
"first": 34,
"second": 12,
}

// 初始化一个值为浮点型的 map
floats := map[string]float64{
"first": 35.98,
"second": 26.99,
}

fmt.Printf("Non-Generic Sums: %v and %v\n",
SumInts(ints),
SumFloats(floats))
}

这段代码中, 你进行了以下几个操作:

  • 初始化了一个值为 float64 类型的 map 对象, 和一个值为 int64 类型的 map 对象, 它们各自有两条记录(entries).
  • 对你之前声明的这两个函数进行调用, 分别对两个 map 对象的值求和
  • 打印结果

5.在 main.go 文件顶部, 紧挨着 package 声明的位置, 导入你刚刚在代码中使用对 package.
前几行代码如下:

1
2
3
package main

import "fmt"

6.保存 main.go 文件

运行代码

打开命令行, 在 main.go 文件的目录下执行如下命令运行代码:

1
2
$ go run .
Non-Generic Sums: 46 and 62.97

通过使用泛型, 你可以仅仅写一个函数来替代上面的两个函数. 接下来, 你将会学习添加一个简单的泛型函数, 用于处理 map 类型的对象, 无论它包含的值整型还是浮点型.


添加一个泛型函数来处理多种数据类型

在本小节, 你将向代码中添加一个泛型函数, 接收一个 map 类型作为参数, 而 map 中的值既可以是整型, 也可以是浮点型, 以此来有效地使用一个单独函数替换刚刚创建的两个函数.

为了支持两种数据类型中的任意一个, 这个单独的函数需要使用一种方式来声明它可以支持的类型. 换句话说, 调用的代码需要通过一种方式来知道它接收的是整型还是浮点型.

为了实现这个功能, 就需要在函数原有形参的基础上额外添加 类型形参(type parameters). 这些类型形参让原本的函数获得了通用性, 使其可以接受不同类型的实参(arguments). 在调用这个函数的时候, 也需要带上 类型实参(type arguments) 和函数原本的实参.

每个类型形参都有一个类型约束(type constraint), 它的作用就像是这个类型形参的元类型(meta-type). 每个类型约束都指定了函数调用时每个类型形参允许对应传入的类型实参.

尽管类型形参的约束一般都代表着一系列类型的集合, 但是在编译时类型形参只能表示一种类型 —— 即函数调用时提供的类型实参的类型. 如果类型实参的类型不被类型约束所允许, 代码则将不能进行编译.

一定要注意的是, 一个类型形参一定要支持通用代码对它进行的所有操作. 举个例子, 如果你的函数代码尝试对类型形参执行 string 类型的操作(比如索引取值), 然而类型形参的类型约束包含数字类型, 代码将不能进行编译.

在接下来要写的代码中, 将要使用一个既允许整型, 也允许浮点型的类型约束.

编写代码

1.在你之前添加的两个函数下方, 粘贴下面的泛型函数代码:

1
2
3
4
5
6
7
8
// SumIntsOrFloats 对 map 对象 m 的值进行求和, 同时支持值为 int64 和 float64 类型的 map
func SumIntsOrFloats[K comparable, V int64 | float64](m map[K]V) V {
var s V
for _, v := range m {
s += v
}
return s
}

在这段代码中, 你进行了以下几个操作:

  • 声明了一个 SumIntsOrFloats 函数, 它有两个类型形参(在方括号中) KV, 和一个使用了类型形参的函数形参, map[K]V 类型的 m. 函数返回一个类型为 V 的值.
  • 为类型形参 K 指定了类型约束 comparable. 为了这样的场景, Go 语言中专门预先声明了 comparable 约束. 任何一个类型, 如果它的值可以作为比较操作符 ==!= 的操作数, 那么它都受到约束的认可. Go 语言规定 map 类型的 key 是 comparable (可比较) 的. 所以为了能够使用 K 作为 map 变量的 key, 有必要将 K 声明为 comparable. 同时它也确保了代码中使用的类型是可以作为 map 类型的 key 的.
  • 为类型形参 V 指定了一个由两个类型的并集组成的类型约束, 这两个类型分别是: int64float64. 使用 | 指定两个类型的并集, 意味着这个类型约束接受这两个类型中的任意一个. 在调用代码时, 使用这两个类型中任意一个类型的值作为实参, 编译器都是允许的.
  • 指定 m 参数为 map[K]V 类型, 而 KV 已经在类型形参中指定了. 注意, 由于 K 是一个 comparable 类型, 所以我们知道 map[K]V 是一个合法的 map 类型. 如果我们没有声明 K 是 comparable 类型, 那么编译器将拒绝对 map[K]V 的引用.

2.在 main.go 文件中, 向已有的代码下方继续粘贴如下代码:

1
2
3
fmt.Printf("Generic Sums: %v and %v\n",
SumIntsOrFloats[string, int64](ints),
SumIntsOrFloats[string, float64](floats))

在这段代码中, 你进行了以下几个操作:

  • 调用了你刚才声明的泛型函数, 分别将你创建的两个 map 对象作为实参传入.
  • 指定了类型实参 —— 方括号中的类型名称 —— 明确了你调用代码时需要替换类型形参的数据类型. 你将在下一小节看到, 在函数调用时类型实参通常可以被省略. Go 通常可以通过你的代码来推断出类型实参.
  • 打印函数返回的值.

运行代码

1
2
3
$ go run .
Non-Generic Sums: 46 and 62.97
Generic Sums: 46 and 62.97

为了使你的代码可以运行, 编译器在每次函数调用时都会使用你指定的具体数据类型来替换类型形参.

在调用你实现的泛型函数时, 你通过指定类型实参, 来告诉编译器使用哪些类型来替换函数的类型形参. 你将在下一节看到, 在很多场景下, 由于编译器可以推断出类型实参, 因此你可以省略它们.


在调用泛型函数时移除类型实参

在本小节, 你将会添加一个修改版本的泛型函数调用, 通过一点小小的改动使代码更加简单. 你将要移除在这个场景下并不需要的类型实参.

注意, 类型实参并不总是可以省略的. 举个例子, 如果你需要调用一个没有实参的泛型函数, 你就需要在进行函数调用时带上类型实参.

编写代码

  • 在 main.go 文件中, 向已有代码的下方粘贴如下代码:
1
2
3
fmt.Printf("Generic Sums, type parameters inferred: %v and %v\n",
SumIntsOrFloats(ints),
SumIntsOrFloats(floats))

在这段代码中, 你进行了以下操作:

  • 调用了泛型函数, 省略了类型实参

运行代码

1
2
3
4
$ go run .
Non-Generic Sums: 46 and 62.97
Generic Sums: 46 and 62.97
Generic Sums: type parameters inferred: 46 and 62.97

接下来, 你将会把整型和浮点型的并集, 保存成一个可复用的类型约束, 使其在其他代码中也可以使用.


声明一个类型约束

1.在 main 函数和 import 语句之间, 粘贴如下代码, 声明一个类型约束:

1
2
3
type Number interface {
int64 | float64
}

在这段代码中, 你进行了以下操作:

  • 声明了可以作为类型约束使用的 Number interface 类型
  • 在 interface 内部声明了 int64float64 的并集

    本质上, 你是将函数声明中的类型并集, 转移到了一个新的类型约束中. 通过这种方式, 在你想要约束一个 int64 或者 float64 类型的时候, 你可以使用 Number 类型约束来替代 int64 | float64 的写法.

2.在已有的函数下方, 添加如下的 SumNumbers 泛型函数

1
2
3
4
5
6
7
func SumNumbers[K comparable, V Number](m map[K]V) V {
var s V
for _, v := range m {
s += v
}
return s
}

在这段代码中, 你进行了以下操作:

  • 声明了一个新的泛型函数, 逻辑和之前声明的泛型函数相同, 但是类型约束使用了新的 interface 类型来替代原来的并集. 和之前一样, 为参数和返回值使用了类型形参.

3.在 main.go 文件中, 向已有代码的下方粘贴如下代码:

1
2
3
fmt.Printf("Generic Sums with Constraint: %v and %v\n",
SumNumbers(ints),
SumNumbers(floats))

在这段代码中, 你进行了以下操作:

  • 调用 SumNumbers 函数, 并分别使用两个 map 对象作为实参, 并打印求和结果.

和之前的小节一样, 在调用泛型函数时, 你省略了类型实参(方括号中的类型名称). Go 编译器可以根据其他实参推断类型实参.

运行代码

打开命令行, 在 main.go 文件所在的目录中, 运行如下命令:

1
2
3
4
5
$ go run .
Non-Generic Sums: 46 and 62.97
Generic Sums: 46 and 62.97
Generic Sums, type parameters inferred: 46 and 62.97
Generic Sums with Constraint: 46 and 62.97


总结

干得漂亮! 你刚刚已经认识了 Go 泛型.

下一步的建议:


完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
package main

import "fmt"

type Number interface {
int64 | float64
}

func main() {
// Initialize a map for the integer values
ints := map[string]int64{
"first": 34,
"second": 12,
}

// Initialize a map for the float values
floats := map[string]float64{
"first": 35.98,
"second": 26.99,
}

fmt.Printf("Non-Generic Sums: %v and %v\n",
SumInts(ints),
SumFloats(floats))

fmt.Printf("Generic Sums: %v and %v\n",
SumIntsOrFloats[string, int64](ints),
SumIntsOrFloats[string, float64](floats))

fmt.Printf("Generic Sums, type parameters inferred: %v and %v\n",
SumIntsOrFloats(ints),
SumIntsOrFloats(floats))

fmt.Printf("Generic Sums with Constraint: %v and %v\n",
SumNumbers(ints),
SumNumbers(floats))
}

// SumInts adds together the values of m.
func SumInts(m map[string]int64) int64 {
var s int64
for _, v := range m {
s += v
}
return s
}

// SumFloats adds together the values of m.
func SumFloats(m map[string]float64) float64 {
var s float64
for _, v := range m {
s += v
}
return s
}

// SumIntsOrFloats sums the values of map m. It supports both floats and integers
// as map values.
func SumIntsOrFloats[K comparable, V int64 | float64](m map[K]V) V {
var s V
for _, v := range m {
s += v
}
return s
}

// SumNumbers sums the values of map m. Its supports both integers
// and floats as map values.
func SumNumbers[K comparable, V Number](m map[K]V) V {
var s V
for _, v := range m {
s += v
}
return s
}

本篇内容是对技术文章翻译的第一次完整尝试, 如有错误, 欢迎邮件指正, 也欢迎邮件交流关于翻译的不同见解.
另外, 通常情况下 parameter 指函数声明时的形式参数名称, argument 指函数调用时传入的实际参数. 但在本文中, argument 同时用与描述函数声明和函数调用的参数, 虽然两个单词都可以翻译为参数, 但无法直接体现出区别; 然而, 如果按照 parameter —— 形参, argument —— 实参 对应翻译, 似乎又不太正确. 这一点暂时存疑, 有待进一步的探究.