0%

Golang 泛型入门指南

Go 语言中的泛型是指一种语言特性,允许创建可以处理不同类型的函数、数据结构和接口。换句话说,泛型使得可以创建不受特定类型或数据结构限制的代码。如果我们此前有使用 Java 或者 C++ 的经验,那么会很好理解。

在 Go 语言引入泛型之前,开发人员必须编写多个函数来处理不同类型的数据。这种方法通常很繁琐,并导致代码重复。有了泛型,开发人员可以编写更简洁和可重用的代码,可以处理不同类型的数据。

Go 语言中的泛型是在 2021 年 2 月发布的 1.18 版本中引入的。Go 语言中的泛型实现是基于类型参数的概念。类型参数是传递给函数或数据结构的类型的占位符,使它们能够处理不同类型的数据。

Go 中的泛型是什么?

泛型是一种代码,允许我们通过改变函数类型来在各种函数中使用它们。泛型的创建是为了使代码独立于类型和函数。

泛型的主要目的是通过添加更少的代码行来实现更大的灵活性。

为了更好地理解,看下面的例子。我们创建一个打印任何类型参数的函数,就像这样:

1
2
3
4
5
func Print(s[] string) {
for _, v := range s {
fmt.Print(v)
}
}

现在,我们突然希望打印一个整数,所以我们相应地改变了代码。

1
2
3
4
5
func Print(s[] int) {
for _, v := range s {
fmt.Print(v)
}
}

但是每次像这样更改代码可能看起来令人生畏,这就是泛型发挥作用的地方。通过将任何类型分配给其泛型形式,我们可以将相同的代码用于不同的函数。看一下这个:

1
2
3
4
5
func Print[T any](s[] T) {
for _, v := range s {
fmt.Print(v)
}
}

在这里,我们将 "T" 定义为 any 类型。这个任意类型允许我们在同一个函数中解析不同类型的变量。S 是相应的变量,它是 T 类型的一个切片。现在,调用该方法,我们可以在同一个函数中打印一个字符串和一个整数。

1
2
3
4
5
6
func main() {
str := []string{"Hello", "Again Hello"}
intArray := []int{1, 2, 3}
Print(str)
Print(intArray)
}

Go 中的泛型是如何工作的?

Go 中的泛型是使用类型参数实现的,它允许创建可以在不同类型上操作的泛型函数和数据结构,而无需显式类型转换。

考虑以下示例,其中类型参数 “T” 是使用 “any” 关键字定义的,该关键字指定该函数可以与任何类型一起使用。

1
2
3
func Swap[T any](a, b * T) {
*a, *b = *b, *a
}

函数体然后执行传入的两个指针指向的值的简单交换。

当函数被调用时,编译器为与函数一起使用的类型生成特定版本的函数。例如,如果函数被用于两个整数指针,编译器会生成一个操作整数的函数版本。

类型参数是什么?

在 Go 中,类型参数是使用方括号括起的类型参数列表来指定的,紧跟在函数、数据结构或接口名称之后。类型参数由单个大写字母或一系列大写字母表示,并用尖括号括起来。

类型参数用于在 Go 中创建通用函数、数据结构和接口。类型参数是在编译时确定的类型的占位符。

1
2
3
4
5
6
7
// 这里的 T 是类型参数,any 是类型约束;
// 这里表示 T 可以是任何类型。
func Print[T any](s []T) {
for _, v := range s {
fmt.Print(v)
}
}

使用:

1
2
3
4
5
6
func main() {
str := []string{"Hello", "Again Hello"}
intArray := []int{1, 2, 3}
Print(str)
Print(intArray)
}

例如,考虑上面的示例,显式了使用类型参数的函数声明。在这个函数中,类型参数由大写字母 "T" 表示。"any" 关键字表示函数可以使用任何类型。当调用此函数时,类型参数将被替换为传递给函数的实际类型。

类型参数使得在 Go 语言中可以创建更通用和可重用的代码,因为它允许函数和数据结构可以处理不同类型的数据。

在泛型中使用类型参数

在上面的例子中,我们看到了如何在同一个函数下结合多种类型的变量。

在这个例子中,使用 "any" 关键字声明了一个带有类型参数 "T" 的函数。"any" 关键字表示该函数可以处理任何类型。该函数以类型 "T" 的切片作为参数,并打印其内容。

T 是类型参数,any 是类型约束;这里表示 T 可以是任何类型。

要使用此功能,您可以使用下面给出的任何类型的切片来调用它:

1
2
3
4
5
6
7
8
intSlice := []int{
1, 2, 3, 4, 5,
}
stringSlice := []string{
"apple", "banana", "cherry",
}
Print(intSlice) // prints 1 2 3 4 5
Print(stringSlice) // prints apple banana cherry

