Effective Java 第三版讀書筆記——條款10:重寫 equals 方法時遵守通用的規定
重寫 equals
方法看起來很簡單,但是有很多方法會導致重寫出錯。避免此問題的最簡單方法是不去重寫 equals
方法,在這種情況下,類的每個例項只與自身相等。如果滿足以下任一條件,則說明不去重寫是正確的做法:
-
每個類的例項都是固有唯一的。例如像 Thread 這樣代表活動實體而不是值的類。Object 提供的
equals
實現對這些類來說是正確的。 -
類不需要提供一個”邏輯相等(logical equality)”的測試功能。例如
java.util.regex.Pattern
可以重寫equals
方法檢查兩個 Pattern 例項是否代表完全相同的正則表示式,但是設計者並不認為客戶需要此功能。在這種情況下,從 Object 繼承的equals
-
父類已經重寫了
equals
方法,並且父類的行為完全適合於該子類。例如,大多數 Set 從 AbstractSet 繼承了equals
實現,List 從 AbstractLis t繼承了equals
實現,Map 從 AbstractMap 繼承了equals
實現。 -
類是私有的或包級私有的,並且可以確定它的
equals
方法永遠不會被呼叫。如果你非常討厭風險,可以重寫equals
方法,以確保不會被意外呼叫:@Override public boolean equals(Object o) { throw new AssertionError
那什麼時候需要重寫 equals
方法呢?如果一個類包含一個邏輯相等( logical equality)的概念——此概念有別於物件同一性(object identity),而且父類還沒有重寫過 equals
方法。這通常用在值類( value classes)的情況。值類只是一個表示值的類,例如 Integer 或 String 類。程式設計師使用 equals
方法比較值物件的引用,希望知道它們在邏輯上是否相等,而不是它們是否引用相同的物件。重寫 equals
方法不僅可以滿足程式設計師的期望,它還支援重寫過 equals
當你重寫 equals
方法時,必須遵守它的通用規範。下面是 Java 原始碼中 Object 類註釋裡的規範:
equals 方法實現了一個等價關係(equivalence relation)。它有以下這些屬性:
• 自反性:對於任何非空引用 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,如果在equals
比較中使用的資訊沒有修改,則x.equals(y)
的多次呼叫必須始終返回true或始終返回false。
• 對於任何非空引用 x,x.equals(null)
必須返回 false。
一旦違反了它,你的程式很可能執行異常或崩潰,並且很難確定失敗的根源。現在讓我們依次看下這五個規定:
-
自反性——第一個要求只是說一個物件必須與自身相等。 這個規定很難在無意中違反。
-
對稱性——第二個要求是任何兩個物件必須在是否相等的問題上達成一致。考慮下面的類,它實現了不區分大小寫的字串。
import java.util.Objects; public final class CaseInsensitiveString { private final String s; public CaseInsensitiveString(String s) { this.s = Objects.requireNonNull(s); } // Broken - violates symmetry! @Override public boolean equals(Object o) { if (o instanceof CaseInsensitiveString) return s.equalsIgnoreCase( ((CaseInsensitiveString) o).s); if (o instanceof String) // One-way interoperability! return s.equalsIgnoreCase((String) o); return false; } ...// Remainder omitted }
上面類中的
equals
試圖與普通的字串進行比較,假設我們有一個不區分大小寫的字串和一個正常的字串:CaseInsensitiveString cis = new CaseInsensitiveString("Polish"); String s = "polish”; System.out.println(cis.equals(s)); // true System.out.println(s.equals(cis)); // false
cis.equals(s)
返回 true。 問題是,儘管CaseInsensitiveString
類中的equals
方法知道普通的字串,但 String 類中的equals
方法卻不知道不區分大小寫的字串。 因此,s.equals(cis)
返回 false,明顯違反對稱性。要消除這個問題,只需刪除equals
方法中與 String 類相互比較的錯誤嘗試。這樣做之後,可以將該方法重構為單個返回語句:@Override public boolean equals(Object o) { return o instanceof CaseInsensitiveString && ((CaseInsensitiveString) o).s.equalsIgnoreCase(s); }
-
傳遞性——第三個要求是:如果第一個物件等於第二個物件,第二個物件等於第三個物件,那麼第一個物件必須等於第三個物件。考慮一個添加了新的值元件( value component)的子類。即子類添加了一些資訊,這些資訊影響了
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; } @Override public boolean equals(Object o) { if (!(o instanceof Point)) return false; Point p = (Point) o; return p.x == x && p.y == y; } ... // Remainder omitted }
假設想繼承這個類,將表示顏色的 color 屬性新增到子類中:
public class ColorPoint extends Point { private final Color color; public ColorPoint(int x, int y, Color color) { super(x, y); this.color = color; } ... // Remainder omitted }
這個子類的
equals
方法應該是什麼樣子?如果完全忽略新新增的屬性,則從 Point 類繼承equals
方法。雖然這並不違反equals
約定,但從邏輯上來講顯然是不可接受的。假設你寫了一個equals
方法,它只在它的引數是另一個具有相同位置和顏色的 ColorPoint 例項時返回 true:// Broken - violates symmetry! @Override public boolean equals(Object o) { if (!(o instanceof ColorPoint)) return false; return super.equals(o) && ((ColorPoint) o).color == color; }
這種方式違反了對稱性,看一個具體的例子:
Point p = new Point(1, 2); ColorPoint cp = new ColorPoint(1, 2, Color.RED);
p.equals(cp)
返回 true,但是cp.equals(p)
返回 false。你可能想通過混合比較的方式來解決這個問題:
@Override public boolean equals(Object o) { if (!(o instanceof Point)) return false; // If o is a normal Point, do a color-blind comparison if (!(o instanceof ColorPoint)) return o.equals(this); // o is a ColorPoint; do a full comparison return super.equals(o) && ((ColorPoint) o).color == color; }
這種方式確實提供了對稱性,但是喪失了傳遞性:
ColorPoint p1 = new ColorPoint(1, 2, Color.RED); Point p2 = new Point(1, 2); ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
現在,
p1.equals(p2)
和p2.equals(p3)
返回了 true,但是p1.equals(p3)
卻返回了 false,很明顯違背了傳遞性的要求。前兩個比較都是不考慮顏色資訊的,而第三個比較卻包含顏色資訊。那麼解決方案是什麼? 事實證明,這是面嚮物件語言中關於等價關係的一個基本問題。 除非您願意放棄面向物件抽象的好處,否則無法繼承可例項化的類,並在保證
equals
約定的同時新增一個值元件。你可能聽說過:可以繼承一個可例項化的類並新增一個值元件,同時通過在
equals
方法中使用一個 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; }
只有當兩個物件具有相同的實現類時,才會被認為是相等的。這看起來可能不是那麼糟糕,但結果是不可接受的:一個 Point 類的子類的例項仍然是一個 Point 的例項,它仍然需要作為一個 Point 來執行,但是如果你採用這個方法,就會失敗。
雖然沒有令人滿意的方法來繼承一個可例項化的類並新增一個值元件,但是有一個很好的變通方法:按照條款 18 的建議:“優先使用組合而不是繼承”。在 ColorPoint 類中定義一個私有的 Point 屬性,和一個公開的檢視(view)方法,用來返回具有相同位置的 Point 物件。
// Adds a value component without violating the equals contract 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 = Objects.requireNonNull(color); } /** * Returns the point-view of this color point. */ 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); } ... // Remainder omitted }
你可以將值元件新增到抽象類的子類中,而不會違反
equals
約定。例如,可以有一個沒有值元件的抽象類 Shape,子類 Circle 有一個 radius 屬性,另一個子類 Rectangle 包含 length 和 width 屬性。直接建立抽象父類的例項是不可能的,因此不會出現前面所示的問題。 -
一致性——第四個要求是:如果兩個物件是相等的,除非一個(或兩個)物件被修改了, 否則它們必須始終保持相等。 換句話說,可變物件可以在不同時期與不同的物件相等,而不可變物件則不會。當你寫一個類時,要認真思考它是否應該被設計為不可變的(條款 17)。如果你認為應該這樣做,那麼確保你的
equals
方法強制執行這樣的限制:相等的物件永遠相等,不相等的物件永遠都不會相等。不管一個類是不是不可變的,都不要寫一個依賴於不可靠資源的
equals
方法。如果違反這一禁令,滿足一致性要求是非常困難的。例如,java.net.URL 類中的equals
方法依賴於與 URL 關聯的主機的 IP 地址的比較。將主機名轉換為 IP 地址可能需要訪問網路,並且不能保證隨著時間的推移會產生相同的結果。這可能會導致 URL 類的equals
方法違反equals
約定,並在實踐中造成問題。URL 類的equals
方法的行為是一個很大的錯誤,不應該被效仿。 -
非空性——所有的非空物件都必須不等於 null。許多類中的
equals
方法都會明確阻止物件為 null 的情況:@Override public boolean equals(Object o) { if (o == null) return false; ... }
這個判斷是不必要的。為了測試它的引數是否相等,
equals
方法必須首先將其引數轉換為合適型別,以便呼叫訪問器或允許訪問的屬性。在執行型別轉換之前,該方法必須使用 instanceof 運算子來檢查其引數是否是正確的型別:@Override public boolean equals(Object o) { if (!(o instanceof MyType)) return false; MyType mt = (MyType) o; ... }
如果第一個運算元為 null,則 instanceof 運算子返回 false,而不管第二個運算元是何種型別。因此,如果傳入 null,型別檢查將返回 false,所以不需要明確的 null 檢查。
綜合起來,以下是編寫高質量 equals
方法的祕訣:
- 使用
==
運算子檢查引數是否為該物件的引用。如果是,返回true。這只是一種效能優化,但是如果完全比較代價很高的的話,這種方式就可以接受。 - 使用
instanceof
運算子來檢查引數是否具有正確的型別。 如果不是,則返回 false。 通常,正確的型別是equals
方法所在的那個類。 有時候,該類實現了一些介面。如果類實現了一個介面——該介面改進了equals
規定以允許實現該介面的類進行比較,那麼使用該介面。 集合介面(如 Set,List,Map 和 Map.Entry)具有此特性。 - 將引數轉換為正確的型別。
- 對於類中的每個“重要”的屬性,請檢查引數的屬性是否與該物件對應的屬性相等。如果所有這些測試都成功,返回 true,否則返回 false。如果步驟 2 中的型別是一個介面,那麼必須通過介面方法訪問引數的屬性;如果型別是類,則可以直接訪問屬性,這取決於屬性的訪問許可權。
- 對於型別為非 float 或 double 的基本型別,使用
==
運算子進行比較;對於物件引用屬性,遞迴地呼叫equals
方法;對於 float 基本型別的屬性,使用靜態方法Float.compare(float, float)
;對於 double 基本型別的屬性,使用Double.compare(double, double)
方法。由於存在Float.NaN
、-0.0f
和類似的 double 型別的值,所以需要對 float 和 double 屬性進行特殊的處理。 雖然你可以使用靜態方法Float.equals
和Double.equals
方法對 float 和 double 基本型別的屬性進行比較,但這會導致每次比較時發生自動裝箱,使效能下降很多。 equals
方法的效能可能受到屬性比較順序的影響。為了獲得最佳效能,你應該首先比較最可能不同的屬性和開銷比較小的屬性。不需要比較可以從“重要屬性”計算出來的派生屬性,但是這樣做可以提高equals
方法的效能。 如果派生屬性相當於對整個物件的摘要描述,比較這個屬性將節省時間。 例如,假設有一個Polygon類。 如果兩個多邊形的面積不相等,則不必費心比較它們的邊和頂點。
在下面這個簡單的 PhoneNumber
類中展示了根據之前的祕訣構建的 equals
方法:
public final class PhoneNumber {
private final short areaCode, prefix, lineNum;
public PhoneNumber(int areaCode, int prefix, int lineNum) {
this.areaCode = rangeCheck(areaCode, 999, "area code");
this.prefix = rangeCheck(prefix, 999, "prefix");
this.lineNum = rangeCheck(lineNum, 9999, "line num");
}
private static short rangeCheck(int val, int max, String arg) {
if (val < 0 || val > max)
throw new IllegalArgumentException(arg + ": " + val);
return (short) val;
}
@Override
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof PhoneNumber))
return false;
PhoneNumber pn = (PhoneNumber) o;
return pn.lineNum == lineNum && pn.prefix == prefix
&& pn.areaCode == areaCode;
}
... // Remainder omitted
}
總之,能不去重寫 equals
方法,就儘量不要去重寫,從 Object 繼承的實現或許正是你想要的。如果你確實重寫了 equals
方法,那麼一定要比較這個類的所有重要屬性,並且要遵守 equals
規定裡的五條規定。