Effective Java 2.0_中文版_Item 8

文章作者:Tyan
博客:noahsnail.com | CSDN | 簡書

CHAPTER3 所有對象的共通方法

雖然Object是一個具體的類,但設計它的主要目的是為了擴展。它的所有非final方法(equalshashCodetoStringclonefinalize)都有明確的通用約定,因為設計它們的目的是為了重寫。任何類都應該遵循通用約定重寫這些方法;不這樣做的話,依賴這些約定的其它類(例如HashMapHashSet)將無法結合這個類正確運行。

會告本章訴你什么時候,怎樣重寫這些非final的Object方法。本章會忽略finalize方法,因為它在Item 7中已經討論過了。雖然不是一個Object方法,但是這章仍會討論Comparable.compareTo,因為它有一個類似的特性。

Item 8:當重寫equals時要遵循通用約定

重寫equals方法看似簡單,但許多方式都會導致錯誤,結果是非常可怕的。避免這些問題的最簡單方式是不要重寫equals方法,在這種情況下類的每個實例只等價于它本身。如果符合以下任何條件,這樣做就是正確的:

  • 類的每個實例本質上都是唯一的。對于表示活動實體而不是表示值的類確實如此,例如Thread。對于這些類,Object提供的equals實現具有完全正確的行為。

  • 不關心類是否提供“邏輯等價”的測試。例如,java.util.Random可以重寫equals方法來檢查兩個Random實例是否會產生相同的隨機數序列,但設計者認為客戶不需要或者不想要這個功能。在這種情況下,從Object繼承的equals實現就足夠了。

  • 超類已經重寫了equals,超類的行為對于子類是合適的。例如,大多數Set實現從AbstractSet繼承了equals實現,List實現從AbstractList繼承了equals實現,Map實現從AbstractMap繼承了equals實現。

  • 類是私有的或包私有的,可以確定它的equals方法從不會被調用。可以說,在這些情況下equals方法應該重寫,以防它被偶然調用:

@Override public boolean equals(Object o) {
    throw new AssertionError(); // Method is never called
}

什么時候重寫Object.equals方法是合適的?如果類具有邏輯等的概念,不同于對象同一性,并且超類沒有重寫equals方法來實現要求的行為,這時候就需要重寫equals方法。這種情況通常是對值類而言的。值類僅僅是表示值的類,例如IntegerDate。程序員用equals方法比較值對象的引用,期望找出它們是否是邏輯等價的,而不管它們是否是同一對象。重寫equals方法不僅滿足了程序員的期望;它也能使實例作為映射表的主鍵或者集合的元素,使它們表現出可預期的行為。

有一種不需要重寫equals方法的值類,它通過實例控制(Item 1)來確保每個值至多存在一個對象。枚舉類型(Item 30)就是這種類。對于這種類而言,邏輯等價等同與對象同一性,Objectequals方法在功能上就如同邏輯等價方法。

當你重寫equals方法時,你必須遵循通用約定。下面是約定內容,從Object規范[JavaSE6]中拷貝的:

equals實現了一種等價關系。它是:

  • 自反性:對于任何非空引用值xx.equals(x)必須返回true

  • 對稱性:對于任何非空引用值xyx.equals(y)必須返回true當且僅當y.equals(x)返回true

  • 傳遞性:對于任何非空引用值,xyz,如果x.equals(y)返回true并且y.equals(z)返回true,則x.equals(z)必須返回true

  • 一致性:對于任何非空引用值xyx.equals(y)的多次調用一致返回true或一致返回false,假設對象進行equals比較時沒有修改任何信息。

  • 對于非空引用值xx.equals(null)必須返回false

除非你擅長數學,否則這可能看起來有點可怕,但不要忽視它!如果你違反了它,你可能會發現你的程序表現不正常或程序崩潰,并且很難確定失敗的來源。用John Donne的話來說,沒有類是孤立的。一個類的實例頻繁傳遞給另一個類。許多類,包括所有的集合類,都依賴于傳遞給它們的對象遵循equals約定。

現在你已經意識到了違反了equals約定的危險,讓我們詳細回顧一下這個約定。好消息是實際上這個約定并不復雜,盡管從表面上來看不是這樣。一旦你理解了它,遵循它并不難。讓我們依次檢查這五個要求:

