• Go语言新特性之Go语言中的泛型
  • 发布于 2个月前
  • 425 热度
    0 评论
Go 语言在 2022 年最大的变化莫过于添加了对泛型(generics)的支持。这对于 Go 语言的广大使用者来说,也是感受最明显的变化。早在 2022 年 2 月正式发布的 1.18 版本,Go 语言就包含了类型参数提案(即 Type Parameters Proposal)中描述的通用功能的实现。这包括了需要对 Go 语言本身进行的主要的更改。注意,这些更改是完全向后兼容的。这也是 Go 语言官方对于 Go 1.x 版本更新的一贯原则和保证。

Go 语言官方团队为此编写和修改了大量的代码。虽然这些代码并未经过超大规模的验证,但是 Go 语言团队对它们的质量和功效是非常有信心的。不过,也不排除这种可能:一些使用泛型的代码适用于 1.18 版本,但在以后的版本中会变得无效,或者说需要稍加修改才能使其有效。因为,这次为了支持泛型的改动实在是太大了。按照 Go 官方的说法,这种不完全兼容的可能性会被尽可能减小,但是无法对此做出 100% 保证。

虽然我们都喜欢 100% 确定的东西,但是万事万物都不可能有 100% 的稳定性和可预测性。尤其是,对软件开发有一定理解的朋友们肯定都知道,没有哪一个软件是没有 bug 的,也没有哪一个软件功能可以保证 100% 的正确。所以,我们需要用更加宽容的心态来看待 Go 语言的这次超大级别的更新。实际上我们也不用太过担心。因为从 Go 语言的 issue 列表上看,泛型相关的 bug 如今也已经发现和修复得差不多了。Go 语言的泛型已经趋于稳定,我们已经可以放心地将其用在生产级代码上了。

从语法上说,Go 语言的类型参数(可以理解为“泛型”的另一种称谓)并未使用那个在其他编程语言中常见的尖括号(即“<”和“>”),而用的是方括号(即“[”和“]”)。这也是程序员们感受的最大一点不同。请注意,我们在这里所说的“对泛型的支持”实际上是“对自定义泛型的支持”。因为 Go 语言内置的一些数据类型从一开始就是支持泛型的,比如:
var map1 = map[int]string{}
或者
var slice1 = []string{}
然而,在 Go 1.18 之前,使用者们自己编写的函数或者类型是无法添加泛型定义的。下面重点来了。在 Go 1.18 中,对泛型的具体支持都有哪些呢?请看下文。
1. 自定义的函数声明或类型声明都可以包含类型参数
请看如下代码:
type Pair[K ~int, E ~string] struct {    
    Key  K    
    Elem E
}
所谓的类型参数声明,其实就是对一个类型所涉及到的关联类型的泛化定义。例如,对于结构体类型中的每个字段,我们都必须分别定义其类型。这些字段的类型就是它们所属的结构体类型的关联类型。这里的泛化定义的意思是,我们在声明一个类型的时候,并不指定它的关联类型具体是哪一个或哪几个,而只定义相应的范围。等到这个类型被实例化的时候,再由那个编写实例化代码的程序员去设定类型参数的具体信息。这样的话,我们就可以定义出更加抽象的类型,而把具体化的工作留给使用它的人。

这与“声明接口类型,并把其作为某些函数参数或变量的类型”的编程手法有着异曲同工之妙,都是为了提升代码的灵活性,并增强其扩展的能力。不过需要注意的是,类型参数的值与函数参数的值或变量的值是不同的。一个类型参数的值必须是代表了某个已存在类型的标识符(或者说类型字面量)。另外,类型参数值代表的既可以是结构体这样的具体类型,也可以是接口那样的抽象类型。而函数参数或变量的值,则必须是某个具体类型的确切值。

