重新定義 equals() 方法


如果你要重新定義equals(),必須注意幾個地方,例如,你可能如下定義了equals()方法:
class Point(val x: Int, val y: Int) {
def equals(that: Point) = this.x == that.x && this.y == that.y
}

val p1 = new Point(1, 1)
val p2 = new Point(1, 1)

println(p1.equals(p2)) // 顯示 true
println(p1 == p2) // 顯示 false

你 會覺得很奇怪,不是說重新定義equals()實作物件相等性比較,再使用==就可以測試物件的實質相等性嗎?但上例中,equals()的結果是 true,但==的結果是false?原因在於,你沒有重新定義繼承下來的equals(),因為你另外定義了一個接受Point型態的equals() 方法。

事實上,在Scala中,重新定義一定要加上override關鍵字,以確保你確實重新定義了父類別的某個方法,上例中沒有加上override,而編譯器沒提出錯誤訊息時,你就要知道你並沒有重新定義父類別的equals()方法。

來看看真正有重新定義equals()的版本:
import scala.collection.immutable._

class Point(val x: Int, val y: Int) {
override def equals(a: Any) = a match {
case that: Point => this.x == that.x && this.y == that.y
case _ => false
}
}

val p1 = new Point(1, 1)
val p2 = new Point(1, 1)

println(p1.equals(p2)) // 顯示 true
println(p1 == p2) // 顯示 true

val pSet = HashSet(p1)
println(pSet.contains(p2)) // 可能顯示 false

這 邊用到了模式比對(Pattern match)中變數模式(Variable pattern)的語法,雖然還沒正式談到,不過並不難,match語法中會嘗試看看傳入的a是否可以被Point型態所宣告that變數參考,如果可以 讓that參考至a所參考的物件,然後執行=>之後的程式碼。

p1與p2座標都是同一點,所以實際上指的相同的座標,使用==測試的結果也是true了(基本上,如果是重新定義equals(),則使用==與使用equals()的結果是相同的,所以以來就都用==來測試了),但是HashSet 中放入的p1與要測試的p2明明是指同一點,為什麼contains測試會有可能顯示false?因為你在重新定義equals()時,並沒有重新定義hashCode,在許多場合,例如將物件加入群集 (Collection)時,會同時利用equals()與hashCod)來判斷是否加入的是(實質上)相同的物件。來看看定義hashCode時必須遵守的約定(取自java.lang.Object的hashCode() 說明 ):
  • 在同一個應用程式執行期間,對同一物件呼叫 hashCode方法,必須回傳相同的整數結果。
  • 如果兩個物件使用equals(Object)測試結果為相等, 則這兩個物件呼叫hashCode時,必須獲得相同的整數結果。
  • 如果兩個物件使用equals(Object)測試結果為不相等, 則這兩個物件呼叫hashCode時,可以獲得不同的整數結果。

以HashSet為例,會先使用hashCode得出該將物件放至哪個雜湊桶(hash buckets)中,如果雜湊桶有物件,再進一步使用equals()確定實質相等性,從而確定Set中不會有重複的物件。上例中說可能會顯示false,是因為若湊巧物件hashCode算出在同一個雜湊桶,再進一步用equals()就有可能出現true。

在重新定義equals()時,最好重新一併重新定義hashCode。例如:
import scala.collection.immutable._

class Point(val x: Int, val y: Int) {
override def equals(a: Any) = a match {
case that: Point => this.x == that.x && this.y == that.y
case _ => false
}
override def hashCode = 41 * (41 + x) + y
}

val p1 = new Point(1, 1)
val p2 = new Point(1, 1)
val pSet = HashSet(p1)
println(pSet.contains(p2)) // 顯示為 true

一個重要的觀念是,定義equals()與hashCode時,最好別使用狀態會改變的資料成員。你可能會想,以這個例子來說,點會移動,如果移動了就不是相同的點了,不是嗎?假設x、y是個允許會變動的成員,那麼就會發生這個情況:
import scala.collection.immutable._

class Point(var x: Int, var y: Int) {
override def equals(a: Any) = a match {
case that: Point => this.x == that.x && this.y == that.y
case _ => false
}
override def hashCode = 41 * (41 + x) + y
}

val p1 = new Point(1, 1)
val pSet = HashSet(p1)

println(pSet.contains(p1)) // 顯示 true
p1.x = 10
println(pSet.contains(p1)) // 顯示 false

明 明是記憶體中同一個物件,但置入HashSet後,最後跟我說不包括p1?這是因為,你改變了x,算出來的hashCode也就改變了,使用 contains()嘗試比對時,會看看新算出來的雜湊桶中是不是有物件,而根本不是置入p1的雜湊桶中尋找,結果就是false了。

再來看看在實作equals()時要遵守的約定(取自java.lang.Object的 equals() 說明 ):
  • 反身性(Reflexive):x.equals(x)的結果要是true。
  • 對稱性(Symmetric):x.equals(y)與y.equals(x)的結果必須相同。
  • 傳遞性(Transitive):x.equals(y)、y.equals(z)的結果都是true,則x.equals(z)的結果也必須是true。
  • 一致性(Consistent):同一個執行期間,對x.equals(y)的多次呼叫,結果必須相同。
  • 對任何非null的x,x.equals(null)必須傳回false。

