本文描述重載equals方法的技術,這種技術即使是具現類的子類增加了字段也能保證equal語義的正確性。
在《Effective Java》的第8項中,Josh Bloch描述了當繼承類作為面向對象語言中的等價關系的基礎問題,要保證派生類的equal正確性語義所會面對的困難。Bloch這樣寫到:
除非你忘記了面向對象抽象的好處,否則在當你繼承一個新類或在類中增加了一個值組件時你無法同時保證equal的語義依然正確
在《Programming in Scala》中的第28章演示了一種方法,這種方法允許即使繼承了新類,增加了新的值組件,equal的語義仍然能得到保證。雖然在這本書中這項技術是在使用Scala類環境中,但是這項技術同樣可以應用於Java定義的類中。在本文中的描述來自於Programming in Scala中的文字描述,但是代碼被我從scala翻譯成了Java
java.lang.Object 類定義了equals這個方法,它的子類可以通過重載來覆蓋它。不幸的是,在面向對象中寫出正確的equals方法是非常困難的。事實上,在研究了大量的Java代碼後,2007 paper的作者得出了如下的一個結論:
幾乎所有的equals方法的實現都是錯誤的!
這個問題是因為等價是和很多其他的事物相關聯。例如其中之一,一個的類型C的錯誤等價方法可能意味著你無法將這個類型C的對象可信賴的放入到容器中。比如說,你有兩個元素elem1和elem2他們都是類型C的對象,並且他們是相等,即elem1.equals(elm2)返回ture。但是,只要這個equals方法是錯誤的實現,那麼你就有可能會看見如下的一些行為:
Set hashSet<C> = new java.util.HashSet<C>(); hashSet.add(elem1); hashSet.contains(elem2); // returns false!
當equals重載時,這裡有4個會引發equals行為不一致的常見陷阱:
在剩下的章節中我們將依次討論這4中陷阱。
考慮為下面這個簡單類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; } // ... }
看上去非常明顯,但是按照這種方式來定義equals就是錯誤的。
// An utterly wrong definition of 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)); // prints false
然而,當我們一旦把這個Point類的實例放入到一個容器中問題就出現了:
import java.util.HashSet; HashSet<Point> coll = new HashSet<Point>(); coll.add(p1); System.out.println(coll.contains(p2)); // prints false
為什麼coll中沒有包含p2呢?甚至是p1也被加到集合裡面,p1和p2是是等價的對象嗎?在下面的程序中,我們可以找到其中的一些原因,定義p2a是一個指向p2的對象,但是p2a的類型是Object而非Point類型:
Object p2a = p2;
現在我們重復第一個比較,但是不再使用p2而是p2a,我們將會得到如下的結果:
System.out.println(p1.equals(p2a)); // prints false
到底是那裡出了了問題?事實上,之前所給出的equals版本並沒有覆蓋Object類的equals方法,因為他的類型不同。下面是Object的equals方法的定義
public boolean equals(Object other)
因為Point類中的equals方法使用的是以Point類而非Object類做為參數,因此它並沒有覆蓋Object中的equals方法。而是一種變化了的重載。在Java中重載被解析為靜態的參數類型而非運行期的類型,因此當靜態參數類型是Point,Point的equals方法就被調用。然而當靜態參數類型是Object時,Object類的equals就被調用。因為這個方法並沒有被覆蓋,因此它仍然是實現成比較對象標示。這就是為什麼雖然p1和p2a具有同樣的x,y值,”p1.equals(p2a)”仍然返回了false。這也是會什麼HasSet的contains方法返回false的原因,因為這個方法操作的是泛型,他調用的是一般化的Object上equals方法而非Point類上變化了的重載方法equals
一個更好但不完美的equals方法定義如下:
// A better definition, but still not perfect @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類型的參數和一個返回布爾型的結果。這個方法的實現使用instanceof操作和做了一個造型。它首先檢查這個對象是否是一個Point類,如果是,他就比較兩個點的坐標並返回結果,否則返回false。
如果你使用上一個定義的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 (有可能)
事實上,這個個結果不是100%的false,你也可能有返回ture的經歷。如果你得到的結果是true的話,那麼你試試其他的坐標值,最終你一定會得到一個在集合中不包含的結果。導致這個結果的原因是Point重載了equals卻沒有重載hashCode。
注意上面例子的的容器是一個HashSet,這就意味著容器中的元素根據他們的哈希碼被被放入到”哈希桶 hash buckets”中。contains方法首先根據哈希碼在哈希桶中查找,然後讓桶中的所有元素和所給的參數進行比較。現在,雖然最後一個Point類的版本重定義了equals方法,但是它並沒有同時重定義hashCode。因此,hashCode仍然是Object類的那個版本,即:所分配對象的一個地址的變換。所以p1和p2的哈希碼理所當然的不同了,甚至是即時這兩個點的坐標完全相同。不同的哈希碼導致他們具有極高的可能性被放入到集合中不同的哈希桶中。contains方法將會去找p2的哈希碼對應哈希桶中的匹配元素。但是大多數情況下,p1一定是在另外一個桶中,因此,p2永遠找不到p1進行匹配。當然p2和p2也可能偶爾會被放入到一個桶中,在這種情況下,contains的結果就為true了。
最新一個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一個可能的實現。x域加上常量41後的結果再乘與41並將結果在加上y域的值。這樣做就可以以低成本的運行時間和低成本代碼大小得到一個哈希碼的合理的分布(譯者注:性價比相對較高的做法)。
增加hashCode方法重載修正了定義類似Point類等價性的問題。然而,關於類的等價性仍然有其他的問題點待發現。
讓我們在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) { // Problematic 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,並且兩個set方法被增加到類中來,並允許客戶改變x和y的值。equals和hashCode這個方法的定義現在是基於在這兩個會發生變化的域上,因此當他們的域的值改變時,結果也就跟著改變。因此一旦你將這個point對象放入到集合中你將會看到非常神奇的效果。
Point p = new Point(1, 2); HashSet<Point> coll = new HashSet<Point>(); coll.add(p); System.out.println(coll.contains(p)); // 打印 true
現在如果你改變p中的一個域,這個集合中還會包含point嗎,我們將拭目以待。
p.setX(p.getX() + 1); System.out.println(coll.contains(p)); // (有可能)打印 false
看起來非常的奇怪。p去那裡去了?如果你通過集合的迭代器來檢查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最終的的hashCode是在集合coll錯誤的哈希桶中。即,原始哈希桶不再有其新值對應的哈希碼。換句話說,p已經在集合coll的是視野范圍之外,雖然他仍然屬於coll的元素。
從這個例子所得到的教訓是,當equals和hashCode依賴於會變化的狀態時,那麼就會給用戶帶來問題。如果這樣的對象被放入到集合中,用戶必須小心,不要修改這些這些對象所依賴的狀態,這是一個小陷阱。如果你需要根據對象當前的狀態進行比較的話,你應該不要再重定義equals,應該起其他的方法名字而不是equals。對於我們的Point類的最後的定義,我們最好省略掉hashCode的重載,並將比較的方法名命名為equalsContents,或其他不同於equals的名字。那麼Point將會繼承原來默認的equals和hashCode的實現,因此當我們修改了x域後p依然會呆在其原來在容器中應該在位置。
Object中的equals的規范闡述了equals方法必須實現在非null對象上的等價關系:
Point類的equals定義已經被開發成了足夠滿足equals規范的定義。然而,當考慮到繼承的時候,事情就開始變得非常復雜起來。比如說有一個Point的子類ColoredPoint,它比Point多增加了一個類型是Color的color域。假設Color被定義為一個枚舉類型:
public enum Color { RED, ORANGE, YELLOW, GREEN, BLUE, INDIGO, VIOLET; }
ColoredPoint重載了equals方法,並考慮到新加入color域,代碼如下:
public class ColoredPoint extends Point { // Problem: equals not symmetric 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; } }
這是很多程序員都有可能寫成的代碼。注意在本例中,類ColoredPointed不需要重載hashCode,因為新的ColoredPoint類上的equals定義,嚴格的重載了Point上equals的定義。hashCode的規范仍然是有效,如果兩個著色點(colored point)相等,其坐標必定相等,因此它的hashCode也保證了具有同樣的值。
對於ColoredPoint類自身對象的比較是沒有問題的,但是如果使用ColoredPoint和Point混合進行比較就要出現問題。
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
“p等價於cp”的比較這個調用的是定義在Point類上的equals方法。這個方法只考慮兩個點的坐標。因此比較返回真。在另外一方面,“cp等價於p”的比較這個調用的是定義在ColoredPoint類上的equals方法,返回的結果卻是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 { // Problem: equals not transitive 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)); // prints true System.out.println(p.equals(blueP)); // prints true
然而,對比redP和blueP的結果是false:
System.out.println(redP.equals(blueP)); // 打印 false
因此,equals的傳遞性就被違背了。
使equals的關系更一般化似乎會將我們帶入到死胡同。我們應該采用更嚴格化的方法。一種更嚴格化的equals方法是認為不同類的對象是不同的。這個可以通過修改Point類和ColoredPoint類的equals方法來達到。你能增加額外的比較來檢查是否運行態的這個Point類和那個Point類是同一個類,就像如下所示的代碼一樣:
// A technically valid, but unsatisfying, equals method 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類的equals實現用回剛才那個不滿足對稱性要的equals實現了。
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。所以著色點(colored point)永遠不會等於點(point)。通常這看起來非常合理,但是這裡也存在著另外一種爭論——這樣的比較過於嚴格了。
考慮我們如下這種稍微的迂回的方式來定義我們的坐標點(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上的另外一個點。所以將他們認為是不同的點是沒有理由的。
到此,我們看其來似乎是遇到阻礙了,存在著一種正常的方式不僅可以在不同類繼承層次上定義等價性,並且保證其等價的規范性嗎?事實上,的確存在這樣的一種方法,但是這就要求除了重定義equals和hashCode外還要另外的定義一個方法。基本思路就是在重載equals(和hashCode)的同時,它應該也要要明確的聲明這個類的對象永遠不等價於其他的實現了不同等價方法的超類的對象。為了達到這個目標,我們對每一個重載了equals的類新增一個方法canEqual方法。這個方法的方法簽名是:
public boolean canEqual(Object other)
如果other 對象是canEquals(重)定義那個類的實例時,那麼這個方法應該返回真,否則返回false。這個方法由equals方法調用,並保證了兩個對象是可以相互比較的。下面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方法中包含了一個額外的需求,通過canEquals方法來決定另外一個對象是否是是滿足可以比較的對象。在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類定義保證了等價的規范。等價是對稱和可傳遞的。比較一個Point和ColoredPoint類總是返回false。因為點p和著色點cp,“p.equals(cp)返回的是假。並且,因為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<Point> coll = new java.util.HashSet<Point>(); 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的實現定義並調用了canEquals,那麼開發人員實現的子類就能決定這個子類是否可以和它父類的實例進行比較。例如ColoredPoint,因為它以”一個著色點永遠不可以等於普通不帶顏色的點重載了” canEqual,所以他們就不能比較。但是因為pAnon引用的匿名子類沒有重載canEqual,因此它的實例就可以和Point的實例進行對比。
canEqual方法的一個潛在的爭論是它是否違背了Liskov替換准則(LSP)。例如,通過比較運行態的類來實現的比較技術(譯者注: canEqual的前一版本,使用.getClass()的那個版本),將導致不能定義出一個子類,這個子類的實例可以和其父類進行比較,因此就違背了LSP。這是因為,LSP原則是這樣的,在任何你能使用父類的地方你都可以使用子類去替換它。在之前例子中,雖然cp的x,y坐標匹配那些在集合中的點,然而”coll.contains(cp)”仍然返回false,這看起來似乎違背得了LSP准則,因為你不能這裡能使用Point的地方使用一個ColoredPointed。但是我們認為這種解釋是錯誤的,因為LSP原則並沒有要求子類和父類的行為一致,而僅要求其行為能一種方式滿足父類的規范。
通過比較運行態的類來編寫equals方法(譯者注: canEqual的前一版本,使用.getClass()的那個版本)的問題並不是違背LSP准則的問題,但是它也沒有為你指明一種創建派生類的實例能和父類實例進行對比的的方法。例如,我們使用這種運行態比較的技術在之前的”coll.contains(pAnon)”將會返回false,並且這並不是我們希望的。相反我們希望“coll.contains(cp)”返回false,因為通過在ColoredPoint中重載的equals,我基本上可以說,一個在坐標1,2上著色點和一個坐標1,2上的普通點並不是一回事。然而,在最後的例子中,我們能傳遞Point兩種不同的子類實例到集合中contains方法,並且我們能得到兩個不同的答案,並且這兩個答案都正確。