Go 博客

名称(别名)有什么意义?

Robert Griesemer
2024 年 9 月 17 日

这篇文章是关于泛型别名类型的:它们是什么,以及为什么我们需要它们。

背景

Go 设计之初就是为了应对规模化编程。规模化编程意味着处理大量数据,也意味着处理大型代码库,以及许多工程师在长时间内维护这些代码库。

Go 将代码组织成包,通过将大型代码库拆分成更小、更易于管理的部分来实现规模化编程。这些部分通常由不同的人编写,并通过公共 API 连接。在 Go 中,这些 API 由包导出的标识符组成:导出的常量、类型、变量和函数。这也包括结构体的导出字段和类型的导出方法。

随着软件项目的发展或需求的变更,最初的代码包组织方式可能会变得不够适用,需要进行重构。重构可能涉及将导出的标识符及其相应的声明从旧包移动到新包。这也要求对移动的声明的所有引用都必须更新,以便它们指向新的位置。在大型代码库中,以原子方式进行此类更改可能不切实际或不可行;换句话说,无法在一次更改中完成移动并更新所有客户端。相反,更改必须逐步进行:例如,要“移动”一个函数 F,我们在新包中添加其声明,而不删除旧包中的原始声明。这样,客户端可以随着时间的推移逐步更新。一旦所有调用者都引用新包中的 F,旧的 F 声明就可以安全地删除(除非为了向后兼容必须无限期保留)。Russ Cox 在他 2016 年的关于代码库重构(借助于 Go)的文章中详细描述了重构。

将一个函数 F 从一个包移动到另一个包,同时保留在原包中,这很容易:只需要一个包装函数。要将 Fpkg1 移动到 pkg2pkg2 声明一个新函数 F(包装函数),其签名与 pkg1.F 相同,并且 pkg2.F 调用 pkg1.F。新的调用者可以调用 pkg2.F,旧的调用者可以调用 pkg1.F,然而在这两种情况下,最终调用的函数是相同的。

移动常量同样直接。变量需要多做一些工作:可能需要在新包中引入指向原始变量的指针,或者使用访问器函数。这不太理想,但至少是可行的。这里的重点是,对于常量、变量和函数,存在允许上述描述的逐步重构的现有语言特性。

但是移动类型呢?

在 Go 中,(限定的)标识符,或者简称为名称,决定了类型的标识:由包 pkg1 定义并导出的类型 T 与由包 pkg2 导出的类型 T其他方面完全相同的类型定义是不同的。这个特性使得将 T 从一个包移动到另一个包,同时在原包中保留一份副本变得复杂。例如,类型 pkg2.T 的值不可赋值给类型 pkg1.T 的变量,因为它们的类型名称以及类型标识不同。在逐步更新阶段,客户端可能同时拥有这两种类型的值和变量,即使编程者的意图是让它们拥有相同的类型。

为了解决这个问题,Go 1.9 引入了类型别名的概念。类型别名为现有类型提供一个新名称,而不会引入一个具有不同标识的新类型。

与常规的类型定义不同

type T T0

它声明了一个与声明右侧类型永远不相同的新类型,而别名声明

type A = T  // the "=" indicates an alias declaration

只为右侧的类型声明一个新名称 A:在这里,AT 指代相同且因此是同一类型的 T

别名声明使得在保留类型标识的同时,为一个给定类型提供一个新名称(在新包中!)成为可能

package pkg2

import "path/to/pkg1"

type T = pkg1.T

类型名称从 pkg1.T 变为 pkg2.T,但类型 pkg2.T 的值与类型 pkg1.T 的变量具有相同的类型。

泛型别名类型

Go 1.18 引入了泛型。从那个版本开始,类型定义和函数声明可以通过类型参数进行定制。出于技术原因,别名类型当时没有获得同样的能力。显然,那时也没有导出泛型类型并需要重构的大型代码库。

如今,泛型已经出现几年了,大型代码库正在利用泛型特性。最终会出现重构这些代码库的需求,随之而来的就是将泛型类型从一个包迁移到另一个包的需求。

