多重宇宙平等
以前,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
来指示两种给定类型的可以相互比较。如果 S
或 T
是派生 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]
的隐式搜索的答案,除非 L
或 R
在其上定义了 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: T
和 y: U
之间使用 x == y
或 x != y
进行比较是合法的,前提是存在类型为 CanEqual[T, U]
的 given
。
在未启用 strictEquality
特性的默认情况下,如果
T
和U
相同,或者T
、U
中的一个是另一类型的提升版本的子类型,或者T
和U
都没有自反的CanEqual
实例。
说明
- 提升类型
S
意味着将S
中协变位置的所有抽象类型引用替换为其上界,并将S
中协变位置的所有细化类型替换为其父类型。 - 如果对
CanEqual[T, T]
的隐式搜索成功,则类型T
具有自反的CanEqual
实例。
预定义的 CanEqual 实例
CanEqual
对象定义了用于比较以下内容的实例
- 基本类型
Byte
、Short
、Char
、Int
、Long
、Float
、Double
、Boolean
和Unit
, java.lang.Number
、java.lang.Boolean
和java.lang.Character
,scala.collection.Seq
和scala.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
这个通用版本的 contains
是 List
当前(Scala 2.13)版本中使用的版本。它看起来不同,但它允许与我们最初开始使用的 contains(x: Any)
定义完全相同的应用程序。但是,我们可以通过添加 CanEqual
参数使其更有用(即更具限制性)
def contains[U >: T](x: U)(using CanEqual[T, U]): Boolean // (1)
此版本的 contains
是相等安全的!更准确地说,给定 x: T
、xs: 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]
后备始终可用!因此我们没有获得任何收益。在过渡到单个参数类型类时丢失的是原始规则,即仅当 A
和 B
都不具有自反 CanEqual
实例时,才提供 CanEqual[A, B]
。如果 CanEqual
只有一个类型参数,则根本无法表达该规则。
在 -language:strictEquality
下,情况有所不同。在这种情况下,将永远无法使用 CanEqual[Any, Any]
或 CanEqual1[Any]
实例,并且单个和双参数版本确实会在大多数实际目的中重合。
但是,立即且无处不在地假设 -language:strictEquality
会造成可能难以克服的迁移问题。再次考虑标准库中的 contains
。使用 (1) 中的 CanEqual
类型类对其进行参数化是一个立竿见影的胜利,因为它排除了无意义的应用程序,同时仍然允许所有明智的应用程序。因此,几乎可以在任何时候完成,除了二进制兼容性问题。另一方面,使用 (2) 中的 CanEqual1
对 contains
进行参数化将使 contains
无法用于尚未声明 CanEqual1
实例的所有类型,包括来自 Java 的所有类型。这显然是不可接受的。这将导致一种情况,即与其迁移现有库以使用安全相等性,唯一的升级路径是拥有并行库,新版本仅适用于派生 CanEqual1
的类型,而旧版本处理其他所有内容。生态系统的这种分裂将非常成问题,这意味着治疗方法可能比疾病更糟。
由于这些原因,看起来双参数类型类是唯一的前进方式,因为它可以采用现有生态系统并将其迁移到越来越多的代码使用安全相等性的未来。
在 -language:strictEquality
为默认值的情况下,还可以引入一个参数类型别名,例如
type Eq[-T] = CanEqual[T, T]
需要安全相等性的操作可以使用此别名,而不是双参数 CanEqual
类。但它只能在 -language:strictEquality
下工作,因为否则通用的 Eq[Any]
实例将无处不在。