自反性——第一個要求僅僅是說一個對象必須等價于它本身。很難想象會無意的違反這個要求。如果你違反了它并將你的類實例添加到一個集合中,集合的contains方法可能會說這個集合中不包含你剛剛添加的實例。

對稱性——第二個要求是說任何兩個對象必須對它們是否相等達成一致。不像第一個要求,不難想象會無意的違反這個要求。例如,考慮下面的類,它實現了大小寫敏感的字符串。字符串保存在toString中,但在比較時被忽略了:

// Broken - violates symmetry!
public final class CaseInsensitiveString {
    private final String s;
    public CaseInsensitiveString(String s) {
        if (s == null)
            throw new NullPointerException();
        this.s = 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";

正如預料的那樣,cis.equals(s)返回true。問題是雖然CaseInsensitiveString中的equals知道普通的字符串,但是String中的equals方法不注意不區分大小寫的字符串。因此s.equals(cis)返回false,這明顯違反了對稱性。假設你將一個不區分大小寫的字符串放到一個集合中:

List<CaseInsensitiveString> list = new ArrayList<CaseInsensitiveString>();
list.add(cis);

這時list.contains(s)會返回什么?誰知道呢?在Sun當前的實現中,它碰巧會返回false,但那僅是一種實現方案。在另一種實現中,它也可能很容易的返回true或拋出一個運行時異常。一旦你違反了equals約定,當面對你的對象時,你根本不指定其它的對象行為會怎樣。

為了消除這個問題,只要從equals方法中移除與String進行交互的,考慮不周的嘗試即可。一旦你這樣做了,你可以重構這個方法給它一個返回即可:

 @Override 
 public boolean equals(Object o) {
    return o instanceof CaseInsensitiveString && ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}

傳遞性——equals約定的第三個要求是說如果一個對象等價于第二個對象,而第二個對象等價于第三個對象,則第一個對象等價于第三個對象。同樣的,不難想象會無意中違反這個要求。考慮這樣一種情況,子類添加一個新的值組件到它的超類中。換句話說,子類添加的信息會影響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;
    }
    ...  // Remainder omitted
}

假設你想擴展這個類,給點添加顏色的概念:

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約定,但很明顯這是不可接受的。假設你寫了一個equals方法,只有在它的參數是另一個有色點,且它們具有相同的位置和顏色時才返回true

// Broken - violates symmetry!
@Override 
public boolean equals(Object o) {
    if (!(o instanceof ColorPoint))
        return false;
    return super.equals(o) && ((ColorPoint) o).color == color;
}

這個方法的問題在于:當你比較一個普通點和一個有色點或相反的情況時,你可能會得到不同的結果。前者的比較忽略了顏色,而后者總是返回false,因為參數類型不正確。為了使這個更具體一點,我們創建一個普通點和一個有色點:

Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);

p.equals(cp)返回true,而cp.equals(p)返回false。你可能想讓ColorPoint.equals進行比較混合比較時忽略顏色來修正這個問題:

// Broken - violates transitivity!
@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約定:

// Broken - violates Liskov substitution principle (page 40)
@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;
}

當且僅當它們具有相同的實現類時,上面的代碼在比較對象時才會有效。雖然這不是很糟糕,但結果是不可接受的。

假設我們想寫一個方法來判斷一個整數點是否在單位圓上。下面是一種寫法:

// Initialize UnitCircle to contain all Points on the unit circle private static final Set<Point> unitCircle;
static {
    unitCircle = new HashSet<Point>();
    unitCircle.add(new Point( 1,  0));
    unitCircle.add(new Point( 0,  1));
    unitCircle.add(new Point(-1,  0));
    unitCircle.add(new Point( 0, -1));
}
public static boolean onUnitCircle(Point p) {
    return unitCircle.contains(p);
}

雖然這可能不是實現這個功能的最快方式,但它確實有效。但假設你以某種不添加值組件的方式擴展了Point,例如通過它的構造函數來追蹤創建了多少實例:

public class CounterPoint extends Point {
    private static final AtomicInteger counter = new AtomicInteger();

    public CounterPoint(int x, int y) {
        super(x, y);
        counter.incrementAndGet();
    }
    
    public int numberCreated() { 
        return counter.get(); 
    }
}