为了支持涉及泛型类型的逐步重构,计划于 2025 年 2 月初发布的未来 Go 1.24 版本将完全支持别名类型上的类型参数,这符合提案 #46477。新语法遵循与类型定义和函数声明相同的模式,左侧的标识符(别名名称)后面可以跟一个可选的类型参数列表。在此更改之前,只能写

type Alias = someType

但现在我们也可以在别名声明中声明类型参数

type Alias[P1 C1, P2 C2] = someType

考虑前面的例子,现在使用泛型类型。原始包 pkg1 声明并导出一个泛型类型 G,带有一个适当约束的类型参数 P

package pkg1

type Constraint      someConstraint
type G[P Constraint] someType

如果需要从新包 pkg2 访问相同的类型 G,泛型别名类型正好适用 (playground)

package pkg2

import "path/to/pkg1"

type Constraint      = pkg1.Constraint  // pkg1.Constraint could also be used directly in G
type G[P Constraint] = pkg1.G[P]

注意,你不能简单地写

type G = pkg1.G

有几个原因

  1. 根据现有规范规则,泛型类型在被使用时必须被实例化。别名声明的右侧使用了类型 pkg1.G,因此必须提供类型实参。不这样做将需要为这种情况设置一个例外,从而使规范更加复杂。这种微小的便利是否值得增加复杂性尚不清楚。

  2. 如果别名声明不需要声明自己的类型参数,而是简单地从被别名化的类型 pkg1.G “继承”它们,那么 G 的声明就没有表明它是一个泛型类型。其类型参数和约束将必须从 pkg1.G 的声明中检索(pkg1.G 本身可能是一个别名)。这样会影响可读性,而可读性代码是 Go 项目的主要目标之一。

写下明确的类型参数列表起初可能看起来是不必要的负担,但它也提供了额外的灵活性。首先,别名类型声明的类型参数数量不必与被别名化类型的类型参数数量匹配。考虑一个泛型 map 类型

type Map[K comparable, V any] mapImplementation

如果将 Map 用作集合很常见,别名

type Set[K comparable] = Map[K, bool]

可能很有用 (playground)。因为它是别名,所以 Set[int]Map[int, bool] 等类型是相同的。如果 Set 是一个定义的(非别名)类型,情况就不会是这样。

此外,泛型别名类型的类型约束不必与被别名化类型的约束匹配,它们只需要满足它们即可。例如,重用上面的集合示例,可以定义一个 IntSet 如下

type integers interface{ ~int | ~int8 | ~int16 | ~int32 | ~int64 }
type IntSet[K integers] = Set[K]

这个 map 可以用任何满足 integers 约束的键类型进行实例化 (playground)。因为 integers 满足 comparable 约束,类型参数 K 可以作为 Set 的参数 K 的类型实参,遵循常规的实例化规则。

最后,因为别名也可以表示类型字面量,参数化别名使得创建泛型类型字面量成为可能 (playground)

type Point3D[E any] = struct{ x, y, z E }

需要说明的是,这些示例都不是“特例”,也没有以任何方式需要规范中的额外规则。它们直接遵循泛型已有的规则的应用。规范中唯一改变的是在别名声明中声明类型参数的能力。

关于类型名称的插曲

在引入别名类型之前,Go 只有一种形式的类型声明

type TypeName existingType

这种声明创建了一个与现有类型不同且是新的类型,并给这个新类型一个名称。自然而然地,将这类类型称为命名类型,因为它们具有类型名称,这与诸如 struct{ x, y int } 这样的未命名类型字面量形成对比。

随着 Go 1.9 中别名类型的引入,现在也可以给类型字面量赋予一个名称(别名)了。例如,考虑

type Point2D = struct{ x, y int }

突然之间,描述与类型字面量不同的概念命名类型不再那么有意义了,因为别名显然是类型的名称,因此被指代的类型(它可能是一个类型字面量,而不是类型名称!)可以说也可以称为“命名类型”。

