Go 博客

别名(Alias)的含义?

Robert Griesemer
2024 年 9 月 17 日

这篇博文将介绍泛型别名类型,它们是什么,以及我们为什么需要它们。

背景

Go 的设计初衷是为了大规模编程。大规模编程意味着需要处理大量数据,同时也要处理大型代码库,并且有许多工程师在长时间内维护这些代码库。

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

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

将一个函数 F 从一个包移动到另一个包,同时在原始包中保留它,这很容易:只需要一个包装函数。要将 Fpkg1 移动到 pkg2pkg2 声明一个与 pkg1.F 具有相同签名的函数 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 声明并导出了一个带有类型参数 P 的泛型类型 G,该类型已得到适当约束。

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 的声明中检索(它本身可能是一个别名)。可读性会受到影响,而可读的代码是 Go 项目的主要目标之一。

显式地写出类型参数列表起初可能看起来是额外的负担,但它也提供了额外的灵活性。例如,别名类型声明的类型参数数量不必与被别名化类型的类型参数数量匹配。考虑一个泛型映射类型

type Map[K comparable, V any] mapImplementation

如果将 Map 用作集合(set)的场景很常见,那么别名

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]

此映射可以与满足 integers 约束的任何键类型进行实例化 (playground)。因为 integers 满足 comparable,所以类型参数 K 可以用作 SetK 参数的类型实参,遵循常规的实例化规则。

最后,因为别名也可以表示类型字面量,参数化别名使得创建泛型类型字面量成为可能 (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 的预声明类型(intstring 等)只能通过其名称访问,并且与已定义类型和类型参数一样,如果它们的名称不同,则表示不同的类型(暂时忽略 byterune 别名类型)。预声明类型确实是命名类型。

因此,随着 Go 1.18,规范完成了闭环,并正式重新引入了命名类型的概念,该概念现在包括“预声明类型、已定义类型和类型参数”。为了纠正别名类型表示类型字面量的情况,规范指出:“当别名所代表的类型是命名类型时,该别名就表示一个命名类型。”

暂时退一步,暂时不考虑 Go 的术语,对 Go 中的命名类型最准确的技术术语可能是名义类型。名义类型的身份明确地与其名称相关联,这正是 Go 的命名类型(现在使用 1.18 的术语)的全部内容。名义类型的行为与结构类型相反,结构类型的行为仅取决于其结构,而与名称无关(如果它有名称的话)。总而言之,Go 的预声明类型、已定义类型和类型参数类型都是名义类型,而 Go 的类型字面量和表示类型字面量的别名是结构类型。名义类型和结构类型都可以有名称,但有名称并不意味着类型是名义的,它只是意味着它是命名的。

这一切对日常使用 Go 而言都无关紧要,在实践中可以安全地忽略这些细节。但精确的术语在规范中很重要,因为它使得描述语言规则更加容易。那么规范是否应该再次更改术语?这可能不值得引起混乱:不仅需要更新规范,还需要更新大量支持文档。许多关于 Go 的书籍可能会变得不准确。此外,“命名”虽然不太精确,但对大多数人来说可能比“名义”更直观易懂。它也与规范中使用的原始术语相匹配,即使现在需要对表示类型字面量的别名类型进行例外处理。

可用性

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

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

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

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

一如既往,如果您遇到任何问题,请通过提交问题告知我们;我们对新功能进行测试的次数越多,整体发布就越顺利。

感谢,祝您重构愉快!

下一篇文章:Go 迎来 15 周年
上一篇文章:在 Go 中构建 LLM 驱动的应用程序
博客索引