程式扎記: [ Java Essence ] 記憶中的那個東西 : 要怎麼參考呢 (物件相等性)

標籤

2012年5月26日 星期六

[ Java Essence ] 記憶中的那個東西 : 要怎麼參考呢 (物件相等性)

轉載自 這裡 
前言 : 
在Java中,如果要比較兩個物件的實質相等性,並不是使用==,而是必須透過equals()方法,例如 : 
  1. String s1 = new String("Java");  
  2. String s2 = new String("Java");  
  3. System.out.println(s1 == s2);       // 顯示 false  
  4. System.out.println(s1.equals(s2));  // 顯示 true  
兩個物件是新建構出來的,所以s1與s2是參考到不同物件,因而使用==比較會是false,要比較兩個字串的實質字元序列,必須使用equals(),這是因為String的equals()重新定義為比較兩個字串的字元序列. 

物件相等性 : 
如果你定義類別時,沒有重新定義equals()方法,則預設繼承自Object,Object的equals()方法是定義為 : 
  1. public boolean equals(Object obj) {  
  2.     return (this == obj);  
  3. }  
也就是如果你沒有重新定義equals(),使用equals()方法時,作用等同於使用==.如果你要重新定義equals(),必須注意幾個地方,例如,你可能如下定義了equals()方法 : 
  1. public class Point {  
  2.     public final int x;  
  3.     public final int y;  
  4.     public Point(int x, int y) {  
  5.         this.x = x;  
  6.         this.y = y;  
  7.     }  
  8.     public boolean equals(Point that) {  
  9.         return this.x == that.x && this.y == that.y;  
  10.     }  
  11. }  
如果你這麼測試 : 
  1. Point p1 = new Point(11);  
  2. Point p2 = new Point(11);  
  3. System.out.println(p1.equals(p2));  // 顯示 true  
看來似乎沒錯,p1與p2座標都是同一點,所以實際上指的相同的座標,但是如果你這麼測試 : 
  1. Point p1 = new Point(11);  
  2. Point p2 = new Point(11);  
  3. Set pSet = new HashSet();  
  4. pSet.add(p1);  
  5. System.out.println(pSet.contains(p2));    // 顯示 false  
Set 中放入的p1與要測試的p2明明是指同一點,為什麼會顯示false?問題在於你沒有重新定義Object的equals(),你是另外定義了一個 equals()方法,參數是Point型態,換言之,你是重載(overload),不是重新定義(Override),Object的equals()接受的是Object型態的參數。如果你使用以下的程式測試,就可以知道原因 : 
  1. Object p1 = new Point(11);  // 使用 Object 指標, 所以之後的 p1.equals() 版本是 Object 版本  
  2. Point p2 = new Point(11);  
  3. System.out.println(p1.equals(p2));  // 顯示 false  
p1是Object宣告,看不到Point中的equals(),所以就使用Object本身的equals(),結果當然是false! 在JDK5之後,可以使用@Override避免這類錯誤,例如 : 
  1. public class Point {  
  2.     public final int x;  
  3.     public final int y;  
  4.     public Point(int x, int y) {  
  5.         this.x = x;  
  6.         this.y = y;  
  7.     }  
  8.     @Override  
  9.     public boolean equals(Object that) {  
  10.         if(that instanceof Point) {  
  11.             Point p = (Point) that;  
  12.             return this.x == p.x && this.y == p.y;  
  13.         }  
  14.         return false;  
  15.     }  
  16. }  
再作同樣的測試 : 
  1. Object p1 = new Point(11);  
  2. Point p2 = new Point(11);  
  3. System.out.println(p1.equals(p2));  // 顯示 true  
結果看來是正確了,不過 : 
  1. Point p1 = new Point(11);  
  2. Point p2 = new Point(11);  
  3. Set pSet = new HashSet();  
  4. pSet.add(p1);  
  5. System.out.println(pSet.contains(p2));    // 可能顯示 false  
如 果上例結果顯示false,並不用訝異,因為你在重新定義equals()時,並沒有重新定義hashCode(),在許多場合,例如將物件加入群集 (Collection)時,會同時利用equals()與hashCode()來判斷是否加入的是(實質上)相同的物件。在Object的hashCode() 說明 指出 : 
* 在同一個應用程式執行期間,對同一物件呼叫 hashCode()方法,必須回傳相同的整數結果.
* 如果兩個物件使用equals(Object)測試結果為相等, 則這兩個物件呼叫hashCode()時,必須獲得相同的整數結果.
* 如果兩個物件使用equals(Object)測試結果為不相等, 則這兩個物件呼叫hashCode()時,可以獲得不同的整數結果.