另一方面,Go 语言的类型参数声明与它的函数参数声明是类似的。在上述代码的方括号中,K 和 E 分别是两个类型参数的标识符,类似于函数参数的名称。而~int 和~string 则分别是两个类型参数的类型约束(type constraint),类似于函数参数的类型声明。至于在 int 和 string 的前面为什么会有“~”这个符号,我们稍后再说。

正因为结构体类型 Pair 的声明里包含了类型参数 K 和 E 的声明,所以在它的主体当中,我们自然可以自由地使用 K 和 E。如代码所示,我们把 Pair 的字段 Key 的类型声明为 K,并把字段 Elem 的类型声明为 E。这样一来,Pair 的类型参数就与其主体实现了联动。这种联动将会在我们对 Pair 类型进行实例化的时候得以体现。

2. 对于带有类型参数的函数或类型,可以通过在它们的名称后面追加方括号并指定相应的类型参数值来进行实例化
示例如下:
var pair1 Pair[int, string] = Pair[int, string]{
    Key: 1,
    Elem: "a",
}
我们在这里声明了一个 Pair[int, string] 类型的变量 pair1,并把一个 Pair[int, string] 类型的值赋给了它。请注意,我们在对一个带有类型参数的类型进行实例化的时候,也必须对它的类型参数进行实例化。在这里,Pair[int, string] 中的 int 和 string 就是分别对 Pair 的类型参数 K 和 E 的实例化。

还记得吗?我们当初在声明 Pair 类型的时候,把它的类型参数列表编写成 [K ~int, E ~string]。其中,~int 是类型参数 K 的类型约束,而~string 则是类型参数 E 的类型约束。那么,这里的 Pair[int, string] 中的 int 和 string,分别作为 K 和 E 的值就是合法的,可以通过编译。至于为什么,我们马上就会说到。

先接着看其余的代码。因为在 Pair 类型的声明当中,字段 Key 的类型声明是 K,字段 Elem 的类型声明是 E。所以,在实例化 Pair[int, string] 的时候,我们自然就可以把某个 int 类型的值(这里是 1)赋给 Key,并把某个 string 类型的值(这里是"a")赋给 Elem。

3. 新符号“~”已被添加到了运算符和标点符号的集合中
我们再看 Pair 类型的声明:
type Pair[K ~int, E ~string] struct {    
  Key  K    
  Elem E
}
我们大可以把这里的符号“~”理解为“潜在”。代码“K ~int”的意思是,只要一个类型(假定为 A)的潜在类型是 int,那么就可以满足我们在这段代码中对 K 所做的类型约束,这就意味着 A 类型的字面量可以作为类型参数 K 的值。同样的道理,代码“E ~string”的意思是,只要一个类型(假定为 B)的潜在类型是 string,那么就可以满足我们在这段代码中对 E 所做的类型约束,这就意味着 B 类型的字面量可以作为类型参数 E 的值。也正因为如此,类型 Pair[int, string] 才是合乎语法规则的,它的类型参数都已通过了有效的实例化。

至于什么是“潜在类型”,Go 语言规范对此有明确的解释。具体内容是:每个类型 T 都有一个潜在类型。如果 T 是 Go 语言内置的布尔类型、数字类型、字符串类型之一,或者是某个类型字面量,那么相应的潜在类型就是 T 本身。否则,T 的潜在类型就是 T 在其声明中引用的类型的潜在类型。

下面举个例子。如果我们又编写了如下代码:
type MyInt int
type MyStr string
那么,对于当前的 Pair 类型声明来说,下面的代码也是合法的:
var pair2 Pair[MyInt, MyStr] = Pair[MyInt, MyStr]{
  Key: 2,
  Elem: "b",
}
更确切的说,类型 Pair[MyInt, MyStr] 是合乎语法规则的。因为,从前面的说明和代码可知,MyInt 的潜在类型是 int,而 MyStr 的潜在类型是 string。它们分别符合 Pair 类型的声明里对类型参数 K 和 E 的定义。

