在 GitHub 上编辑此页面

多重宇宙平等

以前,Scala 具有通用相等性:任何类型的两个值都可以使用 ==!= 进行比较。这是因为 ==!= 是根据 Java 的 equals 方法实现的,该方法还可以比较任何两个引用类型的值。

通用相等性很方便。但它也很危险,因为它破坏了类型安全性。例如,假设在进行一些重构后,一个值 y 的类型为 S,而不是正确的类型 T

val x = ... // of type T
val y = ... // of type S, but should be T
x == y      // typechecks, will always yield false

如果 y 与类型为 T 的其他值进行比较,程序仍然会进行类型检查,因为所有类型的值都可以相互比较。但它可能会产生意外的结果,并在运行时失败。

多重相等是一种选择加入的方式,可以使通用相等更安全。它使用二进制类型类 scala.CanEqual 来指示两种给定类型的可以相互比较。如果 ST 是派生 CanEqual 的类,则上面的示例不会进行类型检查,例如

class T derives CanEqual

通常,派生子句 只接受具有一个参数的类型类,但对于 CanEqual 有一个特例。

或者,也可以直接提供一个 CanEqual 给定实例,如下所示

given CanEqual[T, T] = CanEqual.derived

此定义实际上表示类型 T 的值在使用 ==!= 时只能与类型 T 的其他值进行比较。该定义会影响类型检查,但对运行时行为没有影响,因为 == 始终映射到 equals,而 != 始终映射到 equals 的否定。定义的右侧 CanEqual.derived 是一个值,其类型为任何 CanEqual 实例。以下是类 CanEqual 及其伴生对象的定义

package scala
import annotation.implicitNotFound

@implicitNotFound("Values of types ${L} and ${R} cannot be compared with == or !=")
sealed trait CanEqual[-L, -R]

object CanEqual:
  object derived extends CanEqual[Any, Any]

一个类型可以有多个 CanEqual 给定实例。例如,以下四个定义使类型 A 和类型 B 的值可以相互比较,但不能与其他任何值进行比较

given CanEqual[A, A] = CanEqual.derived
given CanEqual[B, B] = CanEqual.derived
given CanEqual[A, B] = CanEqual.derived
given CanEqual[B, A] = CanEqual.derived

scala.CanEqual 对象定义了许多 CanEqual 给定实例,它们共同定义了一个规则手册,说明哪些标准类型可以进行比较(更多详细信息见下文)。

还有一个名为 canEqualAny 的“后备”实例,它允许对所有本身没有 CanEqual 给定的类型进行比较。canEqualAny 定义如下

def canEqualAny[L, R]: CanEqual[L, R] = CanEqual.derived

即使 canEqualAny 未声明为 given,编译器仍然会构造一个 canEqualAny 实例作为对类型 CanEqual[L, R] 的隐式搜索的答案,除非 LR 在其上定义了 CanEqual 实例,或启用了语言特性 strictEquality

拥有 canEqualAny 的主要动机是向后兼容性。如果这不令人担忧,可以通过启用语言特性 strictEquality 来禁用 canEqualAny。对于所有语言特性,都可以通过导入来完成

import scala.language.strictEquality

或使用命令行选项 -language:strictEquality

派生 CanEqual 实例

通常,派生 CanEqual 实例比直接定义它们更方便。示例

class Box[T](x: T) derives CanEqual

根据 类型类派生 的常规规则,这会在 Box 的伴生对象中生成以下 CanEqual 实例

given [T, U](using CanEqual[T, U]): CanEqual[Box[T], Box[U]] =
  CanEqual.derived

也就是说,如果两个盒子的元素相等,则这两个盒子可以使用 ==!= 进行比较。示例

new Box(1) == new Box(1L)   // ok since there is an instance for `CanEqual[Int, Long]`
new Box(1) == new Box("a")  // error: can't compare
new Box(1) == 1             // error: can't compare

相等性检查的精确规则

相等性检查的精确规则如下。

如果启用了 strictEquality 特性,则在值 x: Ty: U 之间使用 x == yx != y 进行比较是合法的,前提是存在类型为 CanEqual[T, U]given

在未启用 strictEquality 特性的默认情况下,如果

  1. TU 相同,或者
  2. TU 中的一个是另一类型的提升版本的子类型,或者
  3. TU 都没有自反CanEqual 实例。

说明

  • 提升类型 S 意味着将 S 中协变位置的所有抽象类型引用替换为其上界,并将 S 中协变位置的所有细化类型替换为其父类型。
  • 如果对 CanEqual[T, T] 的隐式搜索成功,则类型 T 具有自反CanEqual 实例。

预定义的 CanEqual 实例

CanEqual 对象定义了用于比较以下内容的实例

  • 基本类型 ByteShortCharIntLongFloatDoubleBooleanUnit
  • java.lang.Numberjava.lang.Booleanjava.lang.Character
  • scala.collection.Seqscala.collection.Set

