这篇文章讨论了泛型别名类型,它们是什么,以及为什么我们需要它们。
背景
Go 语言的设计目标是大规模编程。大规模编程不仅意味着处理大量数据,还意味着处理大型代码库,许多工程师长期在这些代码库上工作。
Go 将代码组织成包,通过将大型代码库分割成更小、更易管理的部分,实现了大规模编程。这些部分通常由不同的人编写,并通过公共 API 连接。在 Go 中,这些 API 由包导出的标识符组成:导出的常量、类型、变量和函数。这还包括结构体的导出字段和类型的方法。
随着软件项目的发展或需求的变化,原始的代码包组织可能变得不适当,需要进行重构。重构可能涉及将导出的标识符及其相应的声明从旧包移动到新包。这还需要更新对已移动声明的任何引用,使其指向新位置。在大型代码库中,原子地进行这种更改可能不切实际或不可行;换句话说,无法在单次更改中完成移动和更新所有客户端。相反,更改必须逐步进行:例如,要"移动"一个函数 F
,我们在新包中添加其声明,而不删除旧包中的原始声明。这样,客户端可以随时间逐步更新。一旦所有调用者都引用新包中的 F
,就可以安全地删除 F
的原始声明(除非出于向后兼容性必须无限期保留)。Russ Cox 在他 2016 年的文章 代码库重构(在 Go 的帮助下)[2] 中详细描述了重构。
将函数 F
从一个包移动到另一个包,同时在原始包中保留它很容易:只需要一个包装函数。要将 F
从 pkg1
移动到 pkg2
,pkg2
声明一个与 pkg1.F
具有相同签名的新函数 F
(包装函数),并且 pkg2.F
调用 pkg1.F
。新的调用者可以调用 pkg2.F
,旧的调用者可以调用 pkg1.F
,但在两种情况下最终调用的函数都是相同的。
移动常量同样简单。变量需要更多工作:可能需要在新包中引入指向原始变量的指针,或者使用访问器函数。这不太理想,但至少是可行的。这里的要点是,对于常量、变量和函数,现有的语言特性允许进行上述增量重构。
但是移动类型呢?
在 Go 中,(限定的)标识符,或简称为名称,决定了类型的身份:由包 pkg1
定义[3] 和导出的类型 T
与包 pkg2
导出的其他完全相同的类型 T
的定义是 不同的[4] 。这个属性使得在保留原始包中副本的同时将 T
从一个包移动到另一个包变得复杂。例如,类型 pkg2.T
的值不能 赋值[5] 给类型 pkg1.T
的变量,因为它们的类型名称,因此类型身份不同。在增量更新阶段,客户端可能同时拥有这两种类型的值和变量,尽管程序员的意图是它们具有相同的类型。
为了解决这个问题, Go 1.9[6] 引入了 类型别名[7] 的概念。类型别名为现有类型提供了一个新名称,而不引入具有不同身份的新类型。
与常规的 类型定义[2] 相比
type T1 T
它声明了一个永远不会与声明右侧类型相同的新类型,而 别名声明[6]
type A = T
只为右侧的类型声明了一个新名称 A
:在这里,A
和 T
表示相同且因此相同的类型 T
。
别名声明使得可以为给定类型提供一个新名称(在新包中!),同时保持类型身份:
package pkg2 import "pkg1" type T = pkg1.T
类型名称已从 pkg1.T
更改为 pkg2.T
,但类型 pkg2.T
的值与类型 pkg1.T
的变量具有相同的类型。
泛型别名类型
Go 1.18[8] 引入了泛型。从那个版本开始,类型定义和函数声明可以通过类型参数进行自定义。由于技术原因,别名类型当时没有获得相同的能力。显然,当时也没有大型代码库导出泛型类型并需要重构。
如今,泛型已经存在了几年,大型代码库正在使用泛型特性。最终将需要重构这些代码库,并且需要将泛型类型从一个包迁移到另一个包。
为了支持涉及泛型类型的增量重构,计划于 2025 年 2 月初发布的未来 Go 1.24 版本将根据提案 #46477[9] 完全支持别名类型的类型参数。新语法遵循与类型定义和函数声明相同的模式,在左侧的标识符(别名名称)后面有一个可选的类型参数列表。在此更改之前,只能写:
type A = pkg.G[T1, T2]
但现在我们也可以在别名声明中声明类型参数:
type A[T1, T2 any] = pkg.G[T1, T2]
考虑之前的例子,现在使用泛型类型。原始包 pkg1
声明并导出了一个带有适当约束的类型参数 P
的泛型类型 G
:
package pkg1 type G[P any] struct{ ... }
如果需要从新包 pkg2
访问相同的类型 G
,泛型别名类型就是解决方案 (playground)[10] :
package pkg2 import "pkg1" type G[P any] = pkg1.G[P]
注意,不能简单地写
type G = pkg1.G
原因有两个:
根据 现有的规范规则[2] ,泛型类型在使用时必须 实例化[11] 。别名声明的右侧使用类型
pkg1.G
,因此必须提供类型参数。不这样做将需要为这种情况设置一个例外,使规范更加复杂。这种微小的便利显然不值得增加复杂性。如果别名声明不需要声明自己的类型参数,而是简单地从别名类型
pkg1.G
"继承"它们,那么A
的声明就没有迹象表明它是一个泛型类型。它的类型参数和约束将不得不从pkg1.G
的声明中检索(而pkg1.G
本身可能是一个别名)。可读性将受到影响,而可读性是 Go 项目的主要目标之一。
一开始,写下显式的类型参数列表可能看起来是不必要的负担,但它也提供了额外的灵活性。首先,别名类型声明的类型参数数量不必与别名类型的类型参数数量匹配。考虑一个泛型映射类型:
type Map[K comparable, V any] map[K]V
如果 Map
作为集合的用法很常见,那么别名
type Set[T comparable] = Map[T, bool]
可能会很有用 (playground)[12] 。因为它是一个别名,所以 Set[int]
和 Map[int, bool]
这样的类型是相同的。如果 Set
是一个 定义的[2] (非别名)类型,情况就不是这样了。
此外,泛型别名类型的类型约束不必与别名类型的约束匹配,它们只需要 满足[13] 它们。例如,重用上面的集合示例,可以定义一个 IntSet
如下:
type integers interface { ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr } type IntSet[K integers] = Set[K]
这个映射可以用任何满足 integers
约束的键类型实例化 (playground)[14] 。因为 integers
满足 comparable
,所以类型参数 K
可以用作 Set
的 K
参数的类型参数,遵循通常的实例化规则。
最后,因为别名也可以表示类型字面量,参数化别名使得创建泛型类型字面量成为可能 (playground)[15] :
type Pair[T any] = struct{ x, y T }
需要明确的是,这些例子都不是"特殊情况",也不需要在规范中添加额外的规则。它们直接来自于为泛型设置的现有规则的应用。规范中唯一改变的是在别名声明中声明类型参数的能力。
关于类型名称的插曲
在引入别名类型之前,Go 只有一种形式的类型声明:
type T U
这个声明从现有类型创建一个新的不同类型,并给这个新类型一个名称。将这种类型称为命名类型是很自然的,因为它们有一个类型名称,与未命名的 类型字面量[16] (如 struct{ x, y int }
)相对。
随着 Go 1.9 中别名类型的引入,也可以给类型字面量一个名称(别名)。例如,考虑:
type T = struct{ x, y int }
突然间,命名类型这个概念描述与类型字面量不同的东西就不那么有意义了,因为别名名称显然是类型的名称,因此所表示的类型(可能是类型字面量,而不是类型名称!)可以说是"命名类型"。
因为(正确的)命名类型具有特殊属性(可以绑定方法,遵循不同的赋值规则等),为了避免混淆,使用新术语似乎是明智的。因此,从 Go 1.9 开始,规范将以前称为命名类型的类型称为定义类型:只有定义类型具有与其名称相关的属性(方法、赋值限制等)。定义类型通过类型定义引入,别名类型通过别名声明引入。在这两种情况下,都给类型命名。
Go 1.18 中泛型的引入使事情变得更加复杂。类型参数也是类型,它们有一个名称,并且与定义类型共享规则。例如,像定义类型一样,两个不同名称的类型参数表示不同的类型。换句话说,类型参数是命名类型,而且在某些方面,它们的行为类似于 Go 的原始命名类型。
最后,Go 的预声明类型(int
、string
等)只能通过它们的名称访问,并且像定义类型和类型参数一样,如果它们的名称不同,它们就是不同的(暂时忽略 byte
和 rune
别名类型)。预声明类型确实是命名类型。
因此,在 Go 1.18 中,规范重新引入了 命名类型[15] 的概念,现在包括"预声明类型、定义类型和类型参数"。为了纠正别名类型表示类型字面量的情况,规范说:"如果别名声明中给出的类型是命名类型,则别名表示命名类型。"
暂时跳出 Go 术语的框框,Go 中命名类型的正确技术术语可能是 名义类型[17] 。名义类型的身份明确地与其名称相关联,这正是 Go 的命名类型(现在使用 1.18 术语)的全部内容。名义类型的行为与结构类型形成对比,结构类型的行为只取决于其结构,而不取决于其名称(如果它一开始就有名称的话)。综上所述,Go 的预声明、定义和类型参数类型都是名义类型,而 Go 的类型字面量和表示类型字面量的别名是结构类型。名义类型和结构类型都可以有名称,但有名称并不意味着类型是名义的,它只是意味着它是命名的。
这些细节对于 Go 的日常使用并不重要,实际上可以安全地忽略。但精确的术语在规范中很重要,因为它使描述语言规则变得更容易。那么规范是否应该再次更改其术语呢?这可能不值得造成混乱:不仅需要更新规范,还需要更新大量支持文档。相当数量的关于 Go 的书籍可能会变得不准确。此外,"命名"虽然不太精确,但对大多数人来说可能比"名义"更直观。它也与规范中最初使用的术语相匹配,即使现在需要为表示类型字面量的别名类型设置一个例外。
可用性
实现泛型类型别名所花费的时间比预期更长:必要的更改需要在 go/types
[18] 中添加一个新的导出 Alias
类型,然后添加使用该类型记录类型参数的能力。在编译器方面,类似的更改还需要修改导出数据格式(描述包导出内容的文件格式),现在需要能够描述别名的类型参数。这些更改的影响不仅限于编译器,还影响了 go/types
的客户端以及许多第三方包。这确实是一个影响大型代码库的变更;为了避免破坏现有功能,有必要在几个版本中逐步推出。
经过所有这些工作,泛型别名类型最终将在 Go 1.24 中默认可用。
为了让第三方客户端能够准备好他们的代码,从 Go 1.23 开始,可以通过在调用 go
工具时设置 GOEXPERIMENT=aliastypeparams
来启用对泛型类型别名的支持。但请注意,该版本仍然缺少对导出泛型别名的支持。
完整支持(包括导出)已在 tip 版本中实现,GOEXPERIMENT
的默认设置将很快切换,使泛型类型别名默认启用。因此,另一个选择是使用最新的 Go tip 版本进行实验。
一如既往,如果您遇到任何问题,请通过提交 问题[19] 让我们知道;我们对新功能测试得越充分,总体推出就会越顺利。
感谢您的支持,祝重构愉快!
参考链接
- Go 博客: https://go.dev/blog/
- 代码库重构(在 Go 的帮助下): https://go.dev/talks/2016/refactor.article
- 定义: https://go.dev/ref/spec#Type_definitions
- 不同的: https://go.dev/ref/spec#Type_identity
- 赋值: https://go.dev/ref/spec#Assignability
- Go 1.9: https://go.dev/doc/go1.9
- 类型别名: https://go.dev/ref/spec#Alias_declarations
- Go 1.18: https://go.dev/doc/go1.18
- #46477: https://go.dev/issue/46477
- (playground): https://go.dev/play/p/wKOf6NbVtdw?v=gotip
- 实例化: https://go.dev/ref/spec#Instantiations
- (playground): https://go.dev/play/p/IxeUPGCztqf?v=gotip
- 满足: https://go.dev/ref/spec#Satisfying_a_type_constraint
- (playground): https://go.dev/play/p/0f7hOAALaFb?v=gotip
- (playground): https://go.dev/play/p/wql3NJaUs0o?v=gotip
- 类型字面量: https://go.dev/ref/spec#Types
- 名义类型: https://en.wikipedia.org/wiki/Nominal_type_system
go/types
: https://go.dev/pkg/go/types- 问题: https://go.dev/issue/new