目前定義的Point,其equals()方法滿足以上幾個約定(你可以自行寫程式測試)。現在考慮繼承的情況,你要定義3D的點:
import scala.collection.immutable._

class Point(varl x: Int, val y: Int) {
override def equals(a: Any) = a match {
case that: Point => this.x == that.x && this.y == that.y
case _ => false
}
override def hashCode = 41 * (41 + x) + y
}

class Point3D(x: Int, y: Int, val z: Int) extends Point(x, y) {
override def equals(a: Any) = a match {
case that: Point3D => super.equals(that) && this.z == that.z
case _ => false
}
}

val p1 = new Point(1, 1)
val p2 = new Point3D(1, 1, 1)
println(p1 == p2) // 顯示 true
println(p2 == p1) // 顯示 false

結 果該是true或false需要討論一下。3D的點與2D的點是否相等呢?假設你考慮的是點投射在xy平面上是否相等,那p1 == p2為 true就可以接受,在此假設之下,再來看p2 == p1為false,這違反equals()對稱性的對稱性合約。如果你要滿足對稱性,則 要作個修改:
import scala.collection.immutable._

class Point(val x: Int, val y: Int) {
override def equals(a: Any) = a match {
case that: Point => this.x == that.x && this.y == that.y
case _ => false
}
override def hashCode = 41 * (41 + x) + y
}

class Point3D(x: Int, y: Int, val z: Int) extends Point(x, y) {
override def equals(a: Any) = a match {
case that: Point3D => super.equals(that) && this.z == that.z
case that: Point => that == this
case _ => false
}
}

val p1 = new Point(1, 1)
val p2 = new Point3D(1, 1, 1)
val p3 = new Point3D(1, 1, 2)
println(p1 == p2) // 顯示為 true
println(p2 == p1) // 顯示為 true
println(p1 == p3) // 顯示為 true
println(p2 == p3) // 顯示為 false

p1等於p2,p2等於p1,這符合對稱性合約了。但p2等於p1,p1等於p3,但p2不等於p3,這違反傳遞性合約。問題點在於,2D的點並沒有z軸資訊,無論如何也沒辦法滿足傳遞性了。

一般來說,對於不同的類別實例,會將之視為不同,基本上你可以這麼設計:
class Point(val x: Int, val y: Int) {
override def equals(a: Any) = a match {
case that: Point => this.getClass == that.getClass &&
this.x == that.x && this.y == that.y
case _ => false
}
override def hashCode = 41 * (41 + x) + y
}

class Point3D(x: Int, y: Int, val z: Int) extends Point(x, y) {
override def equals(a: Any) = a match {
case that: Point3D => super.equals(that) && this.z == that.z
case _ => false
}
}

直接判斷類別,讓不同類別的實例視為不相等,就這個例子而言,使得Point只能與Point比,Point3D只能與Point3D比,直接解決了不同繼承階層下equals()的合約問題。

不過在以下這種需求時,這樣的定義也許不符合你的需求:
val p1 = new Point(1, 1)
val p2 = new Point(1, 1) { override def toString = "(" + x + ", " + y + ")" }
println(p1 == p2) // 顯示 false

你也許是在某處建立了個匿名類別物件,然後在程式中某處又打算測試看看與p1是否相等,但結果並不是顯示true,這是因為你嚴格地在equals()中檢查了實例的類別名稱。

你可以將定義改為以下:
class Point(val x: Int, val y: Int) {
override def equals(a: Any) = a match {
case that: Point => that.canEquals(this) &&
this.x == that.x && this.y == that.y
case _ => false
}
override def hashCode = 41 * (41 + x) + y

def canEquals(that: Any) = that.isInstanceOf[Point]
}

class Point3D(x: Int, y: Int, val z: Int) extends Point(x, y) {
override def equals(a: Any) = a match {
case that: Point3D => that.canEquals(this) &&
super.equals(that) && this.z == that.z
case _ => false
}
override def hashCode = 41 * super.hashCode + z
override def canEquals(that: Any) = that.isInstanceOf[Point3D]
}

在equals()中,你不僅檢查傳入的實例是否為Point,也反過來讓傳入的實例取得this的型態進行測試(這是 Visitor 模式 的實現)。如果p1是Point物件,而p2是Point3D物件,p1.equals(p2)時,由於傳入的實例可以取得this的型態進行測試,p2反過來測試p1是不是Point3D,結果不是,所以equals()傳回false,利用這個方式,讓有具體名稱的子類別實例,不會與父類別實例有相等成立的可能性。如果是直接繼承Point類別的匿名類別物件,則直接繼承canEquals()方法,由於匿名類別物件還是一種Point實例,因此equals()的結果會是true。

一個測試的結果如下:
val p1 = new Point(1, 1)
val p2 = new Point(1, 1) { override def toString = "(" + x + ", " + y + ")" }
println(p1 == p2) // 顯示 true
val pSet = HashSet(p1)
println(pSet.contains(p2)) // 顯示 true

後記:這篇文件是看完 Programming in Scala 第28章Object equality後的心得,範例的流程與書中的類似,並作了些修飾與補充。