物件相等性(上)
在Java中,如果要比較兩個物件的實質相等性,並不是使用==,而是必須透過equals()方法,例如: String s1 = new String("Java"); String s2 = new String("Java"); out.println(s1 == s2); // 顯示 false out.println(s1.equals(s2)); // 顯示 true 兩個物件是新建構出來的,所以s1與s2是參考到不同物件,因而使用==比較會是false,要比較兩個字串的實質字元序列,必須使用equals(),這是因為String的equals()重新定義為比較兩個字串的字元序列。 如果你定義類別時,沒有重新定義equals()方法,則預設繼承自Object,Object的equals()方法是定義為: public boolean equals(Object obj) { return (this == obj); } 也就是如果你沒有重新定義equals(),使用equals()方法時,作用等同於使用==。如果你要重新定義equals(),必須注意幾個地方,例如,你可能如下定義了equals()方法: public class Point { public final int x; public final int y; public Point(int x, int y) { this.x = x; this.y = y; } public boolean equals(Point that) { return this.x == that.x && this.y == that.y; } } 如果你這麼測試: Point p1 = new Point(1, 1); Point p2 = new Point(1, 1); out.println(p1.equals(p2)); // 顯示 true 看來似乎沒錯,p1與p2座標都是同一點,所以實際上指的相同的座標,但是如果你這麼測試: Point p1 = new Point(1, 1); Point p2 = new Point(1, 1); Set pSet = new HashSet(); pSet.add(p1); out.println(pSet.contains(p2)); // 顯示 false Set 中放入的p1與要測試的p2明明是指同一點,為什麼會顯示false?問題在於你沒有重新定義Object的equals(),你是另外定義了一個 equals()方法,參數是Point型態,換言之,你是重載(overload),不是重新定義(Override),Object的equals()接受的是Object型態的參數。如果你使用以下的程式測試,就可以知道原因: Object p1 = new Point(1, 1); Point p2 = new Point(1, 1); out.println(p1.equals(p2)); // 顯示 false p1是Object宣告,看不到Point中的equals(),所以就使用Object本身的equals(),結果當然是false。 在JDK5之後,可以使用@Override避免這類錯誤,例如: public class Point { public final int x; public final int y; public Point(int x, int y) { this.x = x; this.y = y; } @Override public boolean equals(Object that) { if(that instanceof Point) { Point p = (Point) that; return this.x == p.x && this.y == p.y; } return false; } } 再作同樣的測試: Object p1 = new Point(1, 1); Point p2 = new Point(1, 1); out.println(p1.equals(p2)); // 顯示 true 結果看來是正確了,不過: Point p1 = new Point(1, 1); Point p2 = new Point(1, 1); Set pSet = new HashSet(); pSet.add(p1); out.println(pSet.contains(p2)); // 可能顯示 false 如 果上例結果顯示false,並不用訝異,因為你在重新定義equals()時,並沒有重新定義hashCode(),在許多場合,例如將物件加入群集 (Collection)時,會同時利用equals()與hashCode()來判斷是否加入的是(實質上)相同的物件。在Object的 hashCode() 說明 指出:
以HashSet為例,會先使用hashCode()得出該將物件放至哪個雜湊桶(hash buckets)中,如果雜湊桶有物件,再進一步使用equals()確定實質相等性,從而確定Set中不會有重複的物件。上例中說可能會顯示false,是因為若湊巧物件hashCode()算出在同一個雜湊桶,再進一步用equals()就有可能出現true。 在重新定義equals()時,最好重新一併重新定義hashCode()。例如: public class Point { public final int x; public final int y; public Point(int x, int y) { this.x = x; this.y = y; } @Override public boolean equals(Object that) { if(that instanceof Point) { Point p = (Point) that; return this.x == p.x && this.y == p.y; } return false; } @Override public int hashCode() { return 41 * (41 + x) + y; } } 再次測試就會得到true了: Point p1 = new Point(1, 1); Point p2 = new Point(1, 1); Set pSet = new HashSet(); pSet.add(p1); out.println(pSet.contains(p2)); // 顯示 true 一個重要的觀念是,定義equals()與hashCode()時,最好別使用狀態會改變的資料成員。你可能會想,以這個例子來說,點會移動,如果移動了就不是相同的點了,不是嗎?假設x、y是個允許會變動的成員,那麼就會發生這個情況: Point p1 = new Point(1, 1); Set pSet = new HashSet(); pSet.add(p1); out.println(pSet.contains(p1)); // 顯示 true p1.x = 2; out.println(pSet.contains(p1)); // 顯示 false 明明是記憶體中同一個物件,但置入Set後,最後跟我說不包括p1?這是因為,你改變了x,算出來的hashCode()也就改變了,使用contains()嘗試比對時,會看看新算出來的雜湊桶中是不是有物件,而根本不是置入p1的雜湊桶中尋找,結果就是false了。 在Object的 equals() 說明 中有提到,實作equals()時要遵守的約定:
目前定義的Point,其equals()方法滿足以上幾個約定(你可以自行寫程式測試)。現在考慮繼承的情況,你要定義3D的點: public class Point3D extends Point { public final int z; public Point3D(int x, int y, int z) { super(x, y); this.z = z; } @Override public boolean equals(Object that) { if(that instanceof Point3D) { Point3D p = (Point3D) that; return super.equals(p) && this.z == p.z; } return false; } } 這看來似乎沒什麼問題,3D的點要再比較z座標是沒錯。不過來測試一下: Point p1 = new Point(1, 1); Point p2 = new Point3D(1, 1, 1); out.println(p1.equals(p2)); // 顯示 true println(p2.equals(p1)); // 顯示 false 結 果該是true或false需要討論一下。3D的點與2D的點是否相等呢?假設你考慮的是點投射在xy平面上是否相等,那p1.equals(p2)為 true就可以接受,在此假設之下,再來看p2.equals(p1)為false,這違反equals()對稱性的對稱性合約。如果你要滿足對稱性,則 要作個修改: public class Point3D extends Point { public final int z; public Point3D(int x, int y, int z) { super(x, y); this.z = z; } @Override public boolean equals(Object that) { if(that instanceof Point3D) { Point3D p = (Point3D) that; return super.equals(p) && this.z == p.z; } if(that instanceof Point) { return that.equals(this); } return false; } } 再次運行上面的測試,就可以得到都是true的結果,但如果是這個: Point p1 = new Point(1, 1); Point p2 = new Point3D(1, 1, 1); Point p3 = new Point3D(1, 1, 2); out.println(p2.equals(p1)); // 顯示 true out.println(p1.equals(p3)); // 顯示 true out.println(p2.equals(p3)); // 顯示 false p2等於p1,p1等於p3,但p2不等於p3,這違反傳遞性合約。問題點在於,2D的點並沒有z軸資訊,無論如何也沒辦法滿足傳遞性了。 一般來說,對於不同的類別實例,會將之視為不同,基本上你可以這麼設計: public class Point { public final int x; public final int y; public Point(int x, int y) { this.x = x; this.y = y; } @Override public boolean equals(Object that) { if(that instanceof Point) { Point p = (Point) that; return this.getClass() == p.getClass() && this.x == p.x && this.y == p.y; } return false; } @Override public int hashCode() { return 41 * (41 + x) + y; } } public class Point3D extends Point { public final int z; public Point3D(int x, int y, int z) { super(x, y); this.z = z; } @Override public boolean equals(Object that) { if(that instanceof Point3D) { Point3D p = (Point3D) that; return super.equals(p) && this.z == p.z; } return false; } } 直接判斷類別,讓不同類別的實例視為不相等,就這個例子而言,使得Point只能與Point比,Point3D只能與Point3D比,直接解決了不同繼承階層下equals()的合約問題。 不過在以下這種需求時,這樣的定義也許不符合你的需求: Point p1 = new Point(1, 1); Point p2 = new Point(1, 1) { @Override public String toString() { return "(" + x + ", " + y + ")"; } }; Set pSet = new HashSet(); pSet.add(p1); out.println(pSet.contains(p1)); // 顯示 true out.println(pSet.contains(p2)); // 顯示 false,但你想顯示 true 你也許是在某處建立了個匿名類別物件,然後在程式中某處又打算測試看看Set中是否含有相同座標的點,但結果並不是顯示true,這是因為你嚴格地在equals()中檢查了實例的類別名稱。 你可以將定義改為以下: public class Point { public final int x; public final int y; public Point(int x, int y) { this.x = x; this.y = y; } @Override public boolean equals(Object that) { if(that instanceof Point) { Point p = (Point) that; return p.canEquals(this) && this.x == p.x && this.y == p.y; } return false; } public boolean canEquals(Object that) { return that instanceof Point; } @Override public int hashCode() { return 41 * (41 + x) + y; } } 在equals()中,你不僅檢查傳入的實例是否為Point,也反過來讓傳入的實例取得this的型態進行測試(這是 Visitor 模式 的實現)。而在 Point3D 中: public class Point3D extends Point { public final int z; public Point3D(int x, int y, int z) { super(x, y); this.z = z; } @Override public boolean equals(Object that) { if(that instanceof Point3D) { Point3D p = (Point3D) that; return p.canEquals(this) && super.equals(p) && this.z == p.z; } return false; } @Override public boolean canEquals(Object that) { return that instanceof Point3D; } @Override public int hashCode() { return 41 * super.hashCode() + z; } } 如果p1是Point物件,而p2是Point3D物件,p1.equals(p2)時,由於傳入的實例可以取得this的型態進行測試,p2反過來測試p1是不是Point3D,結果不是,所以equals()傳回false,利用這個方式,讓有具體名稱的子類別實例,不會與父類別實例有相等成立的可能性。如果是直接繼承Point類別的匿名類別物件,則直接繼承canEquals()方法,由於匿名類別物件還是一種Point實例,因此equals()的結果會是true。 一個測試的結果如下: Point p1 = new Point(1, 1); Point p2 = new Point(1, 1) { @Override public String toString() { return "(" + x + ", " + y + ")"; } }; Point p3 = new Point3D(1, 1, 1); Set pSet = new HashSet(); pSet.add(p1); out.println(pSet.contains(p1)); // 顯示 true out.println(pSet.contains(p2)); // 顯示 true out.println(pSet.contains(p3)); // 顯示 false |
Valentino Young
12/19
談及物件的相等性時,總是很自然地會提及 Object#hashCode(),總是很自然地會想提醒 「若要讓 x.equals(y),則也讓 x.hashCode() == y.hashCode() 能夠成立吧」。當然,這是無庸置疑的好習慣,無論是為了立刻感覺得到的準確性,還是為了經年累月才能體會到的品質度。
另一方面,談及物件的相等性時,總是很自然地會以 Collection#contains(Object) 做為素材。畢竟,若是只著眼在個體與個體之間的對稱性傳遞性等議題,應用其實極為有限;唯有納入「個體對群體」「群體對群體」的應用,逐步利用 Collection的#contains(Object), #remove(Collection), #retainAll(Collection)... 等等方法去組構出業務邏輯,Object#equals(Object) 的設計才能真正發揮所長。
只不過,個人並不太建議進一步讓上述兩頭馬車交錯並行,因為很容易演變出...諸如『例如將物件加入群集 (Collection)時,會同時利用equals()與hashCode()來判斷是否加入的是(實質上)相同的物件...』的文詞,而造成誤解。
Object#equals(Object)是偏向抽象性概念性思考後的設計,Object#hashCode()則是偏向實用性的考量下的產物;
Collection#contains(Object)的基本邏輯運作只有考慮Object#equals(Object)而已,
會受 Object#hashCode() 干涉的,實為 HashSet以及類似類別的 #contains(Object) 實作,而非抽象性更高的Collection。
雖然概念上的Set位於Collection族譜之中,然而實作出來被活用的類別(HashSet等等),實際上是包著Collection外衣的Map,
(換言之,通常實用化的Set會以「只在乎K不在乎V的Map<K,V>」作為基礎)。
為了強調「讓 x.equals(y)的話也讓 x.hashCode() == y.hashCode()」的好習慣,舉HashSet作例子固然無可厚非,
但這不應該被誤認為是Collection#contains(Object)的基本規則。
Comparable#compareTo(T)與Comparator#compare(T,T)兩者,也有類似提及 Object#equals(Object) 時所談到的對稱性傳遞性等等的理論,也有如同「若要讓 x.equals(y),則也讓 x.compareTo(y) == 0 能夠成立吧」的建言,
但也同樣的,那最後也是只影響實作出來的TreeSet(另一個只在乎K不在乎V的Map<K,V>)以及類似類別的個性,而非Collection#contains(Object)的基本規則。
Map與Collection,既相近又疏遠...
當Collection#contains(Object) 以 ArrayList等等實現時,既不在乎#hashCode()也不在乎#compareTo();
而HashSet#retainAll(TreeSet)與TreeSet#retainAll(HashSet),由於在乎的東西不一樣,相同結果反而是預期之外...
Collection#contains(Object)家族雖然以Object#equals(Object)為出發,但不是個在談論Object#equals(Object)之中佔個小篇幅即可的規模。