Golang Type Alias 中有什么?

文摘   2024-09-25 18:04   美国  

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

背景

Go 语言的设计目标是大规模编程。大规模编程不仅意味着处理大量数据,还意味着处理大型代码库,许多工程师长期在这些代码库上工作。

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

随着软件项目的发展或需求的变化,原始的代码包组织可能变得不适当,需要进行重构。重构可能涉及将导出的标识符及其相应的声明从旧包移动到新包。这还需要更新对已移动声明的任何引用,使其指向新位置。在大型代码库中,原子地进行这种更改可能不切实际或不可行;换句话说,无法在单次更改中完成移动和更新所有客户端。相反,更改必须逐步进行:例如,要"移动"一个函数 F,我们在新包中添加其声明,而不删除旧包中的原始声明。这样,客户端可以随时间逐步更新。一旦所有调用者都引用新包中的 F,就可以安全地删除 F 的原始声明(除非出于向后兼容性必须无限期保留)。Russ Cox 在他 2016 年的文章  代码库重构(在 Go 的帮助下)[2]  中详细描述了重构。

将函数 F 从一个包移动到另一个包,同时在原始包中保留它很容易:只需要一个包装函数。要将 Fpkg1 移动到 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:在这里,AT 表示相同且因此相同的类型 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

原因有两个:

  1. 根据 现有的规范规则[2] ,泛型类型在使用时必须 实例化[11] 。别名声明的右侧使用类型 pkg1.G,因此必须提供类型参数。不这样做将需要为这种情况设置一个例外,使规范更加复杂。这种微小的便利显然不值得增加复杂性。

  2. 如果别名声明不需要声明自己的类型参数,而是简单地从别名类型 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 可以用作 SetK 参数的类型参数,遵循通常的实例化规则。

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

因此,在 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] 让我们知道;我们对新功能测试得越充分,总体推出就会越顺利。

感谢您的支持,祝重构愉快!

参考链接

  1. Go 博客: https://go.dev/blog/
  2. 代码库重构(在 Go 的帮助下): https://go.dev/talks/2016/refactor.article
  3. 定义: https://go.dev/ref/spec#Type_definitions
  4. 不同的: https://go.dev/ref/spec#Type_identity
  5. 赋值: https://go.dev/ref/spec#Assignability
  6. Go 1.9: https://go.dev/doc/go1.9
  7. 类型别名: https://go.dev/ref/spec#Alias_declarations
  8. Go 1.18: https://go.dev/doc/go1.18
  9. #46477: https://go.dev/issue/46477
  10. (playground): https://go.dev/play/p/wKOf6NbVtdw?v=gotip
  11. 实例化: https://go.dev/ref/spec#Instantiations
  12. (playground): https://go.dev/play/p/IxeUPGCztqf?v=gotip
  13. 满足: https://go.dev/ref/spec#Satisfying_a_type_constraint
  14. (playground): https://go.dev/play/p/0f7hOAALaFb?v=gotip
  15. (playground): https://go.dev/play/p/wql3NJaUs0o?v=gotip
  16. 类型字面量: https://go.dev/ref/spec#Types
  17. 名义类型: https://en.wikipedia.org/wiki/Nominal_type_system
  18. go/types: https://go.dev/pkg/go/types
  19. 问题: https://go.dev/issue/new

幻想发生器
图解技术本质
 最新文章