Go 博客
所有可比较类型
在 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
}
定义了所有底层类型为 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
是接口类型集的元素时,(非接口)类型 T
实现接口 I
。如果 T
本身是接口,则它描述了一个类型集。该集合中的每个类型都必须也在 I
的类型集中,否则 T
将包含不实现 I
的类型。因此,如果 T
是接口,则当 T
的类型集是 I
的类型集的子集时,它就实现了接口 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
中,即使所有接口都支持 ==
。为什么呢?
接口(以及包含它们的复合类型)的比较可能在运行时引发 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
中的类型参数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://golang.ac.cn/issue/new提交问题,让我们知道。
感谢您!
下一篇文章: Go 集成测试的代码覆盖率
上一篇文章: 概要引导优化预览
博客索引