在 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 類型的其中一個特殊功能是它需要兩個類型參數,代表要比較的兩個項目的類型。相較之下,等價類型類別的傳統實作只有一個類型參數,代表兩個運算元的共用類型。一個類型參數比兩個簡單,那為什麼還要多此一舉?原因在於,我們處理的是對現有通用等價的精緻化,而不是提出一個以前不存在運算的類型類別。最好的說明方式就是透過範例。

假設你想要想出一個安全版本的 contains 方法在 List[T] 上。標準函式庫中 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: Txs: List[T]y: U,則 xs.contains(y) 僅當 x == 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) 中的 CanEqual1 參數化 contains 會讓 contains 無法用於尚未宣告 CanEqual1 執行個體的所有類型,包括來自 Java 的所有類型。這顯然不可接受。這將導致一種情況,而不是將現有函式庫遷移到使用安全相等,唯一的升級路徑是擁有平行函式庫,新版本僅適用於衍生 CanEqual1 的類型,而舊版本則處理所有其他類型。生態系統的這種分裂將非常成問題,這意味著治療方法可能會比疾病更糟。

由於這些原因,看起來雙參數類型類別是唯一的方法,因為它可以採用現有的生態系統並將其遷移到越來越多的程式碼使用安全相等的未來。

-language:strictEquality 是預設值的應用中,也可以引入一個參數類型別名,例如

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

需要安全相等的運算可以使用此別名,而不是雙參數 CanEqual 類別。但它只會在 -language:strictEquality 下運作,因為否則通用的 Eq[Any] 執行個體將無所不在。

更多關於多重相等性的內容可以在 部落格文章GitHub 問題 中找到。