Golang 别名
Oct 5, 2024 13:30 · 3394 words · 7 minute read
来自 Go 官方博客 https://go.dev/blog/alias-names
背景
Go 专为大规模编程设计,不仅意味着用它来处理海量数据,还有构建大型代码库。我们通过将代码组织成包来实现大规模编程,把大型代码库拆分为更小、更易管理的部分,通常由不同的人员编写,并通过公开的 API 连接。这些 API 由包导出的标识符组成:导出的常量、类型、变量和函数。
随着软件项目的演进或需求的变化,最初将代码组织到包中的方式可能不够完善,需要重构。重构可能涉及将导出的标识符及其相应的声明从旧包移动到新包,这还需要更新引用方,指向新的位置。在大型代码库中,原子地做此类变更是不切实际的(即在单次变更中移动代码并更新所有客户端)。变更必须循序渐进:例如要“移动”函数 F,我们在新包中添加其声明而先不删除旧包中的原始声明。一旦所有调用方都引用新包中的 F,就可以安全地删除原始声明了(除非为了向前兼容必须无限期保留)。
将函数 F 从一个包复制到另一个是很容易的:就只要一个包装器函数。要将 F 从 pkg1 移动到 pkg2,pkg2 中声明一个与 pkg1.F 有着相同签名的新函数 F(包装器函数),并且 pkg2.F 调用 pkg1.F。新调用方调用 pkg2.F,旧调用方可以调用 pkg1.F,但在前后者最终被调用的函数是相同的。
移动常量同样简单,变量则麻烦一点:可能要在新包中引入指向原始变量的指针,或者使用访问器函数。不太理想但至少是可行的。这里的重点是:对于常量、变量和函数,现有语言特性允许如上所述的增量重构。
但是移动类型呢?
在 Go 中 限定的标识符,即名称,决定了类型的身份:由包 1 定义和导出的类型 T 和由包 2 导出的类型 T 是不同的。这个属性使得 T 从一个包复制到另一个包变得复杂。例如类型 pkg2.T 的值不能分配给类型 pkg1.T 的变量:因为它们的类型名称不同,所以它们的类型标识也不同。在增量更新阶段,客户端可能同时拥有这两种类型的变量和值,即便开发者的意图是让它们具有相同的类型。
为了解决这个问题,Go 1.9 引入了类型别名的概念。类型别名为现有类型提供了一个新名称,而无需引入具有不同标识的新类型。
与常规类型定义 type T T0
相比
type T T0
这声明了一种新类型,该类型永远和声明右侧的类型 T0 不同。而一个别名声明(alias declaration)
type A = T // the "=" indicates an alias declaration
仅为右侧的类型声明一个新名称 A:这里 A 和 T 表示相同的类型 T。
别名声明使得在保留类型标识的同时为给定类型提供一个新名称(在一个新包中!)成为可能:
package pkg2
import "path/to/pkg1"
type T = pkg1.T
类型的名称从 pkg1.T 更改为 pkg2.T,但类型 pkg2.T 的值与 pkg1.T 的值的类型是相同的。
泛型别名类型
Go 1.18 引入了泛型。自那次发布以来,类型定义和函数声明可以通过类型参数来定制。由于技术原因,别名类型在当时没有获得相同的能力。显然当时也没有大型代码库导出泛型类型并需要重构。
如今,Go 泛型已经存在了几年,大型代码库正在使用这个特性。最终将会出现重构这些代码库的需求:要将泛型类型从一个包迁移至另一个包。
为了支持涉及泛型类型的增量重构,计划于 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
,出于几个原因:
-
根据规范,泛型类型在使用时必须被实例化。别名声明的右侧使用类型 pkg1.G,因此必须提供类型参数。不这样做的话就要为此情况设置一个例外,使规范更复杂。很明显这不值得。
-
如果别名声明不需要声明它自己的类型参数,而是简单地从类型 pkg1.G “继承”它们,那么 A 提供的声明就不能表明它是一个泛型类型。其类型参数和约束必须从 pkg1.G 的声明处获取(甚至它本身可能也是个别名)。可读性将受到影响,而代码可读是 Go 项目的主要目标之一。
乍一看写下显式的类型参数列表似乎是没必要的,但这也提供了额外的灵活性。首先,别名类型声明的类型参数数量不必与原类型相匹配。思考一个通用映射类型:
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]
该映射可用满足整数约束的任何键类型实例化(playground)。因为整数满足可比较,类型参数 K 可用作 Set
的 K 参数的实参,遵循通常的实例化规则。
最后,由于别名也可以表示字面量,参数化的别名使得创建泛型类型字面量成为可能(playground):
type Point3D[E any] = struct{ x, y, z E }
要明确的是,这些例子都不是“特殊情况”,也不需要在规范中新增额外的规则,直接遵循现有泛型规则。规范唯一要改的是可以在别名声明中定义类型参数。
关于类型名称的插曲
在引入别名类型之前,Go 只有一种类型声明形式:
type TypeName existingType
该声明从现有类型创建了一种新的“不同”类型,并为之命名,称其为具名类型是自然而然的。
在 Go 1.9 中引入别名类型后,也可以为类型字面量命名(即别名)了。例如:
type Point2D = struct{ x, y int }
突然之间,具名类型与类型字面量是不同的这件事不再有意义,因为类型别名显然是一个类型的名称,因此它指示的类型按理说也可被称为“具名类型”。
因为具名类型有特殊属性(可以给它们绑定方法;遵循不同的赋值规则等等),所以使用一个新术语来避免混淆看上去是明智的。因此自 Go 1.9 以来,规范将以前被称为具名类型称为被定义的类型(defined types):只有被定义的类型具有与其名字相关联的属性(方法等等)。被定义的类型通过类型定义引入,而别名类型则通过别名声明引入:两者都会给类型指定名称。
Go 1.18 中引入的泛型让事情变得复杂。类型参数也是类型,它们有名称,并与定义的类型共享规则。例如,与被定义的类型一样,两个不同名的类型参数表示不同类型。换句话说,类型参数也是具名类型,而且它们在某些方面表现得和 Go 最初的具名类型相似。
最重要的是,Go 的预先声明类型(int、string 等等)只能通过它们的名称来访问,就像定义的类型和类型参数一样,如果名称不同它们就不同。预先声明的类型是真正的具名类型。
因此在 Go 1.18 中规范绕了一大圈,正式重新引入具名类型的概念,现在包括“预先声明的类型、被定义的类型和类型参数”。为了纠正别名类型表示类型字面量,规范指出:如果别名声明中给出的类型是具名类型,那么别名就表示具名类型。
退一步讲,Go 中具名类型的正确技术术语可能叫“名义类型”。一个名义类型的身份明确地与其名称相关,这是 Go 具名类型的意义所在。名义类型的行为与结构类型相反,后者的行为仅取决于其结构而非名称。总而言之,Go 的预声明、已定义还有类型参数都是名义类型,而 Go 的类型字面量和别名表示的类型字面量都是结构类型。名义类型和结构类型都可以有名称,但有名称不意味着它就是名义类型。
对于 Go 的日常使用来说这些都无关紧要,可以放心地忽略这些细节。但在规范中精确的术语很重要,因为这样更容易描述语言规则。那么规范是否应该再次改变其术语呢?这可能不值当:不仅仅是规范本身需要更新,还有许多支持文档,因此大量的 Go 书籍可能变得不准确。此外,“具名”虽然不精确,但对大多数人来说比“名义”直观清晰。这也和规范中原先的术语相匹配,即使现在要为表示类型字面量的别名类型添加例外。
可用性
实现泛型类型别名比预期花的时间更长:需要向 go/types 添加一个新的导出类型 Alias
,然后赋予使用该类型记录类型参数的能力。在编译器方面,类似的变更也需要修改导出数据格式,即描述包导出的文件格式,现在需要能够描述别名的类型参数。这些变更不仅限于编译器,还影响到使用 go/types 的客户端,因此很多三方库都会波及。这是一个影响大型代码库的变动,为了避免破坏,在多个版本中逐步推出是有必要的。
经过这些铺垫工作,泛型别名类型最终将在 Go 1.24 中默认可用。
为了让三方库做好准备,从 Go 1.23 开始,通过在调用 go 工具时设置 GOEXPERIMENT=aliastypeparams
来支持泛型类型别名。但要注意的是该版本仍不支持导出的泛型别名。