本文介紹了一種改寫(override)equals 方法的技巧。使用該技巧,即使在實體類的子類添加了新的域(field)時,仍然能夠滿足 equals 方法的約定。
在《Effective Java》一書的第 8 條目中,Josh Bloch 將子類化時滿足 equals 約定這一困難描述為:面向對象語言中等值關系的最根本問題。Bloch 這樣寫道:
不存在一種方式,能夠在擴展非實例類並添加值組件的同時,仍然滿足equals的約定。除非你願意放棄面向對象的抽象性這一優點。
《Programming in Scala》一書中的第 28 章提供了一種方法,子類可以對非實例類進行擴展,添加值組件,而同時滿足 equals 約定。雖然書中提供的那種技巧是用於定義 Scala 類,但一樣適用於 Java 中的 類定義。在本文中,為了講解這種方法,我將使用《Programming in Scala》中相關章節,改編相關的文本,並將原書中的 Scala 示例代碼轉換為了 Java 代碼。
常見的等值陷阱
Class java.lang.Object 定義了一個 equals 方法,其中的子類可以進行改寫(override)。不幸的是,最終的結果表明,在面向對象語言中,編寫正確的等值方法相當困難。事實上,在對 Java 代碼的大量正文進行研究之後,幾位作者在 2007 年的一份論文中作出如下結論:幾乎所有 equals 方法的實現都是錯誤的。
這是一個嚴重的問題,因為等值方法是很多代碼的根本。其一,對於類型 C,一個錯誤的等值方法可能意味著,你不能可靠地將一個類型 C 的對象放入集合中。你可能有兩個等值的類型 C 元素 elem1、elem2,即“em1.equals(elem2)”輸出 true。然而,在下面的示例中,equals 方法的實現就是一種常見的錯誤:
Set< C> hashSet = new java.util.HashSet< C>();
hashSet.add(elem1);
hashSet.contains(elem2); // 返回 false!
存在四種常見的陷阱,它們都會在改寫equals時導致非一致性的行為:
◆使用錯誤的原型對equals進行定義。
◆更改equals而未同時更改 hashCode。
◆對equals進行定義時涉及可變域(field)。
◆未能成功地將equals定義為等值關系。
這四種陷阱將在下文中具體講述。
陷阱 1:使用錯誤的原型對equals進行定義
在下面的代碼中,我們將為普通點的類添加一個等值方法:
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
// ...
}
一個顯而易見的錯誤定義如下:
// 一個完全錯誤的 equals 定義
public boolean equals(Point other) {
return (this.getX() == other.getX() && this.getY() == other.getY());
}
這種方法的錯誤之處是什麼?初一看,它可以正常運行:
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
Point q = new Point(2, 3);
System.out.println(p1.equals(p2)); // prints true
System.out.println(p1.equals(q)); // 輸出 false
然而,一旦你將point放入集合中,問題就出來了:
import java.util.HashSet;
HashSet< Point> coll = new HashSet< Point>();
coll.add(p1);
System.out.println(coll.contains(p2)); // 輸出 false
coll 怎麼可能不包含 p2 呢?你已經將 p1 添加到其中,而 p1 等於 p2。在下面的互操作中,進行比較的點的具體類型被隱藏,這時,導致問題的原因將清晰可見。將 p2a 定義為 p2 的別名,但使用的是 Object 類型而不是 Point:
Object p2a = p2;
現在,如果你重復第一個比較,使用別名 p2a 而不是 p2,結果是:
System.out.println(p1.equals(p2a)); // 輸出 false
哪裡出錯了呢?事實上,由於類型不同,之前指定的 equals 版本並沒有改寫標准方法 equals。下面是在根類 Object 中定義的 equals 方法:
public boolean equals(Object other)
由於 Point 中的 equals 方法使用 Point 而不是 Object 作為參數,因此,它並未對 Object 中的 equals 進行改寫。相反,它只是一種重載的替代方法。Java 中重載由參數的靜態類型解析,而不是運行時(run-time)類型。因此,只要參數的靜態類型是 Point,就調用 Point 中的 equals 方法。同樣,如果靜態參數是 Object 類型,就調用 Object 中的 equals 方法。該方法沒有被改寫,因此在對 object 參數進行比較時,仍使用該方法。這就是“p1.equals(p2a)”輸出 false 的原因,即使點 p1 和 p2a 具有相同的 x 和 y 值。這也是為什麼在 HashSet 中 contains 方法返回 false 的原因。該方法是針對對常規集合進行操作,因此它會調用 Object 中的常規 equals 方法,而不是 Point 中重載的方法變種。
下面的代碼定義了一個更好的equals方法:
// 一個更好的定義,但仍不是完美的
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof Point) {
Point that = (Point) other;
result = (this.getX() == that.getX() && this.getY() == that.getY());
}
return result;
}
現在,equals 具有了正確的類型。它將 Object 類型的值作為參數並輸出一個 boolean 結果。該方法的實現使用了 instanceof 和 cast(類型轉換)。它首先檢測其他(other)對象是否為 Point 類型。如果是,它將對這 2 個點的坐標進行比較,然後返回結果。否則,輸出為 false。
陷阱2 :更改equals而未同時更改hashCode
如果你使用 Point 的最新定義,再次對 p1和 p2a 進行比較,將會得到期望中的結果:true。但是,如果你重復 HashSet.contains 測試,結果仍可能是 false:
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
HashSet< Point> coll = new HashSet< Point>();
coll.add(p1);
System.out.println(coll.contains(p2)); // (很可能)輸出 false
事實上,輸出結果不是百分百確定。你也可能從測試中得到true值。如果得到的結果是true,你可以試試另外一些坐標為1和2的點。最終,你將會找到一個未包含在集合中的點。這裡出現錯誤的原因是,Point重定義了equals而沒有對hashCode進行重定義。
請注意,上述實例中的集合為HashSet。這表示,集合中元素被放在由相應的散列碼決定的哈希桶(hash bucket)中。在contains測試中,它首先查找散列桶,然後對哈希桶中的所有元素和指定元素進行比較。現在,Point類的最新版本確實對equals進行了重定義,但它沒有同時對hashCode進行重定義。所以 hashCode 仍然保持 Object 類中其版本的值:分配對象地址的某種變化格式。p1 和 p2 的散列碼幾乎肯定是不同,即使這兩個點的域(field)是相同的。不同的散列碼意味著集合中散列桶具有較高概率的非重復性。contains 測試將根據 p2 的散列碼在相應的散列桶中查找匹配的元素。大多數情況下,點 p1 會位於另一個散列桶中,因此絕不會找到它。p1 和 p2 有可能很偶然地位於同一散列桶中。對於這種情況,測試將返回ture 值。
問題在於,Point 的上次實現違法了Object 類中定義的hashCode約定:
如果兩個對象根據equals(Object) 方法是等值的,那麼對兩個對象中任何一個調用 hashCode 方法都必須得到相同的整型結果。
事實上,在Java中,通常應同時對 hashCode 和equals進行重定義,這一事實是廣為人知的。此外,hashCode 可能僅依賴equals所依賴的域。對於 Point 類,以下將是一個合適的 hashCode 定義:
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof Point) {
Point that = (Point) other;
result = (this.getX() == that.getX() && this.getY() == that.getY());
}
return result;
}
@Override public int hashCode() {
return (41 * (41 + getX()) + getY());
}
}
這只是 hashCode 多種可能的實現中的一種。將常量 41 加到一個整型域 x 上,所得結果再乘以素數 41,然後在加上另一個整型域 y。這樣就可以提供合理分布的散列碼,而運行時間和代碼大小也會降低。
在定義與 Point 相似的類時,添加 hashCode 解決了等值的問題。但是,還有其他的問題需要注意。
陷阱 3 :對equals進行定義時涉及可變域
以下對 Point 類進行一項細微的修改:
public class Point {
private int x;
private int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
public void setX(int x) {
this.x = x;
}
public void setY(int y) {
this.y = y;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof Point) {
Point that = (Point) other;
result = (this.getX() == that.getX() && this.getY() == that.getY());
}
return result;
}
@Override public int hashCode() {
return (41 * (41 + getX()) + getY());
}
}
唯一的不同之處是域 x 和 y 不再是 final 類型,同時添加了兩個集合方法,允許用戶更改 x 和 y 值。現在,equals和 hashCode 方法的定義涉及了這些可變域,因此域更改時它們的結果也將改變。一旦你將點放入集合中,這會帶來很奇怪的效果:
Point p = new Point(1, 2);
HashSet< Point> coll = new HashSet< Point>();
coll.add(p);
System.out.println(coll.contains(p)); // 輸出 true
現在,如果更改點 p 中的域,集合還將包含該點嗎? 我們來試試下面的代碼:
p.setX(p.getX() + 1);
System.out.println(coll.contains(p)); // (很可能)輸出 false
這看起來很奇怪。p 到哪裡去了?如果你對集合的 iterator 是否包含 p 進行測試,會得到各位奇怪的結果:
Iterator< Point> it = coll.iterator();
boolean containedP = false;
while (it.hasNext()) {
Point nextP = it.next();
if (nextP.equals(p)) {
containedP = true;
break;
}
}
System.out.println(containedP); // 輸出 true
此處的集合不包含 p,但 p 卻在該集合的元素之中!發生了什麼事呢?在更改 x 域之後,點 p 最後被放在了該集合 coll 下錯誤的散列桶中。也就是,其初始散列桶與散列碼的新值已不再對應。在某種意義上可以說,點 p 在集合 coll 中消失了,即使它仍然是集合中元素。
從這個示例得出的教訓就是,當equals和 hashCode 取決於可變狀態時,可能會為用戶帶來問題。如果他們將這種對象放入集合中,必須小心,不要修改決定性的狀態。而這是很棘手的。如果你現在需要進行一個比較,要考慮到對象的當前狀態,通常不應直接使用 equals,而是使用其他命名。 對於 Point 的上一個定義,更為可取的是省略 hashCode 的重定義,並且命名比較方法 equalContents,或者使用其他不同於equals的命名。 這樣,Point 將能夠繼承equals和 hashCode 的缺省實現。
陷阱 4:未能成功地將equals定義為等值關系
Object 中equals的約定指出 equals 必須實現非空對象的等值關系:
◆自反性:對於如何非空值 x,表達式 x.equals(x) 應返回true。
◆對稱性:對於任何非空值:x 和 y,x.equals(y) 應返回true,當且僅當 y.equals(x) 返回 true。
◆傳遞性:對於任何非空值 x、y、z,如果 x.equals(y)返回 true 並且 y.equals(z) 返回 true,那麼x.equals(z) 應返回 true。
◆一致性:對於任何非空值:x 和 y,多次調用 x.equals(y)應始終返回 true 或始終返回 false,如果對象的equals比較中所用信息未被修改。
◆對於任何非空值 x,x.equals(null) 應返回 false。
目前,對於 Point 類所使用的equals定義滿足了equals的約定。然而,一旦涉及子類,事情將變得更加復雜。比如說,Point 有一個子類 ColoredPoint,其中添加了一個 Color 類型的域 color。假定將 Color 定義為枚舉類型:
public enum Color {
RED, ORANGE, YELLOW, GREEN, BLUE, INDIGO, VIOLET;
}
ColoredPoint 改寫 equals,並添加新的 color 域:
public class ColoredPoint extends Point { // 問題:equals 不對稱
private final Color color;
public ColoredPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof ColoredPoint) {
ColoredPoint that = (ColoredPoint) other;
result = (this.color.equals(that.color) && super.equals(that));
}
return result;
}
}
很多程序員都可能這樣編寫代碼。請注意,在這種情況下,類 ColoredPoint 不必改寫 hashCode。因為 ColoredPoint 的equals的新定義比 Point 中被改寫的定義更為嚴格(意味著等值的對象更少),hashCode 的約定仍然有效。 如果兩個顏色點是相等的,它們必須具有相同的坐標,因此也已保證它們的散列碼是相等的。
以類 ColoredPoint 本身為例,equals的定義看起來沒問題。但,一旦普通點和顏色點混合著一起時,equals的約定就將被破壞。 例如:
Point p = new Point(1, 2);
ColoredPoint cp = new ColoredPoint(1, 2, Color.RED);
System.out.println(p.equals(cp)); // 輸出 true
System.out.println(cp.equals(p)); // 輸出 false
相等比較“pequalscp”將調用 p 的equals方法,已在類 Point 中定義。該方法僅考慮兩個點的坐標。因此,相等比較輸出 true 值。而另一方面,相等比較“cp equals p”調用 cp 的 equals 方法,其中類 ColoredPoint 中已定義。該方法返回 false,因為 p 不是 ColoredPoint。該方法返回 false,因為 p 不是 ColoredPoint。 因此,equals定義的關系不是對稱的。
對稱的缺失將為集合造成意想不到的後果。下面為一個示例:
Set< Point> hashSet1 = new java.util.HashSet< Point>();
hashSet1.add(p);
System.out.println(hashSet1.contains(cp)); // 輸出 false
Set< Point> hashSet2 = new java.util.HashSet< Point>();
hashSet2.add(cp);
System.out.println(hashSet2.contains(p)); // 輸出 true
因此,即使 p 和 cp 是相等的,一個 contains 測試成功,而另一個卻失敗。
如何更改equals的 定義可以讓它變為對稱?基本上有兩種方式。您可以使關系更一般或更嚴格。使它更加一般意味著對兩個對象,a 和 b 被認為是相等的,如果比較 a 和 b 或 b 和 a 輸出 true。以下為完成該功能的代碼:
public class ColoredPoint extends Point { // 有問題:equals 不具有傳遞性
private final Color color;
public ColoredPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof ColoredPoint) {
ColoredPoint that = (ColoredPoint) other;
result = (this.color.equals(that.color) && super.equals(that));
}
else if (other instanceof Point) {
Point that = (Point) other;
result = that.equals(this);
}
return result;
}
}
在 ColoredPoint 中,equals的新定義比舊版本多了一種情況的檢查:如果其他對象是 Point 而不是 ColoredPoint,該方法將使用 Point 的equals方法。這樣就可以取得預期的效果,使equals具有對稱性。現在,“cp.equals(p)”和“p.equals(cp)”都返回 true。 然而,equals的約定還是被打破了。現在的問題是,新的關系不再具有傳遞性!為了演示這個問題,下面進行一系列的聲明。定義一個點和兩個不同色的顏色點,所有點在同一位置:
ColoredPoint redP = new ColoredPoint(1, 2, Color.RED);
ColoredPoint blueP = new ColoredPoint(1, 2, Color.BLUE);
單獨來看,redp 等於 p 並且 p 等於 bluep:
System.out.println(redP.equals(p)); // 輸出 true
System.out.println(p.equals(blueP)); // 輸出 true
然而,比較 redP 和 blueP,輸出 false:
System.out.println(redP.equals(blueP)); // 輸出 false
因此,這違反了equals約定中的傳遞性子條款。
讓equals關系更一般看來是死路一條。下面我們試試讓它更嚴格。使equals更嚴格的一個方法是:不同類的對象,不同地對待。通過修改類 Point 和 ColoredPoint 中的equals方法來實現。 在類 Point 中,可以添加一個額外的比較,用於檢查其他點的運行時類是否與這個點的類相同,代碼如下:
// 技術上有效,但仍不能令人滿意的 equals 方法
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof Point) {
Point that = (Point) other;
result = (this.getX() == that.getX() && this.getY() == that.getY()
&& this.getClass().equals(that.getClass()));
}
return result;
}
@Override public int hashCode() {
return (41 * (41 + getX()) + getY());
}
}
然後,你就可以將類 ColoredPoint 的實現恢復為之前違反了對稱性要求的版本:
public class ColoredPoint extends Point { // 不再違反平衡性要求
private final Color color;
public ColoredPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof ColoredPoint) {
ColoredPoint that = (ColoredPoint) other;
result = (this.color.equals(that.color) && super.equals(that));
}
return result;
}
}
在這裡,類 Point 的實例被認為與系統類的其他實例是相等的,僅當對象具有相同的坐標,並且具有相同的運行時類,即每個對象 .getClass() 返回相同的值。 新的定義可以滿足對象性和傳遞性的要求,因為現在對不同類之間的每次對比都將返回 false。因此,顏色點永遠不會與普通點相等。這種約定看起來是合理的,當有人會指出新的定義太過嚴格了。
下面使用稍微有點繞的方式定義位於坐標(1, 2)上的點:
Point pAnon = new Point(1, 1) {
@Override public int getY() {
return 2;
}
};
pAnon 等於 p 嗎?答案是否定的,因為與 p 和 pAnon 關聯的 java.lang.Class 對象是不同的。對於 p 是 Point 類,而對於 pAnon,它是 Point 的一個匿名子類。 但顯然,pAnon 只是位於坐標(1, 2)上的另一個點。 認為它與 p 不同,看起來並不合理。
明智的方式:canEqual 方法
從以上各種情況,看起來我們進退兩難。是否存在一種明智的方式,在類層次結構的多個分層中對等值比較進行重定義,而同時滿足其約定?事實上,有這樣一種方式,但它需要另一個方法來重定義equals和 hashCode。這個想法是,只要類重定義 equals(和 hashCode),它就應該同時顯式地聲明,該類的所有對象與使用不同等值方法的超類中的對象,絕對不會相等。通過對重定義equals的每個類添加方法 canEqual 就可以實現。以下為該方法的原型:
public boolean canEqual(Object other)
當其他(other)對象是(重)定義了 canEqual 的類的實例時,該方法應返回 true,或者返回 false。它從equals中調用,以確保這些對象使用2種方式都是可比較的。下面是類 Point 新的也是最後的一個實現:
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof Point) {
Point that = (Point) other;
result = (that.canEqual(this) && this.getX() == that.getX() && this.getY() == that.getY());
}
return result;
}
@Override public int hashCode() {
return (41 * (41 + getX()) + getY());
}
public boolean canEqual(Object other) {
return (other instanceof Point);
}
}
類 Point:該版本的equals方法包含了一個附加的要求,由 canEqual 方法決定,其他(other)對象可以等於這個(this)對象。Point 中的 canEqual 實現聲明所有 Point 實例都可以是相等的。
下面是 ColoredPoint 相應的實現:
public class ColoredPoint extends Point { // 不再違反對稱性要求
private final Color color;
public ColoredPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof ColoredPoint) {
ColoredPoint that = (ColoredPoint) other;
result = (that.canEqual(this) && this.color.equals(that.color) && super.equals(that));
}
return result;
}
@Override public int hashCode() {
return (41 * super.hashCode() + color.hashCode());
}
@Override public boolean canEqual(Object other) {
return (other instanceof ColoredPoint);
}
}
可以證明 Point 和 ColoredPoint 的新定義滿足了equals的約定。 等值是對稱和可傳遞的。將一個 Point 與 ColoredPoint 比較,總會輸出 false。事實上,任何普通點 p 和顏色點 cp,“p.equals(cp)”將返回 false ,因為“cp.canEqual(p)”將返回 false。反向進行比較,“cp.equals(p)”也將返回 false ,因為 p 的確不是ColoredPoint,所以 ColoredPoint 中equals正文中第一個 instanceof 檢查將失敗。
另一方面, Point 的不同子類的實例可以是相等的,只要這些類沒有重定義等值比較方法。例如,使用新的類定義,p 和 pAnon 的比較結果為 true。下面是一些例子:
Point p = new Point(1, 2);
ColoredPoint cp = new ColoredPoint(1, 2, Color.INDIGO);
Point pAnon = new Point(1, 1) {
@Override public int getY() {
return 2;
}
};
Set coll = new java.util.HashSet();
coll.add(p);
System.out.println(coll.contains(p)); // 輸出 true
System.out.println(coll.contains(cp)); // 輸出 false
System.out.println(coll.contains(pAnon)); // 輸出 true
這些例子顯示,如果超類equals實現定義並調用 canEqual,那麼實現子類的程序員可以決定他們的子類是否與超類的實例相等。由於 ColoredPoint 改寫了 canEqual,例如,顏色點可能遠不會與普通點相等。但由於 pAnon 中引用的匿名子類並未改寫 canEqual,其實例可以與 Point 實例相等。
對 canEqual 方式,一種可能的批評是,它違反了裡氏替換原則(Liskov Substitution Principle:縮寫 LSP)。例如,通過比較運行時類來實現 equals 的技巧,被認為違反了 LSP,因為該技巧導致無法定義這樣一個子類:其實例可以等於超類的實例。其推理思路是,LSP 聲明:在需要超類實例的地方,你應能夠使用(調換)子類實例。但是,在之前的實例中,“coll.contains(cp)”返回 false,即使 cp 的 x 和 y 值與集合中點相匹配。因此,看起來它可能像是違反了 LSP,因為你不能中出現 Point 的地方使用 ColoredPoint。但是,我們認為這是一種錯誤的解釋,因為 LSP 並不要求子類的行為與超類完全相同,而只是它的行為方式能夠滿足超類的約定。
編寫equals方法對運行時類進行比較的問題不在於它違反了 LSP,而是它沒有為你提供一種方式,用來創建其實例與超類實例相等的子類。例如,如果我們之前的實例中使用運行時類的技巧,“coll.contains(pAnon)”將返回 false,而這不是我們想要的。相反,我們真的想要“coll.contains(cp)”返回 false,因為通過改寫 ColoredPoint 中的 equals,我們基本上是在表示,一個位於坐標(1, 2)上的深藍色點與位於(1, 2)的非顏色點並不相同。因此,在前面的例子中,我們可以將兩個不同 Point 子類實例傳遞到集合的 contains 方法中,並且得到兩個不同的結果,兩個都是正確的。
原文:How to Write an Equality Method in Java
作者:Martin Odersky,Lex Spoon,以及Bill Venners