Go 博客

所有可比较类型

Robert Griesemer
2023 年 2 月 17 日

2 月 1 日,我们发布了最新的 Go 版本 1.20,其中包含了一些语言更改。在这里,我们将讨论其中一项更改:预声明的 comparable 类型约束现在由所有 可比较类型满足。令人惊讶的是,在 Go 1.20 之前,一些可比较类型并不满足 comparable

如果您感到困惑,那么您来对地方了。考虑一个有效的 map 声明

var lookupTable map[any]string

其中 map 的键类型是 any(它是一种 可比较类型)。这在 Go 中运行良好。另一方面,在 Go 1.20 之前,看似等价的泛型 map 类型

type genericLookupTable[K comparable, V any] map[K]V

可以像普通 map 类型一样使用,但在将 any 用作键类型时会产生编译时错误

var lookupTable genericLookupTable[any, string] // ERROR: any does not implement comparable (Go 1.18 and Go 1.19)

从 Go 1.20 开始,此代码将可以正常编译。

Go 1.20 之前的 comparable 行为尤其令人讨厌,因为它阻止了我们编写最初希望通过泛型实现的泛型库。提议的 maps.Clone 函数

func Clone[M ~map[K]V, K comparable, V any](m M) M { … }

可以编写,但不能用于像 lookupTable 这样的 map,原因与我们的 genericLookupTable 不能以 any 作为键类型的原因相同。

在这篇博文中,我们希望阐明这背后的语言机制。为此,我们将从一些背景信息开始。

类型参数和约束

Go 1.18 引入了泛型,并随之引入了 类型参数 作为一种新的语言构造。

在普通函数中,参数的值范围受其类型限制。类似地,在泛型函数(或类型)中,类型参数的类型范围受其 类型约束 限制。因此,类型约束定义了允许作为类型参数的类型集合

Go 1.18 还改变了我们看待接口的方式:虽然过去接口定义了一组方法,但现在接口定义了一个类型集。这种新的观点是完全向后兼容的:对于由接口定义的任何一组方法,我们可以想象实现这些方法的(无限)类型集合。例如,给定一个 io.Writer 接口,我们可以想象所有具有适当签名的 Write 方法的类型的无限集合。所有这些类型都实现该接口,因为它们都具有必需的 Write 方法。

但新的类型集观点比旧的方法集观点更强大:我们可以显式地描述一个类型集,而不仅仅是通过方法间接描述。这为我们提供了控制类型集的新方法。从 Go 1.18 开始,接口可以嵌入其他接口,也可以嵌入任何类型、类型联合或具有相同 底层类型的无限类型集。然后将这些类型包含在 类型集计算中:联合表示法 A|B 表示“类型 A 或类型 B”,而 ~T 表示法代表“所有底层类型为 T 的类型”。例如,接口

interface {
    ~int | ~string
    io.Writer
}

定义了所有底层类型为 intstring,并且还实现了 io.WriterWrite 方法的类型集合。

这种广义接口不能用作变量类型。但是,因为它们描述了类型集,所以它们被用作类型约束,即类型集。例如,我们可以编写一个泛型 min 函数

func min[P interface{ ~int64 | ~float64 }](x, y P) P

它接受任何 int64float64 参数。(当然,更现实的实现会使用一个枚举所有具有 < 运算符的基本类型的约束。)

顺便说一句,因为枚举显式类型而不带方法很常见,一些 语法糖允许我们省略包围的 interface{},从而得到简洁且更惯用的

func min[P ~int64 | ~float64](x, y P) P { … }

使用新的类型集观点,我们也需要一种新的方式来解释实现接口的含义。我们说一个(非接口)类型 T 实现接口 I 当且仅当 T 是接口的类型集中的元素。如果 T 本身就是一个接口,它就描述了一个类型集。该集合中的每一个类型都必须也在 I 的类型集中,否则 T 将包含不实现 I 的类型。因此,如果 T 是一个接口,当 T 的类型集是 I 的类型集的子集时,T 就实现了接口 I

现在我们具备了理解约束满足的所有要素。如前所述,类型约束描述了类型参数可接受的参数类型集。当类型参数属于约束接口描述的集合时,它就满足相应的类型参数约束。换句话说,类型参数实现了约束。在 Go 1.18 和 Go 1.19 中,约束满足意味着约束实现。我们将在稍后看到,在 Go 1.20 中,约束满足不再完全是约束实现。

对类型参数值的操作

类型约束不仅指定了类型参数可接受的类型参数,它还确定了对类型参数值可进行的操作。正如我们所料,如果一个约束定义了一个方法,比如 Write,那么就可以在相应类型参数的值上调用 Write 方法。更普遍地说,对于约束定义的类型集中的所有类型都支持的操作,例如 +*,在相应的类型参数值上是允许的。

例如,在 min 示例中,在函数体中,允许在类型参数 P 的值上执行 int64float64 类型都支持的任何操作。这包括所有基本算术运算,以及比较运算符,如 <。但它不包括按位运算,如 &|,因为这些运算在 float64 值上未定义。

