Go 1.18 引入了泛型到语言中。简而言之,这允许我们编写具有可以稍后指定并在需要时实例化的类型的代码。然而,何时使用泛型以及何时不使用可能会让人感到困惑。在本节中,我们将描述 Go 中泛型的概念,然后探讨常见的使用和误用情况。
2.9.1 概念
考虑以下函数,它从 map[string]int
类型中提取所有键:
func getKeys(m map[string]int) []string {
var keys []string
for k := range m {
keys = append(keys, k)
}
return keys
}
如果我们想对其他类型的映射,如 map[int]string
使用类似的功能,会怎么样?在泛型出现之前,Go 开发者有几个选择:使用代码生成、反射或复制代码。例如,我们可以为每种映射类型编写两个函数,或者甚至尝试扩展 getKeys
以接受不同的映射类型:
func getKeys(m any) ([]any, error) {
switch t := m.(type) {
default:
// 接受并返回任何参数
return nil, fmt.Errorf("unknown type: %T", t)
case map[string]int:
var keys []any
for k := range t {
keys = append(keys, k)
}
return keys, nil
case map[int]string:
// 复制提取逻辑
}
}
处理尚未实现的类型的运行时错误
通过这个例子,我们开始注意到一些问题。首先,它增加了样板代码。实际上,当我们想要添加一个 case 时,它需要复制范围循环。同时,该函数现在接受一个 any
类型,这意味着我们失去了 Go 作为类型语言的一些好处。实际上,检查一个类型是否被支持是在运行时而不是编译时进行的。因此,如果提供的类型未知,我们还需要返回一个错误。最后,由于键类型可以是 int
或 string
,我们不得不返回一个 any
类型的切片来提取键类型。这种方法增加了调用者方面的努力,因为客户端可能还需要对键执行类型检查或额外的转换。多亏了泛型,我们现在可以使用类型参数重构这段代码。
类型参数是我们可以与函数和类型一起使用的泛型类型。例如,以下函数接受一个类型参数:
func foo[T any](t T) {
// ...
}
T
是一个类型参数。
当我们调用 foo
时,我们传递一个任何类型的类型参数。提供类型参数称为实例化,工作在编译时完成。这保持了类型安全作为核心语言特性的一部分,并避免了运行时开销。
让我们回到 getKeys
函数,并使用类型参数编写一个泛型版本,该版本将接受任何类型的映射:
func getKeys[K comparable, V any](m map[K]V) []K {
// 键是可比较的,而值是任何类型。
var keys []K
for k := range m {
keys = append(keys, k)
}
return keys
}
创建键的切片
为了处理映射,我们定义了两种类型的类型参数。首先,值可以是任何类型:V any
。然而,在 Go 中,映射的键不能是任何类型。例如,我们不能使用切片:
var m map[[]byte]int
这段代码会导致编译错误:无效的映射键类型 []byte
。因此,我们不是接受任何键类型,而是不得不限制类型参数,以便键类型满足特定要求。在这里,要求是键类型必须是可比较的(我们可以使用 ==
或 !=
)。因此,我们定义 K
为可比较的而不是任何类型。
将类型参数限制为匹配特定要求称为约束。约束是一个接口类型,可以包含:
- 一组行为(方法)
- 任意类型
让我们检查后者的一个具体示例。想象一下我们不想为映射键类型接受任何可比较类型。例如,我们想将其限制为 int
或 string
类型。我们可以通过这种方式定义自定义约束:
type customConstraint interface {
~int | ~string
}
func getKeys[K customConstraint, V any](m map[K]V) []K {
// 相同的实现
}
定义一个自定义类型,限制类型为 int
和 string
将类型参数 K
更改为 customConstraint
类型
首先,我们定义一个 customConstraint
接口,使用联合运算符 |
(我们将稍后讨论 ~
的使用)将类型限制为 int
或 string
。现在 K
是 customConstraint
而不是以前的可比较类型。
getKeys
的签名强制我们可以使用任何值类型和键类型为 int
或 string
的映射调用它,例如,在调用者端:
m := map[string]int{
"one": 1,
"two": 2,
"three": 3,
}
keys := getKeys(m)
注意,Go 可以推断 getKeys
是用字符串类型参数调用的。前面的调用等同于:
keys := getKeys[string](m)
~int
与
int使用
~int
的约束与使用int
的约束有什么区别?使用int
将其限制为该类型,而~int
限制所有底层类型为int
的类型。为了说明这一点,让我们想象一个约束,我们将限制类型为实现String() string
方法的任何int
类型:
type customConstraint interface { ~int String() string }
使用这个约束限制类型参数为自定义类型。例如:
type customInt int func (i customInt) String() string { return strconv.Itoa(int(i)) }
因为
customInt
是int
并实现了String() string
方法,所以customInt
类型满足定义的约束。然而,如果我们将约束改为包含int
而不是~int
,使用customInt
会导致编译错误,因为int
类型没有实现String() string
。
到目前为止,我们已经讨论了使用泛型函数的示例。然而,我们也可以将泛型用于数据结构。例如,我们可以创建一个包含任意类型值的链表。为此,我们将编写一个 Add 方法来附加一个节点:
type Node[T any] struct {
Val T
next *Node[T]
}
func (n *Node[T]) Add(next *Node[T]) {
n.next = next
}
在这个例子中,我们使用类型参数来定义 T,并在 Node 中使用两个字段。关于方法,接收器被实例化了。确实,因为 Node 是泛型的,所以它也必须遵循定义的类型参数。
关于类型参数的最后一点需要注意的是,它们不能与方法参数一起使用,只能与函数参数或方法接收器一起使用。例如,以下方法将无法编译:
type Foo struct {}
func (Foo) bar[T any](t T) {}
./main.go:29:15: methods cannot have type parameters
如果我们想要在方法中使用泛型,接收器就需要成为一个类型参数。
2.9.2 常见的使用和误用
泛型在何时有用?让我们讨论一些推荐使用泛型的常见用途:
-
数据结构 - 例如,如果我们实现二叉树、链表或堆,我们可以使用泛型来提取元素类型。
-
与任何类型的切片、映射和通道一起工作的函数 - 例如,一个合并两个通道的函数将与任何通道类型一起工作。因此,我们可以使用类型参数来提取通道类型:
func merge[T any](ch1, ch2 <-chan T) <-chan T { // ... }
-
而不是类型的提取行为 - 例如,
sort
包包含一个sort.Interface
接口,它有三种方法:type Interface interface { Len() int Less(i, j int) bool Swap(i, j int) }
这个接口被不同的函数使用,如
sort.Ints
或sort.Float64s
。使用类型参数,我们可以提取排序行为(例如,通过定义一个持有切片和比较函数的结构体):type SliceFn[T any] struct { S []T Compare func(T, T) bool } func (s SliceFn[T]) Len() int { return len(s.S) } func (s SliceFn[T]) Less(i, j int) bool { return s.Compare(s.S[i], s.S[j]) } func (s SliceFn[T]) Swap(i, j int) { s.S[i], s.S[j] = s.S[j], s.S[i] }
然后,由于
SliceFn
结构体实现了sort.Interface
,我们可以使用sort.Sort(sort.Interface)
函数对提供的切片进行排序:s := SliceFn[int]{ S: []int{3, 2, 1}, Compare: func(a, b int) bool { return a < b }, } sort.Sort(s) fmt.Println(s.S) // 输出: [1 2 3]
在这个例子中,提取行为允许我们避免为每种类型创建一个函数。
相反,何时建议我们不使用泛型呢?
-
当调用类型参数的方法时 - 考虑一个接收
io.Writer
并调用Write
方法的函数,例如:func foo[T io.Writer](w T) { b := getBytes() _, _ = w.Write(b) }
在这种情况下,使用泛型对我们的代码没有任何价值。我们应该直接将
w
参数作为io.Writer
。 -
当它使我们的代码更复杂时 - 泛型从不是必须的,作为 Go 开发者,我们在没有它们的情况下生活了十多年。如果我们正在编写泛型函数或结构体,并且发现它并没有使我们的代码更清晰,我们可能应该重新考虑我们对特定用例的决定。
尽管泛型在特定条件下可能是有帮助的,我们应该谨慎决定何时使用它们以及何时不使用它们。一般来说,如果我们想要回答何时不使用泛型,我们可以找到与何时不使用接口的相似之处。实际上,泛型引入了一种形式的抽象,我们必须记住,不必要的抽象会引入复杂性。