Go 语言中的泛型是指一种语言特性,允许创建可以处理不同类型的函数、数据结构和接口。换句话说,泛型使得可以创建不受特定类型或数据结构限制的代码。如果我们此前有使用 Java 或者 C++ 的经验,那么会很好理解。
在 Go 语言引入泛型之前,开发人员必须编写多个函数来处理不同类型的数据。这种方法通常很繁琐,并导致代码重复。有了泛型,开发人员可以编写更简洁和可重用的代码,可以处理不同类型的数据。
Go 语言中的泛型是在 2021 年 2 月发布的 1.18 版本中引入的。Go 语言中的泛型实现是基于类型参数的概念。类型参数是传递给函数或数据结构的类型的占位符,使它们能够处理不同类型的数据。
Go 中的泛型是什么?
泛型是一种代码,允许我们通过改变函数类型来在各种函数中使用它们。泛型的创建是为了使代码独立于类型和函数。
泛型的主要目的是通过添加更少的代码行来实现更大的灵活性。
为了更好地理解,看下面的例子。我们创建一个打印任何类型参数的函数,就像这样:
1 | func Print(s[] string) { |
现在,我们突然希望打印一个整数,所以我们相应地改变了代码。
1 | func Print(s[] int) { |
但是每次像这样更改代码可能看起来令人生畏,这就是泛型发挥作用的地方。通过将任何类型分配给其泛型形式,我们可以将相同的代码用于不同的函数。看一下这个:
1 | func Print[T any](s[] T) { |
在这里,我们将 "T"
定义为 any
类型。这个任意类型允许我们在同一个函数中解析不同类型的变量。S
是相应的变量,它是 T
类型的一个切片。现在,调用该方法,我们可以在同一个函数中打印一个字符串和一个整数。
1 | func main() { |
Go 中的泛型是如何工作的?
Go 中的泛型是使用类型参数实现的,它允许创建可以在不同类型上操作的泛型函数和数据结构,而无需显式类型转换。
考虑以下示例,其中类型参数 “T”
是使用 “any”
关键字定义的,该关键字指定该函数可以与任何类型一起使用。
1 | func Swap[T any](a, b * T) { |
函数体然后执行传入的两个指针指向的值的简单交换。
当函数被调用时,编译器为与函数一起使用的类型生成特定版本的函数。例如,如果函数被用于两个整数指针,编译器会生成一个操作整数的函数版本。
类型参数是什么?
在 Go 中,类型参数是使用方括号括起的类型参数列表来指定的,紧跟在函数、数据结构或接口名称之后。类型参数由单个大写字母或一系列大写字母表示,并用尖括号括起来。
类型参数用于在 Go 中创建通用函数、数据结构和接口。类型参数是在编译时确定的类型的占位符。
1 | // 这里的 T 是类型参数,any 是类型约束; |
使用:
1 | func main() { |
例如,考虑上面的示例,显式了使用类型参数的函数声明。在这个函数中,类型参数由大写字母 "T"
表示。"any"
关键字表示函数可以使用任何类型。当调用此函数时,类型参数将被替换为传递给函数的实际类型。
类型参数使得在 Go 语言中可以创建更通用和可重用的代码,因为它允许函数和数据结构可以处理不同类型的数据。
在泛型中使用类型参数
在上面的例子中,我们看到了如何在同一个函数下结合多种类型的变量。
在这个例子中,使用 "any"
关键字声明了一个带有类型参数 "T"
的函数。"any"
关键字表示该函数可以处理任何类型。该函数以类型 "T"
的切片作为参数,并打印其内容。
T
是类型参数,any
是类型约束;这里表示T
可以是任何类型。
要使用此功能,您可以使用下面给出的任何类型的切片来调用它:
1 | intSlice := []int{ |
在这个例子中,Print
函数被调用时使用了整数切片和字符串切片。类型参数 "T"
被实际传递给函数的参数类型所替换。
您还可以使用类型参数在 Go 中创建通用数据结构和接口。以下是一个使用类型参数的通用数据结构示例:
1 | type Stack[T any] struct { |
- 在这里,使用
“any”
关键字声明了带有类型参数“T”
的栈数据结构。 Push
方法接受类型为"T"
的项目作为参数,并将其添加到栈中。Pop
方法从栈顶返回一个类型为"T"
的项目。
要使用这种数据结构,您可以创建任何类型的栈:
1 | intStack := &Stack[int]{} |
在这个例子中,创建了两个栈,一个是 int
类型,另一个是 string
类型。类型参数 “T”
被替换为创建栈的实际类型。
类型约束
泛型中的类型约束定义了可以与泛型函数或数据结构一起使用的类型集合。类型约束允许编译器强制执行类型安全,并确保只有兼容的类型与泛型结构一起使用。
类型约束使用 "interface"
关键字指定,后跟接口的名称和类型必须实现的方法。例如,考虑以下使用类型约束的通用函数:
1 | func Equal[T comparable](a, b T) T { |
在这个例子中,类型参数 "T"
受到 "comparable"
接口的约束,该接口要求类型可以进行 ==
或 !=
比较。这确保了函数只能被支持比较的类型调用。
comparable
是一个内置接口,用于将泛型类型参数限制为仅支持比较运算符(!= ,和 ==)的类型。
comparable
接口是由 Go 语言规范隐式定义的,并不需要在代码中显式定义。这意味着任何支持比较运算符的类型都可以作为 Equal
函数的类型参数,而无需额外声明 comparable
接口。
类型约束也可以是用户定义的接口,它允许对可以与通用函数或数据结构一起使用的类型进行更具体的约束。例如,考虑以下用户定义的接口:
1 | type Number interface { |
该接口定义了一组方法,一个类型必须实现这些方法才能被视为 “Number”
。使用该接口作为类型约束的泛型函数或数据结构只能与实现了这些方法的类型一起使用,确保类型安全和兼容性。
Go 中的泛型类型约束提供了一种确保类型安全并限制可以与泛型结构一起使用的类型集的方法,同时仍然允许泛型提供的灵活性和可重用性。
在 Golang 中使用泛型的示例
这里有一些在Go中使用泛型的例子:
通用函数
该函数接受任何类型 T
的切片和类型 T
的值,并返回该值在切片中的索引。类型参数中的 any
关键字指定可以使用任何类型。
1 | func findIndex[T any](slice []T, value T) int { |
通用类型
这定义了一个通用的栈类型,可以保存任何类型 T
的元素。关键字 any
指定任何类型都可以用作元素类型。
1 | type Stack[T any] []T |
类型参数的约束
这定义了对类型参数 T
的类型约束,要求其实现 Equatable
接口。这允许 findIndex
函数使用 Equals
方法来比较类型T的值。
1 | type Equatable interface { |
支持多种数据类型的加法
让我们编写一个函数 SumGenerics
,它对各种数值类型进行加法操作,比如 int
,int16
,int32
,int64
,int8
,float32
和 float64
。
1 | func SumGenerics[T int | int16 | int32 | int64 | int8 | float32 | float64](a, b T) T { |
在上面的代码中,我们可以看到,在调用泛型函数时通过在方括号 []
中指定类型参数,我们可以对不同的数值类型执行加法操作。类型约束确保只有指定的类型 [T int, int16, int32, int64, int8, float32, or float64]
可以用作类型参数。
map 中的泛型
map
的泛型需要两种类型,一个 key
类型和一个 value
类型。值类型没有任何限制,但键类型应该始终满足 comparable
约束。
1 | // keys 返回一个 map 的所有 key |
结构体中的泛型
Go 允许使用类型参数定义 struct
。语法类似于泛型函数。类型参数可用于结构体上的方法和数据成员。
1 | // T 是类型参数,使用了 any 约束 |
在结构体方法中不允许定义新的类型参数,但在结构体定义中定义的类型参数可以在方法中使用。
多个泛型参数
泛型可以嵌套在其他类型中。在函数或结构中定义的类型参数可以传递给具有类型参数的任何其他类型。
1 | // 拥有两个泛型类型的泛型 struct |
我们可以通过逗号分隔多个类型参数来实现多个泛型参数。
类型并集
我们知道,在以往的 interface
定义中,往往都是只包含了方法定义的,如下面这样:
1 | type Stringer interface { |
而现在,我们还可以在 interface
中定义多个类型,如下面这样:
1 | type Number interface { |
这种带有类型的 interface
可以帮助我们写出更加简洁的泛型代码,因为它可以用一个 intreface
来表示多个不同的相似类型。 但是这种带有类型的接口,不能用于定义变量,只能用于泛型的类型约束中。
在上面的泛型加法实现中,我们使用了 [T int | int16 | int32 | int64 | int8 | float32 | float64]
这种方式来给 T
定义了一个约束, 但是这种方式并不是很优雅,我们可以将约束定义为一个 interface
,然后将 interface
作为约束。
我们称通过
|
连接的多个类型的interface
为类型并集。
1 | type Number interface { |
使用 Number
来作为泛型的约束:
1 | // T 可以是任意 int 或 float 类型 |
使用多种类型的联合允许执行这些类型支持的常见操作,并编写适用于联合中所有类型的代码。
这些只是一些示例,说明了在 Go 中如何使用泛型来编写更灵活、可重用的代码。
类型交集
类似的,还有一种类型交集的概念,它是通过在 interface
中写多行类型来实现的:每一行定义了一种或多种类型的并集。
1 | type AllInt interface { |
在上面的代码中,AllInt
是一个类型并集,它包含了所有整数类型。Uint
是一个类型并集,它包含了所有无符号整数类型。
下面是一个使用类型交集的例子:
1 | // 取 AllInt 和 Uint 的交集 |
其实它的最终的结果等同于:
1 | type Int interface { |
除此之外,如果其中不同行之间没有任何交集,那么它们的交集就是空集。在现实中可能意义不大。
泛型接口和泛型结构体
在 Go 中,struct
和 interface
都可以使用泛型。
例如,在下面的代码片段中,类型参数 T
的任何值只支持 String
方法 - 您可以使用 len()
或对其进行任何其他操作。
1 | // Stringer 是一个约束 |
再比如,下面的例子中,是一个使用了泛型的 struct
:
1 | type Person[T int] struct { |
使用这个 struct
:
1 | var p Person[int] |
使用 ~ 指定底层类型
在 Go 中,定义了一个 cmp.Ordered
接口:
1 | type Ordered interface { |
这个声明表示 Ordered
是所有整数、浮点数、和字符串类型的集合。
对于类型约束,我们通常不关心特定类型,比如 string
,我们对所有字符串类型感兴趣,所以我们使用 ~string
来表示所有字符串类型的集合。 ~string
表达式表示所有底层类型为 string
的类型的集合,这包括类型 string
本身以及所有使用如 type MyString string
声明定义的类型。
下面是一个错误的例子:
1 | type Slice[T int] struct { |
正确的做法是,将 Slice
的类型约束修改为 ~int
:
1 | // T 的底层类型是 int 即可,不一定是 int 类型 |
使用 ~
有个限制:
~
后面的类型不能为接口~
后面的类型必须为基础类型
比如,下面是一个错误的例子:
1 | // 错误:Invalid use of ~ ('cmp.Ordered' is an interface) |
泛型的限制
尽管 Go 语言中的泛型带来了许多好处和新的可能性,但它们的实现仍然存在一些限制和挑战。以下是 Go 语言中泛型的一些主要限制:
- 性能:在 Go 语言中,泛型的一个主要问题是对性能的潜在影响。引入泛型后,Go 编译器需要在编译时为不同类型生成代码,这可能导致更大的二进制文件和更慢的编译时间。
- 类型约束:Go 语言的泛型实现依赖于类型约束来确保类型安全。然而,这些约束可能会限制可以与泛型函数和数据结构一起使用的类型。
- 语法复杂性:声明和使用泛型函数和数据结构的语法可能会很复杂,尤其对于初学者来说难以理解。
- 错误消息:Go 编译器生成的与泛型相关的问题的错误消息可能难以理解,使得调试和故障排除更具挑战性。
- 代码可读性:在 Go 中,泛型有时会使代码变得不太易读,更难理解,特别是在大量使用类型约束和类型参数的情况下。
- 无法进行切换:当您想要从一个基础泛型类型切换到另一个时,使用泛型是不可能的。唯一的方法是使用接口,并在运行时运行类型切换函数。
总结
泛型为创建通用接口、结构体和函数提供了一种强大而简单的方法。
它们可以减少冗余信息,并且至少在某些情况下,提供了一种比反射更优越的替代方案。
当然,长时间以来,泛型受到激烈反对的主要原因是它们可能使代码更难阅读和解析,这似乎与 Go 语言的简洁性相悖。 鉴于此,本文也不会介绍太多复杂的泛型用法,上面提到的这些用法应该可以覆盖 90% 以上的使用场景了,因为复杂的代码必然会牺牲不少代码的可维护性。
另一方面,泛型是语言中的一个很好且必要的补充,如果明智地使用并且在有意义的地方使用的话。