在Java中,如果要比較兩個物件的實質相等性,並不是使用==,而是必須透過equals()方法,例如:
String s1 = new String("Java");
String s2 = new String("Java");
System.out.println(s1 == s2); // 顯示 false
System.out.println(s1.equals(s2)); // 顯示 true
String s2 = new String("Java");
System.out.println(s1 == s2); // 顯示 false
System.out.println(s1.equals(s2)); // 顯示 true
兩個物件是新建構出來的,所以s1與s2是參考到不同物件,因而使用==比較會是false,要比較兩個字串的實質字元序列,必須使用equals(),這是因為String的equals()重新定義為比較兩個字串的字元序列。
如果你定義類別時,沒有重新定義equals()方法,則預設繼承自Object,Object的equals()方法是定義為:
public boolean equals(Object obj) {
return (this == 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);
System.out.println(p1.equals(p2)); // 顯示 true
Point p2 = new Point(1, 1);
System.out.println(p1.equals(p2)); // 顯示 true
看來似乎沒錯,p1與p2座標都是同一點,所以實際上指的相同的座標,但是如果你這麼測試:
Point p1 = new Point(1, 1);
Point p2 = new Point(1, 1);
Set<Point> pSet = new HashSet<Point>();
pSet.add(p1);
System.out.println(pSet.contains(p2)); // 顯示 false
Point p2 = new Point(1, 1);
Set<Point> pSet = new HashSet<Point>();
pSet.add(p1);
System.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);
System.out.println(p1.equals(p2)); // 顯示 false
Point p2 = new Point(1, 1);
System.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);
System.out.println(p1.equals(p2)); // 顯示 true
Point p2 = new Point(1, 1);
System.out.println(p1.equals(p2)); // 顯示 true
結果看來是正確了,不過:
Point p1 = new Point(1, 1);
Point p2 = new Point(1, 1);
Set<Point> pSet = new HashSet<Point>();
pSet.add(p1);
System.out.println(pSet.contains(p2)); // 可能顯示 false
Point p2 = new Point(1, 1);
Set<Point> pSet = new HashSet<Point>();
pSet.add(p1);
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()。例如:
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<Point> pSet = new HashSet<Point>();
pSet.add(p1);
System.out.println(pSet.contains(p2)); // 顯示 true
Point p2 = new Point(1, 1);
Set<Point> pSet = new HashSet<Point>();
pSet.add(p1);
System.out.println(pSet.contains(p2)); // 顯示 true
一個重要的觀念是,定義equals()與hashCode()時,最好別使用狀態會改變的資料成員。你可能會想,以這個例子來說,點會移動,如果移動了就不是相同的點了,不是嗎?假設x、y是個允許會變動的成員,那麼就會發生這個情況:
Point p1 = new Point(1, 1);
Set<Point> pSet = new HashSet<Point>();
pSet.add(p1);
System.out.println(pSet.contains(p1)); // 顯示 true
p1.x = 2;
System.out.println(pSet.contains(p1)); // 顯示 false
Set<Point> pSet = new HashSet<Point>();
pSet.add(p1);
System.out.println(pSet.contains(p1)); // 顯示 true
p1.x = 2;
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的點:
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);
System.out.println(p1.equals(p2)); // 顯示 true
System.out.println(p2.equals(p1)); // 顯示 false
Point p2 = new Point3D(1, 1, 1);
System.out.println(p1.equals(p2)); // 顯示 true
System.out.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);
System.out.println(p2.equals(p1)); // 顯示 true
System.out.println(p1.equals(p3)); // 顯示 true
System.out.println(p2.equals(p3)); // 顯示 false
Point p2 = new Point3D(1, 1, 1);
Point p3 = new Point3D(1, 1, 2);
System.out.println(p2.equals(p1)); // 顯示 true
System.out.println(p1.equals(p3)); // 顯示 true
System.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<Point> pSet = new HashSet<Point>();
pSet.add(p1);
System.out.println(pSet.contains(p1)); // 顯示 true
System.out.println(pSet.contains(p2)); // 顯示 false,但你想顯示 true
Point p2 = new Point(1, 1) {
@Override
public String toString() {
return "(" + x + ", " + y + ")";
}
};
Set<Point> pSet = new HashSet<Point>();
pSet.add(p1);
System.out.println(pSet.contains(p1)); // 顯示 true
System.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<Point> pSet = new HashSet<Point>();
pSet.add(p1);
System.out.println(pSet.contains(p1)); // 顯示 true
System.out.println(pSet.contains(p2)); // 顯示 true
System.out.println(pSet.contains(p3)); // 顯示 false
Point p2 = new Point(1, 1) {
@Override
public String toString() {
return "(" + x + ", " + y + ")";
}
};
Point p3 = new Point3D(1, 1, 1);
Set<Point> pSet = new HashSet<Point>();
pSet.add(p1);
System.out.println(pSet.contains(p1)); // 顯示 true
System.out.println(pSet.contains(p2)); // 顯示 true
System.out.println(pSet.contains(p3)); // 顯示 false
後記:這篇文件是看完 Programming in Scala 第28章Object equality後的心得,範例的流程與書中的類似,使用Java實現並依Java的特性作了些修飾與補充。