里氏替換原則認為,一個類型的任何重要屬性也適用于它的子類型,因此該類型編寫的任何方法在它的子類型中也都應該工作良好[Liskov87]。但假設我們給onUnitCircle傳遞了一個CounterPoint實例。如果Point類使用了基于getClassequals方法,onUnitCircle將會返回false,無論CounterPoint實例的x值和y值是多少。這是因為集合,例如onUnitCircle方法中的HashSet,使用equals方法來測試是否包含元素,沒有CounterPoint實例等于Point。然而,如果你在Point上使用合適的基于instanceofequals方法,當面對CounterPoint時,同樣的onUnitCircle方法會工作的很好。

盡管沒有令人滿意的方式來擴展一個可實例化的類并添加值組件,但有一個很好的解決方案。遵循Item 16 “Favor composition over inheritance”的建議,不再讓ColorPoint繼承Point,而是通過在ColorPoint中添加一個私有的Point字段和一個公有的視圖方法(Item 5),此方法返回一個與有色點具有相同位置的普通點:

// 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) {
        if (color == null)
            throw new NullPointerException();
        point = new Point(x, y);
        this.color = 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
}

在Java平臺庫中有一些類擴展了一個可實例化的類并添加了一個值組件。例如,java.sql.Timestamp擴展了java.util.Date并添加了一個nanoseconds字段。Timestampequals實現確實違反了對稱性,如果TimestampDate用在同一個集合中或混雜在一起,會引起不穩定的行為。Timestamp類有一個免責聲明,警告程序員不要混合日期和時間戳。雖然只要你將它們分開就不會有麻煩,但是沒有任何東西阻止你混合它們,而且產生的錯誤很難調試。Timestamp類的這個行為是一個錯誤,不應該進行模仿。

注意,你可以添加值組件到抽象類的子類而且不會違反equals約定。對于遵循Item 20 “Prefer class hierarchies to tagged classes”的建議而得到這種類層次來說,這是非常重要的。例如,你可以有一個沒有值組件的抽象類Shape,子類Circle添加了radius字段,子類Rectangle添加了lengthwidth字段。只要不能直接創建一個超類實例,上面的種種問題就不會發生。

一致性——equals約定的第四個要求是說如果兩個對象相等,它們必須一致相等,除非其中一個(或二者)被修改了。換句話說,可變對象在不同的時間可以等于不同的對象而不可變對象不能。當你寫了一個類,仔細想想它是否應該是不可變的(Item 15)。如果你推斷它應該是不可變的,那么要確保你的equals方法滿足這樣的約束條件:相等的對象永遠相等,不等的對象永遠不等。

無論一個類是否是不可變的,都不要寫一個依賴于不可靠資源的equals方法。如果你違反了這個禁令,要滿足一致性要求是非常困難的。例如,java.net.URLequals方法依賴于對關聯URL主機的IP地址的比較。將主機名轉換成IP地址可能需要訪問網絡,隨時間推移它不能保證取得相同的結果。這可能會導致URL equals方法違反equals約定并在實踐中產生問題。(很遺憾,由于兼容性問題,這一行為不能被修改。)除了極少數例外,equals方法應該對常駐內存對象進行確定性計算。

非空性”——最后的要求由于沒有名字我稱之為“非空性”,這個要求是說所有的對象都不等于null。雖然很難想象調用o.equals(null)會偶然的返回true,但不難想象會意外拋出NullPointerException的情況。通用約定不允許出現這種情況。許多類的equals方法為了防止出現這種情況都進行對null的顯式測試:

@Override 
public boolean equals(Object o) {
    if (o == null)
        return false;
    ...
}

這個測試是沒必要的。為了平等測試其參數,為了調用它的訪問器或訪問其字段,equals方法首先必須將它的參數轉換成合適的類型。在進行轉換之前,equals方法必須使用instanceof操作符來檢查它的參數是否是正確的類型:

@Override 
public boolean equals(Object o) {
    if (!(o instanceof MyType))
        return false;
    MyType mt = (MyType) o;
    ...
}

如果缺少類型檢查,equals方法傳入了一個錯誤類型的參數,equals方法會拋出ClassCastException,這違反了equals約定。但當指定instanceof時,如果它的第一個操作數為null,無論它的第二個操作數是什么類型,它都會返回false[JLS, 15.20.2]。所以如果傳入null類型檢查將會返回false,因此你不必進行單獨的null檢查。