在这个例子中,Print 函数被调用时使用了整数切片和字符串切片。类型参数 "T" 被实际传递给函数的参数类型所替换。

您还可以使用类型参数在 Go 中创建通用数据结构和接口。以下是一个使用类型参数的通用数据结构示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Stack[T any] struct {
items []T
}

func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}

func (s *Stack[T]) Pop() T {
if len(s.items) == 0 {
panic("stack is empty")
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item
}
  • 在这里,使用 “any” 关键字声明了带有类型参数 “T” 的栈数据结构。
  • Push 方法接受类型为 "T" 的项目作为参数,并将其添加到栈中。
  • Pop 方法从栈顶返回一个类型为 "T" 的项目。

要使用这种数据结构,您可以创建任何类型的栈:

1
2
3
4
5
6
7
8
9
10
intStack := &Stack[int]{}
stringStack := &Stack[string]{}
intStack.Push(1)
intStack.Push(2)
intStack.Push(3)
stringStack.Push("apple")
stringStack.Push("banana")
stringStack.Push("cherry")
fmt.Println(intStack.Pop()) // prints 3
fmt.Println(stringStack.Pop()) // prints cherry

在这个例子中,创建了两个栈,一个是 int 类型,另一个是 string 类型。类型参数 “T” 被替换为创建栈的实际类型。

类型约束

泛型中的类型约束定义了可以与泛型函数或数据结构一起使用的类型集合。类型约束允许编译器强制执行类型安全,并确保只有兼容的类型与泛型结构一起使用。

类型约束使用 "interface" 关键字指定,后跟接口的名称和类型必须实现的方法。例如,考虑以下使用类型约束的通用函数:

1
2
3
4
5
6
func Equal[T comparable](a, b T) T {
if a == b {
return a
}
return b
}

在这个例子中,类型参数 "T" 受到 "comparable" 接口的约束,该接口要求类型可以进行 ==!= 比较。这确保了函数只能被支持比较的类型调用。

comparable 是一个内置接口,用于将泛型类型参数限制为仅支持比较运算符(!= ,和 ==)的类型。

comparable 接口是由 Go 语言规范隐式定义的,并不需要在代码中显式定义。这意味着任何支持比较运算符的类型都可以作为 Equal 函数的类型参数,而无需额外声明 comparable 接口。

类型约束也可以是用户定义的接口,它允许对可以与通用函数或数据结构一起使用的类型进行更具体的约束。例如,考虑以下用户定义的接口:

1
2
3
4
5
6
type Number interface {
Add(other Number) Number
Sub(other Number) Number
Mul(other Number) Number
Div(other Number) Number
}

该接口定义了一组方法,一个类型必须实现这些方法才能被视为 “Number”。使用该接口作为类型约束的泛型函数或数据结构只能与实现了这些方法的类型一起使用,确保类型安全和兼容性。

Go 中的泛型类型约束提供了一种确保类型安全并限制可以与泛型结构一起使用的类型集的方法,同时仍然允许泛型提供的灵活性和可重用性。

在 Golang 中使用泛型的示例

这里有一些在Go中使用泛型的例子:

通用函数

该函数接受任何类型 T 的切片和类型 T 的值,并返回该值在切片中的索引。类型参数中的 any 关键字指定可以使用任何类型。

1
2
3
4
5
6
7
8
func findIndex[T any](slice []T, value T) int {
for i, v := range slice {
if reflect.DeepEqual(v, value) {
return i
}
}
return -1
}

通用类型

这定义了一个通用的栈类型,可以保存任何类型 T 的元素。关键字 any 指定任何类型都可以用作元素类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Stack[T any] []T

func (s *Stack[T]) Push(value T) {
*s = append(*s, value)
}

func (s *Stack[T]) Pop() T {
if len(*s) == 0 {
panic("Stack is empty")
}
value := (*s)[len(*s)-1]
*s = (*s)[:len(*s)-1]
return value
}

类型参数的约束

这定义了对类型参数 T 的类型约束,要求其实现 Equatable 接口。这允许 findIndex 函数使用 Equals 方法来比较类型T的值。

1
2
3
4
5
6
7
8
9
10
11
12
type Equatable interface {
Equals(other interface{}) bool
}

func findIndex[T Equatable](slice []T, value T) int {
for i, v := range slice {
if v.Equals(value) {
return i
}
}
return -1
}

支持多种数据类型的加法

让我们编写一个函数 SumGenerics ,它对各种数值类型进行加法操作,比如 intint16int32int64int8float32float64

1
2
3
4
5
6
7
8
9
10
func SumGenerics[T int | int16 | int32 | int64 | int8 | float32 | float64](a, b T) T {
return a + b
}

func main() {
sumInt := SumGenerics[int](2, 3) // returns 5
sumFloat := SumGenerics[float32](2.5, 3.5) // returns 6.0
sumInt64 := SumGenerics[int64](10, 20) // returns 30
println(sumInt, sumFloat, sumInt64)
}

在上面的代码中,我们可以看到,在调用泛型函数时通过在方括号 [] 中指定类型参数,我们可以对不同的数值类型执行加法操作。类型约束确保只有指定的类型 [T int, int16, int32, int64, int8, float32, or float64] 可以用作类型参数。

map 中的泛型

map 的泛型需要两种类型,一个 key 类型和一个 value 类型。值类型没有任何限制,但键类型应该始终满足 comparable 约束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// keys 返回一个 map 的所有 key
// m 参数是使用了 K 和 V 泛型的 map
// K 是使用了 comparable 约束的泛型,也就是说 K 必须支持 != 和 == 操作
// V 是使用了 any 约束的泛型,也就是说 V 可以是任意类型
func keys[K comparable, V any](m map[K]V) []K {
// 创建一个长度为 map 长度的 K 类型的 slice
key := make([]K, len(m))
i := 0
for k, _ := range m {
key[i] = k
i++
}
return key
}

结构体中的泛型

Go 允许使用类型参数定义 struct 。语法类似于泛型函数。类型参数可用于结构体上的方法和数据成员。

1
2
3
4
5
6
7
8
9
10
11
12
// T 是类型参数,使用了 any 约束
type MyStruct[T any] struct {
inner T
}

// 在 struct 方法中不允许使用新的类型参数
func (m *MyStruct[T]) Get() T {
return m.inner
}
func (m *MyStruct[T]) Set(v T) {
m.inner = v
}

在结构体方法中不允许定义新的类型参数,但在结构体定义中定义的类型参数可以在方法中使用。

多个泛型参数

泛型可以嵌套在其他类型中。在函数或结构中定义的类型参数可以传递给具有类型参数的任何其他类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 拥有两个泛型类型的泛型 struct
type Entries[K comparable, V any] struct {
Key K
Value V
}

// entries 函数返回一个 Entries 的 slice,代表了传入的 map 的所有 key 和 value
// K 和 V 是泛型类型参数,K 有 comparable 约束,V 没有约束
func entries[K comparable, V any](m map[K]V) []*Entries[K, V] {
// 创建一个 Entries 类型的 slice,传入 K 和 V 类型参数
e := make([]*Entries[K, V], len(m))
i := 0
for k, v := range m {
// 定义一个 Entries 类型的变量
newEntry := new(Entries[K, V])
newEntry.Key = k
newEntry.Value = v
e[i] = newEntry
i++
}
return e
}

我们可以通过逗号分隔多个类型参数来实现多个泛型参数。

类型并集

我们知道,在以往的 interface 定义中,往往都是只包含了方法定义的,如下面这样:

1
2
3
type Stringer interface {
String() string
}

而现在,我们还可以在 interface 中定义多个类型,如下面这样:

1
2
3
type Number interface {
int | int8
}

这种带有类型的 interface 可以帮助我们写出更加简洁的泛型代码,因为它可以用一个 intreface 来表示多个不同的相似类型。 但是这种带有类型的接口,不能用于定义变量,只能用于泛型的类型约束中。

在上面的泛型加法实现中,我们使用了 [T int | int16 | int32 | int64 | int8 | float32 | float64] 这种方式来给 T 定义了一个约束, 但是这种方式并不是很优雅,我们可以将约束定义为一个 interface,然后将 interface 作为约束。

我们称通过 | 连接的多个类型的 interface 为类型并集。

1
2
3
type Number interface {
int | int8 | int16 | int32 | int64 | float32 | float64
}

使用 Number 来作为泛型的约束:

1
2
3
4
5
6
7
8
// T 可以是任意 int 或 float 类型
// T 只能是支持算术运算的类型
func Min[T Number](x, y T) T {
if x < y {
return x
}
return y
}

使用多种类型的联合允许执行这些类型支持的常见操作,并编写适用于联合中所有类型的代码。

这些只是一些示例,说明了在 Go 中如何使用泛型来编写更灵活、可重用的代码。

类型交集

类似的,还有一种类型交集的概念,它是通过在 interface 中写多行类型来实现的:每一行定义了一种或多种类型的并集。

1
2
3
4
5
6
7
type AllInt interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}