由于(真正的)命名类型具有特殊属性(可以为其绑定方法,遵循不同的赋值规则等),为了避免混淆,谨慎起见使用了新术语。因此,自 Go 1.9 起,规范将以前称为命名类型的类型称为定义类型:只有定义类型具有与其名称绑定的属性(方法、可赋值性限制等)。定义类型通过类型定义引入,别名类型通过别名声明引入。在这两种情况下,都为类型赋予了名称。

Go 1.18 中泛型的引入使事情变得更加复杂。类型参数也是类型,它们有名称,并且与定义类型共享规则。例如,与定义类型一样,两个不同名称的类型参数表示不同的类型。换句话说,类型参数是命名类型,此外,它们在某些方面与 Go 最初的命名类型行为相似。

最重要的是,Go 的预声明类型(int, string 等)只能通过它们的名称访问,并且像定义类型和类型参数一样,如果它们的名称不同,则它们也不同(暂时忽略 byterune 别名类型)。预声明类型确实是命名类型。

因此,随着 Go 1.18 的发布,规范兜了一圈,正式重新引入了命名类型的概念,现在它包括“预声明类型、定义类型和类型参数”。为了修正表示类型字面量的别名类型,规范规定:“如果别名声明中给定的类型是命名类型,则该别名表示一个命名类型。”

暂时跳出 Go 命名法的框架,Go 中命名类型的正确技术术语可能是标称类型 (nominal type)。标称类型的标识明确地与其名称绑定,这正是 Go 命名类型(现在使用 1.18 术语)的含义。标称类型的行为与结构类型 (structural type) 形成对比,后者的行为仅取决于其结构,而不取决于其名称(如果它本来有名称的话)。总而言之,Go 的预声明类型、定义类型和类型参数类型都是标称类型,而 Go 的类型字面量和表示类型字面量的别名是结构类型。标称类型和结构类型都可以有名称,但有名称并不意味着该类型是标称类型,它只意味着它被命名了。

对于 Go 的日常使用而言,这一切都不重要,实践中可以安全地忽略这些细节。但在规范中,精确的术语很重要,因为它使得描述语言规则变得更容易。那么规范应该再次更改其术语吗?可能不值得折腾:不仅规范需要更新,许多配套文档也需要更新。不少关于 Go 的书籍可能会变得不准确。此外,“命名”虽然不够精确,但对于大多数人来说,可能比“标称”更直观清晰。它也与规范中使用的原始术语相符,即使现在对于表示类型字面量的别名类型需要一个例外。

可用性

实现泛型类型别名花费的时间比预期要长:必要的更改需要在 go/types 中添加一个新的导出 Alias 类型,然后添加记录该类型带有的类型参数的能力。在编译器方面,类似的更改也需要修改导出数据格式,即描述包导出的文件格式,现在该格式需要能够描述别名的类型参数。这些更改的影响不仅限于编译器,还会影响 go/types 的客户端,从而影响许多第三方包。这确实是一个影响大型代码库的更改;为了避免破坏现有功能,有必要分多个版本逐步推出。

经过所有这些工作,泛型别名类型最终将在 Go 1.24 中默认可用。

为了让第三方客户端做好代码准备,从 Go 1.23 开始,可以通过在调用 go 工具时设置 GOEXPERIMENT=aliastypeparams 来启用对泛型类型别名的支持。但是请注意,该版本仍然缺少对导出泛型别名的支持。

完整的支持(包括导出)已在 tip 版本中实现,GOEXPERIMENT 的默认设置将很快切换,以便泛型类型别名默认启用。因此,另一种选择是使用 Go 的最新 tip 版本进行实验。

一如既往,如果您遇到任何问题,请通过提交一个问题告知我们;我们对新功能的测试越充分,正式发布就会越顺利。

感谢阅读,祝您重构愉快!

下一篇文章:Go 诞生 15 周年
上一篇文章:在 Go 中构建基于 LLM 的应用
博客索引