1. 程式人生 > >Effective Java 第三版讀書筆記——條款10:重寫 equals 方法時遵守通用的規定

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
    (); // Method is never called }

那什麼時候需要重寫 equals 方法呢?如果一個類包含一個邏輯相等( logical equality)的概念——此概念有別於物件同一性(object identity),而且父類還沒有重寫過 equals 方法。這通常用在值類( value classes)的情況。值類只是一個表示值的類,例如 Integer 或 String 類。程式設計師使用 equals 方法比較值物件的引用,希望知道它們在邏輯上是否相等,而不是它們是否引用相同的物件。重寫 equals 方法不僅可以滿足程式設計師的期望,它還支援重寫過 equals

的例項作為Map 的鍵(key),或者 Set 裡的元素,以滿足預期的行為。

當你重寫 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.equalsDouble.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 規定裡的五條規定。