Go 博客
什么是 (别名) 名称?
这篇文章讨论了泛型别名类型,它们是什么以及为什么我们需要它们。
背景
Go 旨在用于大规模编程。大规模编程意味着处理大量数据,但也意味着处理大型代码库,以及许多工程师在很长一段时间内处理这些代码库。
Go 将代码组织成包,通过将大型代码库拆分为更小、更易于管理的部分(通常由不同的人编写)并通过公共 API 连接起来,从而实现了大规模编程。在 Go 中,这些 API 由包导出的标识符组成:导出的常量、类型、变量和函数。这包括结构体的导出字段和类型的函数。
随着软件项目的不断发展或需求的改变,最初将代码组织成包的方式可能变得不充分,需要进行重构。重构可能涉及将导出的标识符及其相应的声明从旧包移动到新包。这还需要更新对已移动声明的所有引用,以便它们指向新位置。在大型代码库中,以原子方式进行此类更改可能不切实际或不可行;或者换句话说,在一次更改中进行移动并更新所有客户端。相反,更改必须逐步进行:例如,要“移动”函数F
,我们在新包中添加其声明,而不删除旧包中的原始声明。这样,可以随着时间的推移逐步更新客户端。一旦所有调用者都引用新包中的F
,就可以安全地删除F
的原始声明(除非出于向后兼容性的原因需要无限期保留)。Russ Cox 在他 2016 年关于代码库重构(借助 Go)的文章中详细描述了重构。
将函数F
从一个包移动到另一个包,同时在原始包中保留它很容易:只需要一个包装函数。要将F
从pkg1
移动到pkg2
,pkg2
声明一个新的函数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
:这里,A
和T
表示相同且因此相同的类型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
,泛型别名类型正是解决方法(游乐场)
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
的类型参数,则G
的声明不会提供任何指示表明它是一个泛型类型。它的类型参数和约束必须从pkg1.G
的声明中检索(pkg1.G
本身也可能是一个别名)。可读性会受到影响,但可读代码是 Go 项目的主要目标之一。
一开始,写下显式的类型参数列表可能看起来像是多余的负担,但它也提供了额外的灵活性。例如,别名类型声明的类型参数数量不必与别名类型的类型参数数量匹配。考虑一个泛型映射类型
type Map[K comparable, V any] mapImplementation
如果将Map
用作集合的情况很常见,则别名
type Set[K comparable] = Map[K, bool]
可能很有用(游乐场)。因为它是别名,所以诸如Set[int]
和Map[int, bool]
之类的类型是相同的。如果Set
是定义的(非别名)类型,则不会发生这种情况。
此外,泛型别名类型的类型约束不必与别名类型的约束匹配,它们只需要满足它们。例如,重用上面的集合示例,可以定义一个IntSet
,如下所示
type integers interface{ ~int | ~int8 | ~int16 | ~int32 | ~int64 }
type IntSet[K integers] = Set[K]
此映射可以使用满足integers
约束的任何键类型进行实例化(游乐场)。因为integers
满足comparable
,所以类型参数K
可用作Set
的K
参数的类型参数,遵循通常的实例化规则。
最后,因为别名也可以表示类型字面量,所以参数化别名可以创建泛型类型字面量(游乐场)
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
等)只能通过它们的名称访问,并且与已定义的类型和类型参数一样,如果它们的名称不同,则表示不同的类型(暂时忽略一下 byte
和 rune
别名类型)。预声明类型确实是命名类型。
因此,在 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
来启用对泛型类型别名的支持。但是,请注意,该版本仍缺少对导出泛型别名的支持。
完整的支持(包括导出)已在 tip 版本中实现,并且 GOEXPERIMENT
的默认设置很快就会切换,以便默认启用泛型类型别名。因此,另一种选择是在 tip 版本中体验 Go 的最新版本。
与往常一样,如果您遇到任何问题,请通过提交问题告知我们;我们对新功能测试得越好,整体推出就会越顺利。
感谢您的阅读,祝您重构愉快!
下一篇文章:Go 15 周年
上一篇文章:在 Go 中构建基于大型语言模型的应用程序
博客索引