以HashSet為例,會先使用hashCode()得出該將物件放至哪個雜湊桶(hash buckets)中,如果雜湊桶有物件,再進一步使用equals()確定實質相等性,從而確定Set中不會有重複的物件。上例中說可能會顯示false,是因為若湊巧物件hashCode()算出在同一個雜湊桶,再進一步用equals()就有可能出現true. 因此在重新定義equals()時,最好重新一併重新定義hashCode(). 例如 : 
  1. public class Point {  
  2.     public final int x;  
  3.     public final int y;  
  4.     public Point(int x, int y) {  
  5.         this.x = x;  
  6.         this.y = y;  
  7.     }  
  8.     @Override  
  9.     public boolean equals(Object that) {  
  10.         if(that instanceof Point) {  
  11.             Point p = (Point) that;  
  12.             return this.x == p.x && this.y == p.y;  
  13.         }  
  14.         return false;  
  15.     }  
  16.     @Override  
  17.     public int hashCode() {  
  18.         return 41 * (41 + x) + y;  
  19.     }  
  20. }  
再次測試就會得到true了 : 
  1. Point p1 = new Point(11);  
  2. Point p2 = new Point(11);  
  3. Set pSet = new HashSet();  
  4. pSet.add(p1);  
  5. System.out.println(pSet.contains(p2));    // 顯示 true, 使用 hashCode() 會得到同一個 hash bucket 再用 equals() 得到 true  
一個重要的觀念是,定義equals()與hashCode()時,最好別使用狀態會改變的資料成員. 你可能會想,以這個例子來說,點會移動,如果移動了就不是相同的點了,不是嗎?假設x、y是個允許會變動的成員,那麼就會發生這個情況 : 
  1. Point p1 = new Point(11);  
  2. Set pSet = new HashSet();  
  3. pSet.add(p1);  
  4. System.out.println(pSet.contains(p1));  // 顯示 true  
  5. p1.x = 2;  
  6. System.out.println(pSet.contains(p1));  // 顯示 false  
明明是記憶體中同一個物件,但置入Set後,最後跟我說不包括p1?這是因為,你改變了x,算出來的hashCode()也就改變了,使用contains()嘗試比對時,會看看新算出來的雜湊桶中是不是有物件,而根本不是置入p1的雜湊桶中尋找,結果就是false了. 在Object的 equals() 說明 中有提到,實作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的點 : 
  1. public class Point3D extends Point {  
  2.     public final int z;  
  3.     public Point3D(int x, int y, int z) {  
  4.         super(x, y);  
  5.         this.z = z;  
  6.     }  
  7.     @Override  
  8.     public boolean equals(Object that) {  
  9.         if(that instanceof Point3D) {  
  10.             Point3D p = (Point3D) that;  
  11.             return super.equals(p) && this.z == p.z;  
  12.         }  
  13.         return false;  
  14.     }  
  15. }  
這看來似乎沒什麼問題,3D的點要再比較z座標是沒錯。不過來測試一下 : 
  1. Point p1 = new Point(11);  
  2. Point p2 = new Point3D(111);  
  3. System.out.println(p1.equals(p2));   // 顯示 true  
  4. System.out.println(p2.equals(p1));   // 顯示 false  
結 果該是true或false需要討論一下。3D的點與2D的點是否相等呢?假設你考慮的是點投射在xy平面上是否相等,那p1.equals(p2)為 true就可以接受,在此假設之下,再來看p2.equals(p1)為false,這違反equals()對稱性的對稱性合約。如果你要滿足對稱性,則 要作個修改 : 
  1. public class Point3D extends Point {  
  2.     public final int z;  
  3.     public Point3D(int x, int y, int z) {  
  4.         super(x, y);  
  5.         this.z = z;  
  6.     }  
  7.     @Override  
  8.     public boolean equals(Object that) {  
  9.         if(that instanceof Point3D) {  
  10.             Point3D p = (Point3D) that;  
  11.             return super.equals(p) && this.z == p.z;  
  12.         }  
  13.         if(that instanceof Point) {  
  14.             return that.equals(this);  
  15.         }  
  16.         return false;  
  17.     }  
  18. }  
再次運行上面的測試,就可以得到都是true的結果,但如果是這個 : 
  1. Point p1 = new Point(11);  
  2. Point p2 = new Point3D(111);  
  3. Point p3 = new Point3D(112);  
  4. System.out.println(p2.equals(p1));  // 顯示 true  
  5. System.out.println(p1.equals(p3));  // 顯示 true  
  6. System.out.println(p2.equals(p3));  // 顯示 false  