定义实例以便这些类型中的每一个都具有自反CanEqual 实例,并且满足以下条件

  • 基本数字类型可以相互比较。
  • 基本数字类型可以与 java.lang.Number 的子类型(反之亦然)进行比较。
  • Boolean 可与 java.lang.Boolean 进行比较(反之亦然)。
  • Char 可与 java.lang.Character 进行比较(反之亦然)。
  • 如果两个序列(scala.collection.Seq 的任意子类型)的元素类型可比较,则这两个序列可相互比较。这两个序列类型不必相同。
  • 如果两个集合(scala.collection.Set 的任意子类型)的元素类型可比较,则这两个集合可相互比较。这两个集合类型不必相同。
  • AnyRef 的任何子类型可与 Null 进行比较(反之亦然)。

为何有两个类型参数?

CanEqual 类型的特殊功能之一是它采用两个类型参数,表示要比较的两个项目的类型。相比之下,相等类型类的传统实现只采用一个类型参数,表示两个操作数的公共类型。一个类型参数比两个简单,那么为何要经历额外的复杂过程?原因在于,我们不是提出一种以前不存在操作的类型类,而是处理对预先存在的通用相等性的优化。通过一个示例可以最好地说明这一点。

假设你想要提出 List[T] 上的 contains 方法的安全版本。标准库中 contains 的原始定义是

class List[+T]:
  ...
  def contains(x: Any): Boolean

它以不安全的方式使用通用相等性,因为它允许将任何类型的参数与列表的元素进行比较。“显而易见”的替代定义

def contains(x: T): Boolean

不起作用,因为它在非变体上下文中引用协变参数 T。在 contains 中使用类型参数 T 的唯一方差正确方式是作为下界

def contains[U >: T](x: U): Boolean

这个通用版本的 containsList 当前(Scala 2.13)版本中使用的版本。它看起来不同,但它允许与我们最初开始使用的 contains(x: Any) 定义完全相同的应用程序。但是,我们可以通过添加 CanEqual 参数使其更有用(即更具限制性)

def contains[U >: T](x: U)(using CanEqual[T, U]): Boolean // (1)

此版本的 contains 是相等安全的!更准确地说,给定 x: Txs: List[T]y: U,则当且仅当 x == y 类型正确时,xs.contains(y) 类型正确。

不幸的是,如果我们限制自己使用具有单个类型参数的相等类,则从简单相等和模式匹配到任意用户定义操作“提升”相等类型检查的关键能力就会丢失。考虑具有假设的 CanEqual1[T] 类型类的 contains 的以下签名

def contains[U >: T](x: U)(using CanEqual1[U]): Boolean   // (2)

此版本可与原始 contains(x: Any) 方法一样广泛地应用,因为 CanEqual1[Any] 后备始终可用!因此我们没有获得任何收益。在过渡到单个参数类型类时丢失的是原始规则,即仅当 AB 都不具有自反 CanEqual 实例时,才提供 CanEqual[A, B]。如果 CanEqual 只有一个类型参数,则根本无法表达该规则。

-language:strictEquality 下,情况有所不同。在这种情况下,将永远无法使用 CanEqual[Any, Any]CanEqual1[Any] 实例,并且单个和双参数版本确实会在大多数实际目的中重合。

但是,立即且无处不在地假设 -language:strictEquality 会造成可能难以克服的迁移问题。再次考虑标准库中的 contains。使用 (1) 中的 CanEqual 类型类对其进行参数化是一个立竿见影的胜利,因为它排除了无意义的应用程序,同时仍然允许所有明智的应用程序。因此,几乎可以在任何时候完成,除了二进制兼容性问题。另一方面,使用 (2) 中的 CanEqual1contains 进行参数化将使 contains 无法用于尚未声明 CanEqual1 实例的所有类型,包括来自 Java 的所有类型。这显然是不可接受的。这将导致一种情况,即与其迁移现有库以使用安全相等性,唯一的升级路径是拥有并行库,新版本仅适用于派生 CanEqual1 的类型,而旧版本处理其他所有内容。生态系统的这种分裂将非常成问题,这意味着治疗方法可能比疾病更糟。

由于这些原因,看起来双参数类型类是唯一的前进方式,因为它可以采用现有生态系统并将其迁移到越来越多的代码使用安全相等性的未来。

-language:strictEquality 为默认值的情况下,还可以引入一个参数类型别名,例如

type Eq[-T] = CanEqual[T, T]

需要安全相等性的操作可以使用此别名,而不是双参数 CanEqual 类。但它只能在 -language:strictEquality 下工作,因为否则通用的 Eq[Any] 实例将无处不在。

有关多重相等性的更多信息,请参阅 博客文章GitHub 问题