可比较类型

与其他一元和二元运算符不同,== 不仅定义在有限的 预声明类型集上,而且定义在无限的各种类型上,包括数组、结构体和接口。在约束中不可能枚举所有这些类型。如果我们关心的是预声明类型以外的类型,我们需要一种不同的机制来表达类型参数必须支持 ==(当然还有 !=)。

我们通过预声明类型 comparable 来解决这个问题,该类型随 Go 1.18 一起引入。comparable 是一种接口类型,其类型集是可比较类型的无限集,当我们需要类型参数支持 == 时,可以使用它作为约束。

然而,comparable 所包含的类型集与 Go 规范定义的所有可比较类型的集合并不相同。根据构造,接口(包括 comparable)指定的类型集不包含接口本身(或任何其他接口)。因此,像 any 这样的接口不包含在 comparable 中,尽管所有接口都支持 ==。这是怎么回事?

接口(以及包含它们的复合类型的接口)的比较可能会在运行时发生恐慌:当动态类型,即接口变量中存储的实际值的类型,不可比较时。考虑我们最初的 lookupTable 示例:它接受任意值作为键。但是,如果我们尝试输入一个键不支持 == 的值,例如切片值,我们会得到一个运行时恐慌

lookupTable[[]int{}] = "slice"  // PANIC: runtime error: hash of unhashable type []int

相反,comparable 只包含编译器保证不会因 == 而恐慌的类型。我们将这些类型称为严格可比较类型。

大多数情况下,这正是我们想要的:知道泛型函数中的 == 如果操作数由 comparable 约束,就不会恐慌,这是令人欣慰的,也是我们直观期望的。

不幸的是,comparable 的这种定义以及约束满足的规则阻止了我们编写有用的泛型代码,例如前面显示的 genericLookupTable 类型:为了使 any 成为可接受的参数类型,any 必须满足(并因此实现)comparable。但是 any 的类型集比 comparable 的类型集更大(不是子集),因此不实现 comparable

var lookupTable GenericLookupTable[any, string] // ERROR: any does not implement comparable (Go 1.18 and Go 1.19)

