原文在这里

由 Robert Griesemer 发布于 2024年9月17日

这篇文章讨论了通用别名类型,它们是什么,以及我们为何需要它们。

背景

Go 语言是为大规模编程而设计的。大规模编程意味着需要处理大量数据,以及庞大的代码库,许多工程师在这些代码库上长时间工作。

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

随着软件项目的不断发展或需求的变化,代码最初的包组织可能显得不足,需进行重构。重构可能涉及将导出的标识符及其相应声明从旧包移动到新包。这也要求更新所有对移动声明的引用,以便它们指向新位置。在大型代码库中,原子性地进行这样的更改可能不切实际或不可行;换句话说,无法在一次更改中完成移动和更新所有客户端。相反,更改必须逐步进行:例如,要“移动”函数 F,我们在新包中添加其声明,而不删除旧包中的原始声明。这样,客户端可以逐步更新。等到所有调用者都在新包中引用 F 时,原始的 F 声明可以安全删除(除非为了向后兼容必须保留)。Russ Cox 在他2016年的文章Codebase Refactoring(借助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  // "=" 表示别名声明

只声明一个新名称 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的访问,泛型别名类型正是解决方案(playground):

package pkg2

import "path/to/pkg1"

type Constraint = pkg1.Constraint  // pkg1.Constraint也可以直接在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]

这个映射可以用任何满足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等)只能通过其名称访问,并且像定义类型和类型参数一样,如果它们的名称不同,它们也是不同的(暂时忽略byte和rune别名类型)。预声明类型确实是命名类型。

因此,从Go 1.18开始,规范正式重新引入了命名类型的概念,现在包括“预声明类型、定义类型和类型参数”。为了纠正表示类型字面量的别名类型,规范说:“如果别名声明中给出的类型是命名类型,则别名表示一个命名类型。”

跳出Go术语框框来看,命名类型的正确技术术语可能是名义类型。名义类型的身份明确与其名称相关,这正是Go的命名类型(现在使用1.18的术语)所涉及的内容。名义类型的行为与结构类型形成对比,结构类型的行为仅取决于其结构,而不是其名称(如果它首先有一个名称)。综上所述,Go的预声明、定义和类型参数类型都是名义类型,而Go的类型字面量和表示类型字面量的别名是结构类型。名义和结构类型都可以有名称,但有名称并不意味着类型是名义类型,它只是意味着它是命名的。

这些在Go的日常使用中并不重要,实际上可以忽略细节。但精确的术语在规范中很重要,因为它使描述语言规则变得更容易。那么规范是否应该再一次改变其术语?这可能不值得:不仅规范需要更新,还有很多支持文档。许多关于Go的书可能会变得不准确。此外,对于大多数人来说,“命名”可能比“名义”更直观清晰。它也匹配了规范中使用的原始术语,即使现在需要为表示类型字面量的别名类型做一个例外。

可用性

实现泛型类型别名花费的时间比预期的要长:必要的更改需要向 go/types 添加一个新的导出别名类型,然后添加记录类型参数的能力。在编译器方面,类似的更改还需要修改导出数据格式,即描述包导出的文件格式,现在需要能够描述别名的类型参数。这些更改的影响不限于编译器,还影响go/types的客户端以及许多第三方包。这确实是一个影响大型代码库的更改;为了避免破坏,必须通过多个版本进行增量推出。

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

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

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

一如既往,如果遇到任何问题,请提交问题报告;我们测试新功能的越好,一般推出就会越顺利。

感谢并祝重构愉快!


孟斯特

声明:本作品采用署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)进行许可,使用时请注明出处。
Author: mengbin
blog: mengbin
Github: mengbin92
cnblogs: 恋水无意
腾讯云开发者社区:孟斯特