type Uint interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}

在上面的代码中,AllInt 是一个类型并集,它包含了所有整数类型。Uint 是一个类型并集,它包含了所有无符号整数类型。

下面是一个使用类型交集的例子:

1
2
3
4
5
6
// 取 AllInt 和 Uint 的交集
// 也就是:~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
type Int interface {
AllInt
Uint
}

其实它的最终的结果等同于:

1
2
3
type Int interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}

除此之外,如果其中不同行之间没有任何交集,那么它们的交集就是空集。在现实中可能意义不大。

泛型接口和泛型结构体

在 Go 中,structinterface 都可以使用泛型。

例如,在下面的代码片段中,类型参数 T 的任何值只支持 String 方法 - 您可以使用 len() 或对其进行任何其他操作。

1
2
3
4
5
6
7
8
9
// Stringer 是一个约束
type Stringer interface {
String() string
}

// T 需要实现 Stringer 接口,T 只能执行 Stringer 接口中定义的操作
func stringer[T Stringer](s T) string {
return s.String()
}

再比如,下面的例子中,是一个使用了泛型的 struct

1
2
3
4
5
6
7
type Person[T int] struct {
age T
}

func (p Person[T]) Age() T {
return p.age
}

使用这个 struct

1
2
3
var p Person[int]
p.age = 10
fmt.Println(p.Age()) // 10

