Go 博客
所有可比较类型
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
}
定义了所有底层类型为 int
或 string
,并且还实现了 io.Writer
的 Write
方法的类型集合。
这种广义接口不能用作变量类型。但是,因为它们描述了类型集,所以它们被用作类型约束,即类型集。例如,我们可以编写一个泛型 min
函数
func min[P interface{ ~int64 | ~float64 }](x, y P) P
它接受任何 int64
或 float64
参数。(当然,更现实的实现会使用一个枚举所有具有 <
运算符的基本类型的约束。)
顺便说一句,因为枚举显式类型而不带方法很常见,一些 语法糖允许我们省略包围的 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
的值上执行 int64
和 float64
类型都支持的任何操作。这包括所有基本算术运算,以及比较运算符,如 <
。但它不包括按位运算,如 &
或 |
,因为这些运算在 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
中的类型参数 P
受 any
约束(它本身是可比较的,但不是严格可比较的),因此 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 集成测试的代码覆盖率
上一篇文章:剖析驱动优化预览
博客索引