用户很早就认识到了这个问题,并迅速提交了大量问题和提案(#51338#52474#52531#52614#52624#53734 等)。显然,这是一个我们需要解决的问题。

“显而易见”的解决方案是将非严格可比较类型也包含在 comparable 类型集中。但这会导致与类型集模型不一致。考虑以下示例

func f[Q comparable]() { … }

func g[P any]() {
        _ = f[int] // (1) ok: int implements comparable
        _ = f[P]   // (2) error: type parameter P does not implement comparable
        _ = f[any] // (3) error: any does not implement comparable (Go 1.18, Go.19)
}

函数 f 需要一个严格可比较的类型参数。显然,用 int 实例化 f 是可以的:int 值永远不会因 == 而恐慌,因此 int 实现 comparable(情况 1)。另一方面,用 P 实例化 f 是不允许的:P 的类型集由其约束 any 定义,而 any 代表所有可能类型的集合。此集合包含根本不可比较的类型。因此,P 不实现 comparable,因此不能用于实例化 f(情况 2)。最后,使用类型 any(而不是受 any 约束的类型参数)也不起作用,原因与此完全相同(情况 3)。

然而,我们确实希望在这种情况下使用类型 any 作为类型参数。摆脱这个困境的唯一方法是某种程度上改变语言。但是如何改变呢?

接口实现与约束满足

如前所述,约束满足就是接口实现:类型参数 T 满足约束 C 当且仅当 T 实现 C。这是有道理的:T 必须在 C 预期的类型集中,这正是接口实现的定义。

但这正是问题所在,因为它阻止了我们将非严格可比较类型用作 comparable 的类型参数。

因此,对于 Go 1.20,在公开讨论了多种选择(请参阅上述问题)近一年后,我们决定为这种情况引入一个例外。为了避免不一致,我们没有改变 comparable 的含义,而是区分了接口实现(这与将值传递给变量有关)和约束满足(这与将类型参数传递给类型参数有关)。一旦分开,我们就可以为每个概念(稍微)不同的规则,这正是我们通过提案 #56548 所做的。

好消息是,这个例外在规范中相当局部化。约束满足仍然与接口实现几乎相同,但有一个例外

类型 T 满足约束 C 当且仅当

  • T 实现 C;或
  • C 可以写成 interface{ comparable; E } 的形式,其中 E 是一个基本接口,并且 T可比较的并实现 E

第二点是例外。不深入研究规范的正式性,例外所说的如下:一个期望严格可比较类型的约束 C(并且可能还有其他要求,如方法 E)可以被任何支持 == 的类型参数 T(并且也实现了 E 中的方法(如果有))所满足。或者更简洁地说:支持 == 的类型也满足 comparable(即使它可能不实现它)。

我们可以立即看出这个更改是向后兼容的:在 Go 1.20 之前,约束满足与接口实现相同,我们仍然保留该规则(第一点)。所有依赖于该规则的代码将继续按原样工作。只有当该规则失败时,我们才需要考虑例外。

让我们回顾一下之前的例子

func f[Q comparable]() { … }

func g[P any]() {
        _ = f[int] // (1) ok: int satisfies comparable
        _ = f[P]   // (2) error: type parameter P does not satisfy comparable
        _ = f[any] // (3) ok: satisfies comparable (Go 1.20)
}

现在,any 确实满足(但没有实现!)comparable。为什么?因为 Go 允许在类型为 any 的值上使用 ==(这对应于规范规则中的类型 T),并且因为约束 comparable(对应于规则中的约束 C)可以写成 interface{ comparable; E },其中 E 在本例中只是空接口(情况 3)。

有趣的是,P 仍然不满足 comparable(情况 2)。原因是 P 是一个由 any 约束的类型参数(它不是 any)。== 操作对于 P 类型集中的所有类型都不可用,因此对 P 也不可用;它不是可比较类型。因此,例外不适用。但这没关系:我们确实希望知道 comparable(严格可比较性要求)在大多数情况下都得到强制执行。我们只需要为支持 == 的 Go 类型提供例外,这主要是出于历史原因:我们一直能够比较非严格可比较的类型。

后果和补救措施

我们 Gophers 以一个事实为傲,即特定于语言的行为可以通过相当简洁的一组规则来解释和简化,这些规则都写在语言规范中。多年来,我们不断完善这些规则,并在可能的情况下使其更加简单,并且通常更通用。我们还小心地保持规则的正交性,始终留意意外和不幸的后果。争议通过查阅规范来解决,而不是通过命令。这就是我们从 Go 诞生之初就一直追求的目标。

在没有后果的情况下,你不能轻易地向一个精心设计的类型系统中添加一个例外!

那么问题出在哪里?有一个明显的(如果轻微的)缺点,和一个不太明显(且更严重)的缺点。显然,我们现在有一个更复杂的约束满足规则,这可以说不如我们以前的规则优雅。这不太可能以任何重要的方式影响我们的日常工作。

但我们为这个例外付出了代价:在 Go 1.20 中,依赖 comparable 的泛型函数不再是静态类型安全的。即使声明说它们是严格可比较的,==!= 操作在应用于 comparable 类型参数的操作数时也可能发生恐慌。一个不可比较的值可能会通过一个非严格可比较的类型参数,通过多个泛型函数或类型“溜走”,并导致恐慌。在 Go 1.20 中,我们现在可以声明

var lookupTable genericLookupTable[any, string]

而不会出现编译时错误,但如果我们在此处使用非严格可比较的键类型,我们将收到运行时恐慌,这与内置 map 类型的情况完全相同。我们为了运行时检查而牺牲了静态类型安全。

在某些情况下,这可能不够好,我们希望强制执行严格可比较性。以下观察结果使我们能够做到这一点,至少是有限的形式:类型参数不受我们添加到约束满足规则的例外的益处。例如,在我们之前的示例中,函数 g 中的类型参数 Pany 约束(它本身是可比较的,但不是严格可比较的),因此 P 不满足 comparable。我们可以利用这些知识来创建一个适用于给定类型 T 的编译时断言

type T struct { … }

我们想断言 T 是严格可比较的。很容易写成

// isComparable may be instantiated with any type that supports ==
// including types that are not strictly comparable because of the
// exception for constraint satisfaction.
func isComparable[_ comparable]() {}

// Tempting but not quite what we want: this declaration is also
// valid for types T that are not strictly comparable.
var _ = isComparable[T] // compile-time error if T does not support ==

虚拟(空白)变量声明充当我们的“断言”。但是,由于约束满足规则中的例外,isComparable[T] 仅在 T 完全不可比较时才失败;如果 T 支持 ==,它将成功。我们可以通过使用 T 作为类型约束而不是类型参数来解决这个问题

func _[P T]() {
    _ = isComparable[P] // P supports == only if T is strictly comparable
}

这里有一个通过和一个失败的 playground 示例来说明这个机制。

最终观察

有趣的是,直到 Go 1.18 发布前两个月,编译器实现的约束满足与我们现在 Go 1.20 中的实现完全相同。但是,因为当时约束满足意味着接口实现,所以我们的实现与语言规范不一致。我们通过#50646 问题意识到了这一点。我们离发布非常近,必须迅速做出决定。在没有令人信服的解决方案的情况下,使实现与规范保持一致似乎是最安全的。一年后,随着有充足的时间考虑不同的方法,我们发现最初的实现正是我们想要的。我们兜了一个大圈。

一如既往,如果您发现任何内容不符合预期,请通过在https://go-lang.org.cn/issue/new 提交问题来告知我们。

谢谢!

下一篇文章: Go 集成测试的代码覆盖率
上一篇文章:剖析驱动优化预览
博客索引