p2等於p1,p1等於p3,但p2不等於p3,這違反傳遞性合約。問題點在於,2D的點並沒有z軸資訊,無論如何也沒辦法滿足傳遞性了! 一般來說,對於不同的類別實例,會將之視為不同,基本上你可以這麼設計 : 
- Point.java :
  1. public class Point {  
  2.     public final int x;  
  3.     public final int y;  
  4.     public Point(int x, int y) {  
  5.         this.x = x;  
  6.         this.y = y;  
  7.     }  
  8.     @Override  
  9.     public boolean equals(Object that) {  
  10.         if(that instanceof Point) {  
  11.             Point p = (Point) that;  
  12.             return this.getClass() == p.getClass() &&  
  13.                    this.x == p.x &&   
  14.                    this.y == p.y;  
  15.         }  
  16.         return false;  
  17.     }  
  18.     @Override  
  19.     public int hashCode() {  
  20.         return 41 * (41 + x) + y;  
  21.     }  
  22. }  

- Point3D :
  1. public class Point3D extends Point {  
  2.     public final int z;  
  3.     public Point3D(int x, int y, int z) {  
  4.         super(x, y);  
  5.         this.z = z;  
  6.     }  
  7.     @Override  
  8.     public boolean equals(Object that) {  
  9.         if(that instanceof Point3D) {  
  10.             Point3D p = (Point3D) that;  
  11.             return super.equals(p) && this.z == p.z;  
  12.         }  
  13.         return false;  
  14.     }  
  15. }  

直接判斷類別,讓不同類別的實例視為不相等,就這個例子而言,使得Point只能與Point比,Point3D只能與Point3D比,直接解決了不同繼承階層下equals()的合約問題. 不過在以下這種需求時,這樣的定義也許不符合你的需求 : 
  1. Point p1 = new Point(11);  
  2. Point p2 = new Point(11) {  
  3.            @Override  
  4.             public String toString() {  
  5.                 return "(" + x + ", " + y + ")";  
  6.             }  
  7. };  
  8. Set pSet = new HashSet();  
  9. pSet.add(p1);  
  10. System.out.println(pSet.contains(p1));   // 顯示 true  
  11. System.out.println(pSet.contains(p2));   // 顯示 false,但你想顯示 true, 因為 p2 是匿名類別!!!  
你也許是在某處建立了個匿名類別物件,然後在程式中某處又打算測試看看Set中是否含有相同座標的點,但結果並不是顯示true,這是因為你嚴格地在equals()中檢查了實例的類別名稱. 你可以將定義改為以下 : 
  1. public class Point {  
  2.     public final int x;  
  3.     public final int y;  
  4.     public Point(int x, int y) {  
  5.         this.x = x;  
  6.         this.y = y;  
  7.     }  
  8.     @Override  
  9.     public boolean equals(Object that) {  
  10.         if(that instanceof Point) {  
  11.             Point p = (Point) that;  
  12.             return p.canEquals(this) &&  
  13.                    this.x == p.x &&   
  14.                    this.y == p.y;  
  15.         }  
  16.         return false;  
  17.     }  
  18.     public boolean canEquals(Object that) {  
  19.         return that instanceof Point;  
  20.     }  
  21.     @Override  
  22.     public int hashCode() {  
  23.         return 41 * (41 + x) + y;  
  24.     }  
  25. }  
在equals()中,你不僅檢查傳入的實例是否為Point,也反過來讓傳入的實例取得this的型態進行測試(這是 Visitor 模式 的實現)。而在 Point3D 中 : 
  1. public class Point3D extends Point {  
  2.     public final int z;  
  3.     public Point3D(int x, int y, int z) {  
  4.         super(x, y);  
  5.         this.z = z;  
  6.     }  
  7.     @Override  
  8.     public boolean equals(Object that) {  
  9.         if(that instanceof Point3D) {  
  10.             Point3D p = (Point3D) that;  
  11.             return p.canEquals(this) &&   
  12.                    super.equals(p) && this.z == p.z;  
  13.         }  
  14.         return false;  
  15.     }  
  16.     @Override  
  17.     public boolean canEquals(Object that) {  
  18.         return that instanceof Point3D;  
  19.     }  
  20.     @Override  
  21.     public int hashCode() {  
  22.         return 41 * super.hashCode() + z;  
  23.     }  
  24. }  
如果p1是Point物件,而p2是Point3D物件,p1.equals(p2)時,由於傳入的實例可以取得this的型態進行測試,p2反過來測試p1是不是Point3D,結果不是,所以equals()傳回false,利用這個方式,讓有具體名稱的子類別實例,不會與父類別實例有相等成立的可能性. 如果是直接繼承Point類別的匿名類別物件,則直接繼承canEquals()方法,由於匿名類別物件還是一種Point實例,因此equals()的結果會是true.

沒有留言:

張貼留言

網誌存檔

關於我自己

我的相片
Where there is a will, there is a way!