4. 接口类型的声明中现已允许嵌入任意类型,以及由符号“|”联结的联合类型和由~T 代表的类型元素,而不只是之前允许的其他接口类型。不过,这样的接口只能用于泛型中的类型约束

这段话是什么意思呢?我们来详细解析一下。为了配合 Go 语言对泛型的支持,官方团队对接口类型的声明语法做了很多的增强。使用 Go 语言的程序员们都知道,以前的接口声明只能像下面这样:
type Reader interface { 
 Read(p []byte) (n int, err error)
}
或者这样:
type ReadCloser interface { 
 Reader 
 Close() error
}
也就是说,在接口类型声明的主体中,我们可以嵌入任意数量的非重复的方法声明,也可以嵌入任何其他非重复的接口类型(用接口名称来代表)。我们称这两者为合法的接口元素。但除此之外,我们就不能添加任何东西了。然而,从 Go 1.18 开始,合法的接口元素又多了一种。Go 官方把这种接口元素称为类型集合(type set)。

一个类型集合可以仅包含单独的类型(由类型的名称代表,如:T),也可以包含代表了某个潜在类型的~T,还可以是联合类型(如:T1|T2|T3,其中的符号“|”可以被理解为“并集”),甚至可以是它们的混合(如:T1|~T2|~T3)。而且,对此我们可以分多行来写,只要它们所代表的类型是具体的且不存在重复即可。

不过要注意,包含了类型集合的接口类型只能被用在泛型相关的类型约束当中。例如,有如下代码:
type FloatUnion interface {
 ~float32 | ~float64
}

type FloatMaker interface {
 FloatUnion
 MakeZero()
} 
可以看到,含有类型集合的接口 FloatUnion 是可以被嵌入到其他接口类型的声明里面的(或者说,其他的接口类型可以扩展 FloatUnion 接口)。但如此一来,不但 FloatUnion 接口不能被用作任何值、变量、字段、参数、结果的类型,而且 FloatMaker 接口也会是这样。换句话说,对这种接口的用途限制具有传递性。

5. 新的内置标识符 any 是空接口的别名。它可以代替 interface{}
这一条说得很直白。单词 any 现在属于关键字了。它代表了空接口,也就是 interface{}。但是,空接口本身的含义却因泛型支持的出现而增多了。

从 Go 1.18 开始,空接口自带类型集合,并且它的类型集合包含了所有的非接口类型。注意,这里的“所有”不但代表当前已存在的所有非接口类型,而且还囊括了将来会在程序中出现的所有非接口类型。也就是说,空接口的类型集合拥有无限多的非接口类型。

这与空接口的设立初衷是一致的,即:空接口是包罗万象的,也是类型之树的唯一树根。在 Go 语言中,任何接口都是空接口的扩展接口,任何类型都是空接口的实现类型。这样来看,任何类型,不论是抽象类型还是具体类型,都是对空接口所代表的类型空间的进一步圈定。

对于类型参数中的类型约束来说也是这样。空接口的类型集合包括了无限多的非接口类型,这使得任何类型约束所代表的类型集合都将是空接口的类型集合的一个子集。这是“进一步圈定”的另一种表现形式。因此,空接口在 Go 语言全面支持泛型之后,依然能够作为其类型系统的根基。

6. 新的内置标识符 comparable 也代表一个接口类型
顾名思义,comparable 接口的含义是“可比较的”。只要一个类型符合以下两种情况中的一种,我们就可以断定它实现了 comparable 接口:
这个类型不是接口类型,并且它的值可以通过使用操作符 == 或 != 进行比较。
这个类型是接口类型,而且其类型集合中的每一个类型都实现了 comparable 接口。

