
避免重寫 equals 方法
重寫equals 方法看起來很簡單,但是還會有多種方式導緻出錯,後果可能是嚴重的。最簡單,最容易避免出錯的方式是 避免重寫equals方法 ,采用這種方式的每個類隻需要和自己對比即可,這樣永遠不會出錯。如果滿足了以下任何一個約定,也能産生正确的結果:
1. 該類的每個執行個體本質上都是唯一的
即使對于像Thread 這種代表活動狀态的實體而不是值的類來說也是如此。Object提供的equals方法也能確定這個類展現出正确的行為。
2. 類沒有必要提供邏輯相等的測試
例如:java.util.regex.Pattern能夠重寫equals檢查是否兩個Pattern 執行個體是否代表了同一個正規表達式。但是設計者并不認為客戶需要或者期望這樣的功能。在這種情況下,從Object繼承的equals方法的實作就已經足夠了。
3. 超類已經重寫了equals方法,并且超類的行為對此類也适用
例如:大部分Set實作從AbstractSet那裡繼承了equals方法,List實作從AbstractList那裡繼承了equals 方法,Map實作從AbstractMap那裡繼承了equals 方法。
4. 這個類是私有的或者包級私有的,可以确定equals方法永遠不會調用
如果你非常想要規避風險,那就確定equals方法不會突然調用
@Override public boolean equals(Object o){
throw new AssertionError();
}
複制
那麼何時重寫equals方法呢?
當一個類具有邏輯相等的概念時,它不僅僅是對象身份,而超類還沒有覆寫equals,這通常屬于值類的情形。一個值類僅僅是一個代表了值的類,例如Integer 或者String。程式員用equals來比較對象的時候,往往想要知道的是兩個對象在邏輯上是否相等,而不是想了解他們是否指向同一個對象。為了滿足程式員的要求,不僅必須覆寫equals方法,而且這樣做也使得這個類的執行個體可以用作映射表(map)的鍵(key),或者集合(set)的元素,使映射或者集合表現出正确的行為。
一種不需要重寫equals方法的值類是一個使用單例實作類,以確定每個值最多隻有一個對象。枚舉類型就屬于此類别。對于這些類,邏輯相等就是對象相等,是以對象的equals方法判斷的相等也表示邏輯相等。
重寫equals 遵循的約定
如果你非要重寫equals 方法,請遵循以下約定:
- 自反性:對于任何非 null 的引用值 x,x.equals(x),必須傳回true,null equals (null) 會有空指針。
- 對稱性:對于任何非 null 的引用值 x 和 y,當且僅當 x.equals(y) 為true時,y.equals(x) 時也必須傳回true。
- 傳遞性:對于任何非 null 的引用值 x 、y和 z ,如果 x.equals(y) 為 true 時,y.equals(z) 也是 true 時,那麼x.equals(z) 也必須傳回 true。
- 一緻性:對于任何非 null 的引用值 x 和 y,隻要 equals 比較在對象中資訊沒有修改,多次調用 x.equals(y) 就會一緻傳回 true,或者一緻傳回 false。
- 對于任何非 null 的引用值x, x.equals(null) 必須傳回false。
解釋
現在你已經知道了違反 equals 約定是多麼可怕,下面将更細緻的讨論,下面我們逐一檢視這五個要求
自反性
自反性:第一個要求僅僅說明對象必須等于它自身,假如違背了這一條,然後把該類添加到集合中,該集合的 contains 方法會告訴你,該集合不包含你剛剛添加的執行個體。
對稱性
對稱性:這個要求是說,任何兩個對象在對于"它們是否相等" 的問題上都必須保持一緻。例如如下這個例子
public class CaseInsensitiveString {
private final String s;
public CaseInsensitiveString(String s){
this.s = Objects.requireNonNull(s);
}
@Override
public boolean equals(Object o) {
if(o instanceof CaseInsensitiveString){
return s.equalsIgnoreCase(((CaseInsensitiveString)o).s);
}
if(o instanceof String){
return s.equalsIgnoreCase((String)o);
}
return false;
}
public static void main(String[] args) {
CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "Polish";
System.out.println(cis.equals(s));
System.out.println(s.equals(cis));
}
}
複制
不出所料,cris.equals(s) 傳回true。問題在于,雖然 CaseInsensitiveString 類的 equals 方法知道普通的字元串對象,但是, String 類中的 equals 方法卻并不知道不區分大小寫的字元串,是以,s.equals(cris) 傳回false,顯然違反了對稱性。
如果你用下面的示例來進行操作
List<CaseInsensitiveString> list = new ArrayList<>();
list.add(cis);
System.out.println(list.contains(s));
複制
會傳回什麼呢?
沒人知道,可能在 OpenJDK 實作中會傳回 false,但這隻是特定實作的結果而已,在其他的實作中,也有可能傳回true,或者抛出運作時異常,是以我們能總結出一點:一旦違反了equals 約定,當面對其他對象時,你完全不知道這些對象的行為會怎麼樣
為了解決這個問題,那麼就需要去掉與 String 互操作的這段代碼去掉,變成下面這樣
@Override
public boolean equals(Object o) {
return o instanceof CaseInsensitiveString && ((CaseInsensitiveString)o).s.equalsIgnoreCase(s);
}
複制
傳遞性:equals 約定的第三個要求是傳遞性,如果一個對象等于第二個對象,而第二個對象又等于第三個對象,那麼第一個對象一定等于第三個對象。同樣的,無意識的違反這條規則的情形也不難,例如
public class Point {
private final int x;
private final int y;
public Point(int x,int y){
this.x = x;
this.y = y;
}
@Override
public boolean equals(Object o) {
if(!(o instanceof Point)){
return false;
}
Point p = (Point)o;
return p.x == x && p.y == y;
}
}
複制
假如你想擴充這個類,添加一些顔色資訊:
public class ColorPoint extends Point{
private final Color color;
public ColorPoint(int x, int y,Color color) {
super(x, y);
this.color = color;
}
}
複制
equals 方法是什麼樣的呢?如果完全不提供equals 方法,而是直接從 Point 繼承過來,在 equals 做比較的時候顔色資訊就被忽略。雖然這樣做不會違反 equals 約定,但這很顯然是不可接受的。假設編寫了一個 equals 方法,隻有當它的參數是一個有色點,并且具有相同位置和顔色時,才會傳回true。
@Override
public boolean equals(Object o) {
if(!(o instanceof ColorPoint)){
return false;
}
return super.equals(o) && ((ColorPoint)o).color == color;
}
複制
這種方法的問題在于,在比較普通點和有色點時,以及相反的情形可能會得到不同的結果。前一種比較忽略了顔色資訊,而後一種比較傳回 false,因為參數類型不正确。為了直覺說明問題,我們建立一個普通點和一個有色點來進行測試
public static void main(String[] args) {
Point p = new Point(1,2);
ColorPoint cp = new ColorPoint(1,2,Color.RED);
System.out.println(p.equals(cp));
System.out.println(cp.equals(p));
}
複制
p.equals(cp) 調用的是 Point 中的 equals 方法,而此方法中沒有關于顔色的比較,之比較了 x 和 y
cp.equals(p) 調用的是 ColorPoint 中的 equals 方法,而此方法中有關于顔色的比較,而 p 中沒有顔色資訊
你可以這樣做來修正這個問題
public boolean equals(Object o) {
if(!(o instanceof Point)){
return false;
}
if(!(o instanceof ColorPoint)){
return o.equals(this);
}
return super.equals(o) && ((ColorPoint)o).color == color;
}
複制
這種方法确實提供了對稱性,但是卻犧牲了傳遞性
ColorPoint cp = new ColorPoint(1,2,Color.RED);
Point p = new Point(1,2);
ColorPoint cp2 = new ColorPoint(1,2,Color.BLUE);
複制
此外,還可能會導緻無限遞歸問題,比如 Point 有兩個字類,分别是 ColorPoint 和 SmellPoint,它們各自有自己的 equals 方法,那麼對 myColorPoint.equals(mySmellPoint)的調用将會抛出 StackOverflowError 異常。
你可能聽過使用 getClass 方法替代 instanceof 測試,可以擴充可執行個體化的類和增加新的元件,同時保留 equals 約定,例如
@Override
public boolean equals(Object o) {
if(o == null || o.getClass() != getClass()){
return false;
}
Point p = (Point)o;
return p.x == x && p.y == y;
}
複制
裡氏替換原則認為,一個類型的任何屬性也将适用于它的字類型
一個不錯的改良措施是使用 組合優先于繼承 的原則,我們不再讓 ColorPoint 擴充 Point,而是讓 ColorPoint 持有一個 Point 的私有域,以及一個公有視圖方法,例如
public class ColorPoint {
private final Point point;
private final Color color;
public ColorPoint(int x, int y,Color color) {
point = new Point(x,y);
this.color = color;
}
public Point asPoint(){
return point;
}
@Override
public boolean equals(Object o) {
if(!(o instanceof ColorPoint)){
return false;
}
ColorPoint cp = (ColorPoint)o;
return cp.point.equals(point) && cp.color.equals(color);
}
}
複制
在 Java 平台類庫中,有一些類擴充了可執行個體化的類,并且添加了新的元件值。
例如:java.sql.Timestamp 對 java.util.Date 進行了擴充,并添加了 nanoseconds 域。Timestamp 類與 Date 類進行 equals 比較時會發生不可預期的行為,雖然工程師在 Timestamp 告誡不要和 Date 類一起使用,但是這種行為依舊不值得效仿。
一緻性
equals 約定的第四個要求是,如果兩個對象相等,它們就必須保證始終相等,除非它們中有一個對象(或者兩個都)被修改了。也就是說,可變對象在不同的時候可以與不同的對象相等。不可變對象不會這樣,它們會保證始終相等。
無論類是否可變,都不要使 equals 方法依賴于不可靠的資源。例如,java.net.URL 的 equals 方法依賴于對 URL中主機IP 位址的比較。将一個主機名轉變成 IP 位址可能需要通路網絡,随着時間的推移,就不能確定會産生相同的結果,即有可能 IP 位址發生了改變。這樣會導緻 URL equals 方法違反 equals 約定,在實踐中有可能引發一些問題。URL equals 方法的行為是一個大錯誤并且不應被模仿。遺憾的是,因為相容性的要求,這一行為元法被改變。為了避免發生這種問題,equals 方法應該對駐留在記憶體中的對象執行确定性的計算。
非空性
非空性的意思是所有的對象都不能為 null 。盡管很難想象什麼情況下 o.equals(null) 會傳回 true。但是意外抛出空指針異常的情形可不是很少見。通常不允許抛出 空指針異常,許多類的 equals 方法都通過對一個顯示的 null 做判斷來防止這種情況:
public boolean equals(Object o) {
if(o == null){
return false;
}
}
複制
這項測試是不必要的。為了測試其參數的等同性,equals 方法必須先把參數轉換成适當的類型,以便可以調用它的通路方法,或者通路它的域。
如果漏掉了類型檢查,有傳遞給 equals 方法錯誤的類型,那麼 equals 方法将會抛出 ClassCastException,這就違反了 equals 約定。如果 instanceof 的第一個操作數為 null ,那麼,不管第二個操作數是哪種類型,intanceof 操作符都指定應該傳回 false 。是以,如果把 null 傳給 equals 方法,類型檢查就會傳回 false ,是以不需要顯式的 null 檢查。
遵循如下約定,可以實作高品質的空判斷:
- 使用 == 操作符檢查 參數是否為這個對象的引用 。如果是,傳回 true 。
- 使用 instanceof 操作符檢查 參數是否為正确的引用類型。如果不是,則傳回 false。
- 對于該類中的每個域,檢查參數中的域是否與該對象中對應的域相比對。
編寫完成後,你還需要問自己: 它是否是對稱的、傳遞的、一緻的?
下面是一些告誡:
- 覆寫 equals 時總要覆寫 hashCode
- 不要企圖讓 equals 方法過于智能
- 不要将 equals 聲明中的 Object 對象替換為其他的類型。