使用 ~ 指定底层类型

在 Go 中,定义了一个 cmp.Ordered 接口:

1
2
3
4
5
6
type Ordered interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
~float32 | ~float64 |
~string
}

这个声明表示 Ordered 是所有整数、浮点数、和字符串类型的集合。

对于类型约束,我们通常不关心特定类型,比如 string,我们对所有字符串类型感兴趣,所以我们使用 ~string 来表示所有字符串类型的集合。 ~string 表达式表示所有底层类型为 string 的类型的集合,这包括类型 string 本身以及所有使用如 type MyString string 声明定义的类型。

下面是一个错误的例子:

1
2
3
4
5
6
7
8
type Slice[T int] struct {
}

var s1 Slice[int] // 正确

type MyInt int
// 错误。MyInt 类型底层类型是 int 但并不是 int 类型,不符合 Slice[T] 的类型约束
var s2 Slice[MyInt]

正确的做法是,将 Slice 的类型约束修改为 ~int

1
2
3
4
5
6
7
8
9
// T 的底层类型是 int 即可,不一定是 int 类型
type Slice[T ~int] struct {
}

var s1 Slice[int] // 正确

type MyInt int
// 错误。MyInt 类型底层类型是 int 但并不是 int 类型,不符合 Slice[T] 的类型约束
var s2 Slice[MyInt]

使用 ~ 有个限制:

  • ~ 后面的类型不能为接口
  • ~ 后面的类型必须为基础类型

比如,下面是一个错误的例子:

1
2
3
// 错误:Invalid use of ~ ('cmp.Ordered' is an interface)
type Ab[T ~cmp.Ordered] struct {
}

泛型的限制

尽管 Go 语言中的泛型带来了许多好处和新的可能性,但它们的实现仍然存在一些限制和挑战。以下是 Go 语言中泛型的一些主要限制:

  • 性能:在 Go 语言中,泛型的一个主要问题是对性能的潜在影响。引入泛型后,Go 编译器需要在编译时为不同类型生成代码,这可能导致更大的二进制文件和更慢的编译时间。
  • 类型约束:Go 语言的泛型实现依赖于类型约束来确保类型安全。然而,这些约束可能会限制可以与泛型函数和数据结构一起使用的类型。
  • 语法复杂性:声明和使用泛型函数和数据结构的语法可能会很复杂,尤其对于初学者来说难以理解。
  • 错误消息:Go 编译器生成的与泛型相关的问题的错误消息可能难以理解,使得调试和故障排除更具挑战性。
  • 代码可读性:在 Go 中,泛型有时会使代码变得不太易读,更难理解,特别是在大量使用类型约束和类型参数的情况下。
  • 无法进行切换:当您想要从一个基础泛型类型切换到另一个时,使用泛型是不可能的。唯一的方法是使用接口,并在运行时运行类型切换函数。

总结

泛型为创建通用接口、结构体和函数提供了一种强大而简单的方法。

它们可以减少冗余信息,并且至少在某些情况下,提供了一种比反射更优越的替代方案。

当然,长时间以来,泛型受到激烈反对的主要原因是它们可能使代码更难阅读和解析,这似乎与 Go 语言的简洁性相悖。 鉴于此,本文也不会介绍太多复杂的泛型用法,上面提到的这些用法应该可以覆盖 90% 以上的使用场景了,因为复杂的代码必然会牺牲不少代码的可维护性。

另一方面,泛型是语言中的一个很好且必要的补充,如果明智地使用并且在有意义的地方使用的话。