Go 博客

所有可比较类型

Robert Griesemer
2023 年 2 月 17 日

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

如果您感到困惑,那么您来对地方了。请考虑以下有效的映射声明

var lookupTable map[any]string

其中映射的键类型是 any(它是 可比较类型)。这在 Go 中可以完美运行。另一方面,在 Go 1.20 之前,看似等效的泛型映射类型

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

可以像普通映射类型一样使用,但在 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 这样的映射,因为 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 是接口类型集的元素时,(非接口)类型 T 实现接口 I。如果 T 本身是接口,则它描述了一个类型集。该集合中的每个类型都必须也在 I 的类型集中,否则 T 将包含不实现 I 的类型。因此,如果 T 是接口,则当 T 的类型集是 I 的类型集的子集时,它就实现了接口 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 中,即使所有接口都支持 ==。为什么呢?

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

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

相比之下,comparable 只包含编译器保证不会对 == 引发 panic 的类型。我们将这些类型称为严格可比较

大多数时候,这正是我们想要的:如果操作数受 comparable 约束,那么能够确定泛型函数中的 == 不会引发 panic 令人欣慰,这也是我们直观的期望。

不幸的是,这种 comparable 的定义以及约束满足规则阻止了我们编写有用的泛型代码,例如前面显示的 genericLookupTable 类型:为了使 any 成为可接受的参数类型,any 必须满足(因此实现)comparable。但 any 的类型集大于(不是 comparable 的子集)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值在==上永远不会出现 panic,因此int实现了comparable(情况 1)。另一方面,用P实例化f是不允许的:P的类型集由其约束any定义,而any代表所有可能类型的集合。此集合包括根本不可比较的类型。因此,P没有实现comparable,因此不能用于实例化f(情况 2)。最后,使用类型any(而不是受any约束的类型参数)也不起作用,因为存在完全相同的问题(情况 3)。

然而,我们确实希望能够在这种情况下使用类型any作为类型参数。摆脱这种困境的唯一方法是对语言进行一些更改。但如何更改呢?

接口实现与约束满足

如前所述,约束满足是接口实现:如果类型参数T实现了C,则它满足约束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 类型有一个例外,基本上是为了历史原因:我们一直有能力比较非严格可比较的类型。

后果和补救措施

我们这些 Go 程序员都为语言特定行为能够被解释并简化为一组相当紧凑的规则(在语言规范中说明)而感到自豪。多年来,我们一直在完善这些规则,并在可能的情况下使它们更简单,并且通常更通用。我们还一直小心地保持规则正交,始终注意意外和不幸的后果。争议通过查阅规范来解决,而不是通过命令来解决。这就是我们自 Go 诞生以来一直追求的目标。

不能仅仅在经过精心设计的类型系统中添加一个例外,而不会产生任何后果!

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

但我们确实为这个例外付出了代价:在 Go 1.20 中,依赖于comparable的泛型函数不再是静态类型安全的。如果应用于comparable类型参数的操作数,则==!=操作可能会出现 panic,即使声明说它们是严格可比较的。一个非可比较的值可以通过一个非严格可比较的类型参数,通过多个泛型函数或类型,并最终导致 panic。在 Go 1.20 中,我们现在可以声明

var lookupTable genericLookupTable[any, string]

没有编译时错误,但如果我们在这个例子中使用非严格可比较的键类型,就会出现运行时 panic,就像内置的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://golang.ac.cn/issue/new提交问题,让我们知道。

感谢您!

下一篇文章: Go 集成测试的代码覆盖率
上一篇文章: 概要引导优化预览
博客索引