编写 Go 中的泛型代码
Go 中引入泛型是一个备受争议和期待的功能。一些开发人员认为引入它会违背 Go 的简单性核心原则,而另一些人则认为这是成熟的标志,将使他们能够编写更好的生产代码。与每个技术解决方案或设计决策一样,优点和缺点之间存在权衡。
如前所述,泛型是指编写能够与不同数据类型一起工作的代码的能力,而不局限于特定类型。在没有泛型的情况下,我们使用 Go 接口在 Go 中实现泛型行为。在第3章《模拟和断言框架》中,我们探讨了接口的强大功能,并看到了它们如何用于包装和替换依赖项。虽然接口与泛型不同,但它们提供了一种实现灵活性和代码重用类似目标的方法。
图11.1 提供了泛型和接口的比较:

虽然泛型和接口都为我们的代码提供了代码灵活性和多态性,但它们有以下主要区别:
-
泛型是一种指定类型的方式,而接口指定行为。正如我们所看到的,接口是必须由结构体定义以满足它们的方法集合。另一方面,泛型使我们能够指定可以使用的参数类型。
-
泛型内置于语言中,而接口由应用程序定义。接口由工程师定义为其代码库的一部分,这使得定义它们以包括应用程序所需的任何行为变得更容易。泛型的规范内置于语言中,并且可以在代码库之间共享。
-
泛型范围有限,而接口范围广泛。由于它们内置于语言中,因此它们足够简单以实现各种问题的解决方案。另一方面,接口具有表现力,可以定义复杂的行为。
泛型和接口都由 Go 编译器实现,因此它们都是静态类型检查的。然而,接口确保特定方法可用于参数,而泛型不提供这些保证。
Go 中的泛型
编写泛型代码的能力是其他强类型编程语言(如 Java
、C#
和 C++
)的核心部分。它的加入使我们能够编写更灵活和可重用的代码。让我们看一些如何利用这种新能力的示例。
指定泛型代码有三个主要组成部分:
类型参数 是将在泛型代码中使用的占位符类型规范。它们通常用一个字母表示,例如 T,并允许我们在实现中引用占位符类型。泛型函数或类型由其规范中的此占位符定义。
类型约束 帮助我们为类型参数定义规则或子类型。约束不是像接口那样的完整规范,但它们允许我们将类型参数限制为某些属性。
类型参数 是在调用时传递给泛型函数或类型的类型,它指定我们将使用的数据类型。类型参数用于替换函数或类型签名声明的类型参数占位符。
类型推断 是在幕后进行的确定变量类型的过程,而无需其显式类型规范。这也使我们能够编写更简洁的泛型代码。
这四个组件协同工作,使我们能够轻松编写泛型代码。图11.2 通过一个简单的泛型函数示例展示了每个组件:

该示例定义了一个泛型 sum
函数,它接收两个参数。它定义了一个类型参数 T
,它必须满足指定的类型约束。在 main
函数中,我们使用 int64
类型参数和两个参数调用该函数。编译器使用类型推断来确保传递的参数符合类型约束,并用 main
函数中提供的类型参数替换类型参数。我们将在接下来的部分中探讨如何编写和测试泛型 Go 代码。
探索类型约束
类型约束是泛型的核心组成部分,因为它们允许我们限制泛型代码可以使用的数据类型。这使我们更容易编写安全且简单的代码,这些代码在满足指定条件的类型上运行。
类型参数在 Go 中用方括号([]
)声明。类型参数定义了类型的名称和可能的约束。我们可以通过几种方式声明约束:
-
any
接口 是空接口interface{}
的别名,允许使用所有类型。这使我们能够构建没有约束的函数和类型。例如,我们可以通过使用any
接口使sum
函数接受任何参数类型:func sum[T any](x, y T) T { // 实现
-
comparable
接口 是一个预声明的类型约束,表示可以使用==
操作符进行比较的类型。这些类型包括string
、bool
、所有数字以及包含可比较字段的复合类型。例如,sum
函数现在将允许这些类型作为参数,但我们需要相应地实现求和逻辑,因为 + 操作符不再由所有这些类型实现:func sum[T comparable](x, y T) T { // 实现 }
-
类型集 可以使用
|
操作符创建。这使我们能够创建包含多个类型的约束,而无需将它们包装在自定义接口中。这是我们在图11.2 中看到的示例,其中sum
函数允许int64
和float64
类型,这些类型已经支持+
操作符:func sum[T int64 | float64](x, y T) T { // 实现 }
-
自定义类型约束 也可以创建为接口并在我们的代码中重用。这些也使用
|
操作符声明。例如,Number
接口允许int64
和float64
类型,然后我们可以在sum
函数的规范中使用它,从而产生与之前类型集相同的规范:type Number interface { int64 | float64 } func sum[T Number](x, y T) T { // 实现 }
-
~
关键字 可用于限制具有相同底层类型的所有自定义类型。这使我们能够将自定义类型纳入我们的约束中。例如,Number
接口现在将允许任何基于int
和float64
的类型:type Number interface { ~int64 | ~float64 }
-
constraints
包 定义了一些可以与泛型代码一起使用的有用约束。该包包含您可能会发现有用的数值和有序约束。例如,我们可以通过使用此包修改sum
函数以接受所有有符号整数:func sum[T constraints.Signed](x, y T) T { // 实现 }
泛型可以应用于函数和结构体,使我们能够创建泛型数据结构和可重用的行为。这可以简化我们的代码,并消除为适应不同类型的底层数据而多次定义代码的需要。在第10章《测试边缘情况》中,我们实现了一个根据键值对映射值进行排序的函数:
// GetSortedValues 返回给定映射的按键排序的值。
func GetSortedValues(input map[int]string, dir SortDirection) ([]string, error) {
// 实现
}
这个非常有用的函数有一个主要限制,即它仅适用于具有 int
键和 string
值的单一类型映射——map[int]string
。此限制使得很难将此代码重用于其他映射类型。
在 Go 泛型之前的日子里,我们必须为每种类型的映射创建一个函数:
// GetSortedFloatValues 返回具有 int 键和 float64 值的给定映射的按键排序的值。
func GetSortedFloatValues(input map[int]float64, dir SortDirection) ([]string, error) {
// 实现
}
由于 Go 不允许函数重载,我们还需要为接受不同参数的函数使用不同的名称。即使我们提取辅助函数以帮助重用函数内部的实现代码,这也可能使创建库变得困难。
泛型大大简化了这一点,它允许我们参数化排序函数的键和值类型:
// GetSortedValues 返回给定映射的按键排序的值。
func GetSortedValues[K ~int, V comparable](input map[K]V, dir SortDirection) ([]V, error) {
// 实现
}
函数的签名已更改为利用泛型的功能来创建可重用的函数:
-
该函数有两个类型参数,一个用于键类型,一个用于值类型。键值使用
~
关键字限制为任何int
类型,而值使用comparable
接口开放给任何符合的类型。 -
映射参数使用键和值占位符类型
map[K]V
声明。只有符合类型约束的参数才会被函数接受。 -
函数的返回值调整为返回值类型的切片
[]V
。这确保返回的值与输入映射传递的类型相同。
一旦我们打开函数以接收泛型类型,应用有意义的约束以确保我们的代码继续提供有用的功能也很重要。函数签名还充当调用者的用户手册,因此即使我们为代码增加了灵活性,我们也应该继续指导他们。