本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。
"Don't Repeat Yourself"是常见的软件工程建议。与其重新创建一个数据结构或函数,不如重用它,因为对重复的代码保持更改同步非常困难。在像 Go 这样的强类型语言中,每个函数参数及每个结构体字段的类型必须在编译时确定。这种严格性使编译器能够帮助验证代码是否正确,但有时会希望重用不同类型的函数的逻辑或在重用不同类型的结构体字段。Go 通过类型参数提供了这个功能,俗称为泛型。本章中,读者将了解为什么需要泛型,Go 的泛型实现可以做什么,不能做什么,以及如何正确使用泛型。
泛型减少重复代码并增强类型安全
Go 是一种静态类型语言,这意味着在编译代码时会检查变量和参数的类型。内置类型(字典、切片、通道)和函数(如 len
、cap
或 make
)可接受并返回不同具体类型的值,但直到 Go 1.18,用户自定义的 Go 类型和函数都无法做到这一点。
如果读者熟悉动态类型语言,这类语言中类型在代码运行时才会进行确定,你可能不理解为什么要用泛型,以及它有什么用。如果将其视为"类型参数",可能会有助于理解。截至目前,我们按函数指定的参数来赋值调用。在以下代码中,我们指定 Min
接受两个 float64
类型的参数并返回一个float64
:
go
func Min(v1, v2 float64) float64 {
if v1 < v2 {
return v1
}
return v2
}
类似地,我们按声明结构体时所指定的字段类型创建结构体。这里Node
中有一个类型为int
的字段和一个类型为*Node
的字段。
go
type Node struct {
val int
next *Node
}
但有些情况下编写函数或结构体时,在使用之前不指定参数或字段的具体类型会很有用。
泛型类型的场景很容易理解。在前面,我们学习了一个int类型的二叉树。如果需要一个用于字符串或 float64 的二叉树,并保证类型安全,有几种选择。第一种是为每种类型编写一个自定义树,但是这么多重复的代码既冗长又容易出错。
在没有泛型的情况下,避免重复代码的唯一方法是修改树实现,使用接口来指定如何排序。接口类似这样:
go
type Orderable interface {
// Order returns:
// a value < 0 when the Orderable is less than the supplied value,
// a value > 0 when the Orderable is greater than the supplied value,
// and 0 when the two values are equal.
Order(any) int
}
有了Orderable
,我们就可以修改Tree
的实现来提供支持:
scala
type Tree struct {
val Orderable
left, right *Tree
}
func (t *Tree) Insert(val Orderable) *Tree {
if t == nil {
return &Tree{val: val}
}
switch comp := val.Order(t.val); {
case comp < 0:
t.left = t.left.Insert(val)
case comp > 0:
t.right = t.right.Insert(val)
}
return t
}
对于OrderableInt
类型,可以插入int
值:
go
type OrderableInt int
func (oi OrderableInt) Order(val any) int {
return int(oi - val.(OrderableInt))
}
func main() {
var it *Tree
it = it.Insert(OrderableInt(5))
it = it.Insert(OrderableInt(3))
// etc...
}
这段代码虽可正常运行,但无法让编译器验证插入数据结构的相同值。若有OrderableString
类型:
go
type OrderableString string
func (os OrderableString) Order(val any) int {
return strings.Compare(string(os), val.(string))
}
以下代码可正常编译:
ini
var it *Tree
it = it.Insert(OrderableInt(5))
it = it.Insert(OrderableString("nope"))
Order
函数使用any
表示传入的值。这会使Go的一个主要优势产生短路,即编译时类型安全检查。在编译代码深度对已包含OrderableInt
的Tree
插入OrderableString
时,编译器接受了该代码。但在运行时程序会panic:
vbnet
panic: interface conversion: interface {} is main.OrderableInt, not string
可以测试第8章的GitHub代码库sample_code/non_generic_tree目录中的这段代码。
现在,由于Go引入了泛型,可以一次性为多个类型实现数据结构并在编译时检测出不兼容的数据。很快就会讲到如何正确使用。
虽然没有泛型的数据结构不太方便,但真正的局限在于函数编写。Go标准库中的多个实现是因为最初未包含泛型而做出的决策。例如,Go中没有编写多个处理不同数值类型的函数,而是使用带有足够大范围以精确表示几乎每种其他数值类型的float64
参数来实现诸如math.Max
、math.Min
和math.Mod
这样的函数。 (不影响具有大于253 - 1 或小于-253 - 1的int
、int64
或uint
。)
还有有一功能没有泛型就无法实现。不能创建一个由接口指定变量的新实例,也不能指定两个具有相同接口类型的参数也具有同样的实体类型。没有泛型,就无法不借助反向就编写一个处理所有类型切片的函数,而那又会牺牲一些性能并带来编译时类型安全问题(sort.Slice
就是如此)。也就是说在Go引入泛型之前,处理切片的函数(如map, reduce, and filter) 需要针对不同类型切片进行反复实现。虽然简单的算法很容易拷贝,但很多(也许不是大多数)软件工程师会觉得因为编译器无法智能地自动实现出现重复代码很让人抓狂。
在Go语言中引入泛型
自Go首发以来,一直有呼声要求将泛型添加到该语言中。Go的开发负责人Russ Cox于2009年写了一篇博客文章,解释了为什么最初未包含泛型。Go着重快速编译器、可读性代码和良好的执行时间,而他们所了解的泛型实现都无法同时满足这三个条件。经过十年的研究,Go团队已经找到了一种可行的方法,详见类型参数提案。
可以通过栈来了解Go中的泛型是如何运作的。如果读者没有计算机科学背景,栈是一种数据类型,其中的值以后进先出(LIFO)的顺序添加和删除。这就像一堆等待清洗的盘子;一开始的放在底部,只有先处理后添加的那些盘子才能够拿到它们。我们来看如何使用泛型创建栈:
go
type Stack[T any] struct {
vals []T
}
func (s *Stack[T]) Push(val T) {
s.vals = append(s.vals, val)
}
func (s *Stack[T]) Pop() (T, bool) {
if len(s.vals) == 0 {
var zero T
return zero, false
}
top := s.vals[len(s.vals)-1]
s.vals = s.vals[:len(s.vals)-1]
return top, true
}
有几个需要注意的地方。首先,类型声明后使用[T any]
。类型参数放在了方括号内。书写方式与变量参数相同,首先是类型名称,然后是类型约束。可为类型参数选择任意名称,但通常习惯使用大写字母。Go使用接口来指定可以使用哪些类型。如可使用任何类型,使用全局标识符any
来指定。在Stack
声明内部,我们声明vals
的类型为[]T
。
接下来,看一下方法声明。就像我们在vals
声明中使用了T
,此处也是一样的。在接收器部分,我们还使用Stack[T]
换Stack
来引用类型。
最后,泛型使零值处理产生了变化。在Pop
中,我们不能只返回nil
,因为对于值类型(如int
),这不是一个有效值。获取泛型的零值的最简单方法是使用var
声明一个变量并返回,因为根据定义,如果未赋值,var
会将其变量初始化为零值。
使用泛型类型与使用非泛型类型非常相似:
scss
func main() {
var intStack Stack[int]
intStack.Push(10)
intStack.Push(20)
intStack.Push(30)
v, ok := intStack.Pop()
fmt.Println(v, ok)
}
唯一的不同是在声明变量时对Stack
指定了希望包含的类型,本例中为int
。如尝试将字符串压入栈,编译器会捕获到。添加如下行:
arduino
intStack.Push("nope")
会得到编译错误:
csharp
cannot use "nope" (untyped string constant) as int value
in argument to intStack.Push
可在The Go Playground中测试我们的泛型栈可查看第8章的GitHub代码库sample_code/stack目录中的代码。
下面对该栈添加一个是否包含某值的方法:
go
func (s Stack[T]) Contains(val T) bool {
for _, v := range s.vals {
if v == val {
return true
}
}
return false
}
可惜无法编译。报错如下:
scala
invalid operation: v == val (type parameter T is not comparable with ==)
就像interface{}
没表明什么,any
也一样。只能存储any
类型的值和提取。需要对其它类型才能使用==
。因几乎所有Go类型都可以使用==
和!=
进行比较,在全局代码块中新定义了一个名为comparable
的接口。可comparable
修改的Stack
定义:
css
type Stack[T comparable] struct {
vals []T
}
然后就可以使用这个新方法了:
scss
func main() {
var s Stack[int]
s.Push(10)
s.Push(20)
s.Push(30)
fmt.Println(s.Contains(10))
fmt.Println(s.Contains(5))
}
输出的结果为:
arduino
true
false
可测试第8章的GitHub代码库sample_code/comparable_stack 目录中我们所更新的栈。
稍后我们会学习如何创建泛型二叉树。在此之前,先讲解一些概念:泛型函数、接口如何使用泛型以及类型术语。