比如,像 int、float32、rune、string 这样的基本类型肯定都实现了 comparable 接口,而切片(slice)类型、字典(map)类型以及任何的函数类型肯定就不是 comparable 接口的实现类型。再比如,我们在前面声明过的 FloatUnion:
type FloatUnion interface { 
 ~float32 | ~float64
}
可以确定它肯定实现了 comparable 接口。但如果我们把其中的~float64 替换为~[]float64,那么它就不再是 comparable 接口的扩展接口了。请注意,comparable 接口,以及任何直接或间接地嵌入了(或者说扩展了)comparable 的接口都只能被用于类型约束。它们不能被用作任何值、变量、字段、参数、结果的类型。显而易见,与 any 接口一样,comparable 接口也是专门为了类型参数(或者说泛型)才引入的。同样的,comparable 接口也自带了类型集合。它的类型集合包含了所有可以被比较的类型。这样的类型既可以是已经存在的,也可以是尚未出现的。

除了上述 6 个很重要的改动之外,Go 团队还为使用者们准备了 3 个实验性质的代码包。这些代码包并不在 Go 标准库当中,而是位于 Go 语言官方专门设立的实验包 golang.org/x/exp 里。这意味着,它们的 API 并不在 Go 1.x 的兼容性保证范围之内。并且,随着 Go 团队对泛型支持的进一步深入,这些代码包也可能会发生非向后兼容的变化。具体如下:
代码包 golang.org/x/exp/constraints:其中包含了对泛型编程非常有用的一些类型约束,如 constraints.Ordered 接口等等。

代码包 golang.org/x/exp/slices:其包含了不少对于切片操作非常有用的函数。而且,对于这些函数所操作的切片,其元素类型可以是任意的。比如,泛型函数 func BinarySearch(x []E, target E) (int, bool)、func CompactS ~[]E, E comparable S、func SortE constraints.Ordered 等等。从这些函数的签名上我们就可以看出,它们的通用性都得益于泛型。这样的通用性在 Go 语言支持泛型之前都是不可能存在的。

代码包 golang.org/x/exp/maps:与 golang.org/x/exp/slices 包类似,其中包含了一些针对字典的非常通用的泛型函数,如:func ClearM ~map[K]V, K comparable, V any、func CloneM ~map[K]V, K comparable, V any M、func KeysM ~map[K]V, K comparable, V any []K 等。

真正了解 Go 语言的程序员们肯定都知道,Go 团队经常会向 golang.org/x/exp 包中放入一些实验性的代码。他们往往会通过这种方式来实现一些或新鲜或激进的想法。如果某些代码在这里通过了使用者们的检验,并被认为已经足够成熟,那么它们就有希望被添加到 Go 语言的标准库当中。Go 团队正是依靠这种渐进式升级的方式,在保证标准库稳定的同时,使其创新性得以延续。

再说回泛型。尽管 Go 语言团队为了泛型做了如此多的工作,但到目前为止,Go 语言的泛型实现仍然存在一些小限制(主要体现更加细致的编程语法、值成员访问等方面)。不过,这些小限制在大多数情况下并不会妨碍我们在应用程序中使用泛型。而且,Go 语言团队也已经预告将在未来的版本中对此进行改进。所以,作者就不在这里一一列举了。

到这里,相信大家已经有所体会,“支持泛型”可以说是 Go 语言正式发布以来最大、最复杂且最重要的一项变化了。很显然,Go 语言本身的泛型支持工作离彻底完成还有一小段距离。而对于 Go 语言的技术社区来讲,更加重要的是,这项变化将意味着 Go 语言生态系统的大规模翻新。

到目前为止,Go 语言的生态系统已经非常庞大。因此,Go 语言的这项变化将会给 Go 社区带来很可观的压力。那些 Go 程序员们常用的第三方开发框架和工具必然需要一定的时间才能够跟进这项变化,而完美契合这项变化也许还需要更多的时间。这其实也是 Go 团队当初在考虑“是否添加泛型支持”的时候,涉及到的一个很重要的负面因素。

但无论如何,Go 语言在这件事情上的第一步(也是非常重要的一步)已经迈出并平稳落地了。我们现在只希望,Go 语言以及 Go 语言技术社区能够在这个良好的基础之上继续稳步前行、平滑过渡。
用户评论