將這些要求結合在一起,得出了下面的編寫高質量equals方法的流程:

  1. 使用==操作符來檢查參數是否是這個對象的一個引用,。如果是,返回true。這只是一個性能優化,如果比較的代價有可能很昂貴,這樣做是值得的。

  2. 使用instanceof操作符來檢查參數類型是否正確。如果不正確,返回false。通常,正確的類型是指equals方法所在的那個類。有時候,它是這個類實現的一些接口。如果一個類實現了一個接口,這個接口提煉了equals約定來允許比較那些實現了這個接口類,那么就使用接口。集合接口例如SetListMapMap.Entry都有這個屬性。

  3. 將參數轉換成正確的類型。由于轉換測試已經被instanceof在之前做了,因此它保證能成功。

  4. 對于類中的每一個“有效”字段,檢查參數的這個字段是否匹配這個對象的對應字段。如果所有的這些測試都成功了,返回true;否則返回false。如果第二步中的類型是一個接口,你必須通過接口方法訪問參數的字段;如果類型是一個類,你可能要直接訪問字段,依賴于它們的可訪問性。

對于基本類型,如果不是floatdouble,使用==操作符進行比較;對于對象引用字段,遞歸地調用equals方法;對于float自動,使用Float.compare方法;對于double字段,使用Double.comparefloatdouble字段的特別對待是有必要的,因為存在Float.NaN-0.0f和類似的double常量;更多細節請看Float.equals。對于數組字段,對每個元素應用這些指導。如果數組中的每個元素都是有意義的,你可以使用1.5版本中添加的Arrays.equals方法。

某些對象引用字段可能合理的包含null。為了避免產生NullPointerException的可能性,使用下面的習慣用法來比較這些字段:

(field == null ? o.field == null : field.equals(o.field))

如果fieldo.field經常是等價的,使用下面的可替代方式可能會更快:

(field == o.field || (field != null && field.equals(o.field)))

對于某些類而言,例如上面的CaseInsensitiveString,字段比較比簡單的相等性檢測更復雜。如果是這種情況,你可能想存儲這個字段的標準形式,因此equals方法可以在這些標準形式上進行低開銷的精確比較,而不是更高代碼的非精確比較。這種技術最適合不可變類(Item 15);如果對象可以改變,你必須保持最新的標準形式。

equals方法的性能可能會受到字段比較順序的影響。為了最佳性能,你首先應該比較那些更可能不同,比較代價更小的字段,或者理想情況下二者兼具的字段。你不能比較那些不屬于對象邏輯狀態一部分的字段,例如同步操作中的Lock字段。你也不需要比較冗余的字段,它們能從“有意義字段”中計算出來,但這樣做可能會改善equals方法的性能。如果冗余字段相當于整個對象的概要描述,比較這個字段,如果失敗的話會節省你比較真正數據的開銷。例如,假設你有一個Polygon類,并且你緩存這個區域。如果兩個多邊形有不同的面積,你就不需要比較它們的邊和頂點。

  1. 當你完成了equals方法的編寫時,問你自己三個問題:它是否是對稱的?是否是可傳遞的?是否是一致的?并且不要只問你自己;編寫單元測試來檢查是否擁有這些屬性!如果沒有這些屬性,弄清楚為什么沒有,對應的修改equals方法。當然你的equals方法也必須滿足其它兩個屬性(自反性和“非空性”),但這兩個屬性通常會自動滿足。

根據上述規則構建的equals方法具體例子請看Item 9的PhoneNumber.equals`。下面是一些最后的警告:

  • 當你重寫equals時,總是重寫hashCode方法(Item9)

  • 不要將equals聲明中的Object對象替換為其它對象。對于程序員來講,寫一個equals方法看起來像下面的一樣是不常見的,并且花費了好幾個小時都不明白它為什么不能正確工作:

public boolean equals(MyClass o) {
    ...
}

正如本條目闡述的那樣,@Override注解的一致使用會阻止你犯這個錯誤(Item 36)。這個equals方法不能編譯并且錯誤信息會確切告訴你錯誤是什么。

@Override 
public boolean equals(MyClass o) {
    ...
}
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,565評論 6 539
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,115評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,577評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,514評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,234評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,621評論 1 326
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,641評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,822評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,380評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,128評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,319評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,879評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,548評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,970評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,229評論 1 291
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,048評論 3 397
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,285評論 2 376

推薦閱讀更多精彩內容