多元等式
以前,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
類型的其中一個特殊功能是它需要兩個類型參數,代表要比較的兩個項目的類型。相較之下,等價類型類別的傳統實作只有一個類型參數,代表兩個運算元的共用類型。一個類型參數比兩個簡單,那為什麼還要多此一舉?原因在於,我們處理的是對現有通用等價的精緻化,而不是提出一個以前不存在運算的類型類別。最好的說明方式就是透過範例。
假設你想要想出一個安全版本的 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: T
、xs: 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]
後備始終可用!所以我們什麼都沒得到。在過渡到單參數類型類別時丟失的是原始規則,即僅當 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]
執行個體將無所不在。