1. 程式人生 > >Java效能優化(7):改寫equals時遵守通用約定

Java效能優化(7):改寫equals時遵守通用約定

儘管Object是一個具體類,但是設計它主要是為了擴充套件。它的所有非final方法都有明確的通用約定。因為它們都是為了遵守這些通用約定;如果不能做到這一點,則其他一些依賴於這些約定的類就無法與這些類結合在一起正常工作。

改寫equals方法看起來非常簡單,但是有許多改寫的方式會導致錯誤,並且後果非常嚴重。要避免問題最容易的辦法是不改寫equals方法,在這種情況下,每個例項只與它自己相等。如果下面的任何一個條件滿足的話,這正是所期望的結果:

  • 一個類的每個例項本質上都是唯一的。 對於代表了活動實體而不是值(value)的類,確實是這樣的,比如
    Tthread。Object提供的equals實現對於這些類是正確的。
  • 不關心一個類是否提供了“邏輯相等”的測試功能。例如,java.util.Random改寫了equals,它檢查了兩個Random例項是否產生相同的隨機數序列,但是設計者並不認為客戶會需要或者期望這樣的功能。在這樣的情況下,從Object繼承到的equals實現已經足夠了。
  • 超類已經改寫了equals,從超類繼承過來的行為對於子類也是合適的。例如,大多數的Set實現都從AbstractSet繼承了equals實現,List實現從AbstractList繼承了equals實現,Map實現從AbstractMap繼承了equals實現
  • 一個類是私有的,或者是包級私有的,並且可以確定它的equals方法也永遠不會被呼叫。
    儘管如此,在這樣的情形下,應該要改寫equals方法,以免萬一有一天它會被呼叫到。改寫如下:
public boolean equals(Object o)
{
    throw new UnsupportedOperationException();
}

那麼,什麼時候應該改寫Object.equals呢?當一個類有自己特有的“邏輯相等”概念,而且超類也沒有改寫equals以實現期望的行為,這時我們需要改寫equals方法。這通常適合於“值類”的情形,比如Integer或者Date。程式設計師在利用equals方法類比較兩個指向值物件的引用的時候,希望知道它們邏輯上是否相等,而不是它們是否指向同一個物件。為滿足俺們的要求,改寫equals方法是必須噠,而且這樣做也使得這個類的例項可以被用map的key,或者set的元素,並使map或list集合表現出預期的行為。
有一種值類可以要求不改寫equals方法,即typesafe enum

,因為型別安全列舉型別保證每一個值至多隻存在一個物件,所以對於這樣的類而言,Object的equals方法等同於邏輯意義上的equals方法。
在改寫equals方法的時候,你必須要遵守它的通用約定。下面是約定的內容,來自java.lang.Object的規範:
equals方法實現了等價關係:

  • 自反性 對於任意的引用值x,x.equals(x)一定為true。

  • 對稱性。對於任意的引用值x和y,當且僅當y.equals(x)返回true時,x.equals(y)也一定返回true。

  • 傳遞性。對於任意的引用值x、y和z,如果x.equals(y)返回true,並且y.equals(z)也返回true,那麼x.equals(y)也一定返回true。

  • 一致性。對於任意的引用值x和y,如果用於equals比較的物件資訊沒有被修改的話,那麼,多次呼叫x.equals(z)也一定返回true。

  • 對於任意的非空引用值x,x.equals(null)一定返回false。

如果你違反了這些約定,你的程式將會表現不正常,甚至崩潰,而且很難找到失敗的根源。沒有哪個類是孤立的,一個類的例項通常會被頻繁地傳遞給另一個類的例項,有許多類包括collection類在內,都依賴於傳遞給它們的物件是否遵守了equals約定。

遵守這些規定並不複雜,下面按順序逐一檢視:

  • 自反性——第一個要求僅僅說明一個物件必須等於其自身。很難想象無意識的違反這一條,情形會咋辦。如果你呵呵了,然後你把該類的例項加入到一個collection鍾,則該集合的contains方法將果斷地告訴你,該集合不包含剛剛你加入的例項。

  • 對稱性——第二個要求是說,任何兩個物件對於“它們是否相等”這個問題必須要保持一致。與第一個要求不同,若無意中國違反了這一條,其情形不難想象。例如下面的類

public final class CaseInsensitiveString {

    private String s;

    public CaseInsensitiveString(String s) {
        if (s == null)
            throw new NullPointerException();
        this.s = s;
    }

    public boolean equals(Object o) {
        if (o instanceof CaseInsensitiveString)
            return s.equalsIgnoreCase(((CaseInsensitiveString) o).s);
        if (o instanceof String)
            return s.equals((String) o);
        return false;
    }
}

在這個類中,equals方法的意圖非常好,它企圖與普通的字串物件可以互操作。假設我們有一個大小寫不敏感的字串物件和一個普通的字串:

CaseInsensitiveString cis=new CaseInsensitiveString("Polish");
    String s= "polish";

正如所期望的,cis.equals(s)返回true。問題在於,CaseInsensitiveString類中的equals方法知道普通的字串(String)物件,但是String類中的equals方法卻並不知道大小寫不敏感的字串。因此,s.equals(cis)返回false。很顯然違反了對稱性。假設你把大小寫不敏感的字串物件放到一個集合中:

List list = new ArrayList();
list.add(cis)

這時候,list.contains(s)會返回什麼結果(⊙o⊙)?在sun的當前實現中,它碰巧返回false,但是隻是這個特定實現得出的結果而已,在其他的實現中,它有可能返回true,或者丟擲一個執行時異常。一旦你違反了equals約定,當其他的物件面對你的物件時候,你無法知道這些物件的行為會怎麼樣。
為了解決這個問題,只需要把企圖與String互操作的這段程式碼從equals方法中去掉就可以了。這樣做之後,你可以重構程式碼,使它變成一條返回語句:

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;
    }

    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 Color color;

    public ColorPoint(int x, int y, Color color) {
        super(x, y);
        this.color = color;
    }
}

equals方法會怎麼樣呢?如果你完全不提供equals方法,而是直接從Point繼承過來,那麼在equals做比較的時候顏色資訊被忽略掉了。雖然這樣做不會違反equals約定,但是很明顯這是不可接受的。假設你編寫了一個equals方法,只有當實參是另一有色點,並且具有相同的位置和顏色的時候,它才會返回true:

public boolean equals(Object o) {
        if (!(o instanceof ColorPoint))
            return false;
        ColorPoint cp = (ColorPoint) o;
        return super.equals(o) && cp.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在進行“混合比較”的時候忽略顏色資訊:

public boolean equals(Object o)
    {
        if(!(o instanceof Point))
            return false;
        if(!(o instanceof ColorPoint))
        return o.equals(this);

        ColorPoint cp=(ColorPoint)o;
        return super.equals(o)&&cp.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,很顯然違反了傳遞性前兩個比較不考慮顏色資訊(”色盲“),而第二個比較考慮了顏色資訊。

How to deal with?事實上,這是面嚮物件語言中關於等價關係的一個基本問題。要想在擴充套件一個可例項化類的同時,既要增加新的特徵,同時還要保留equals約定,沒有一個簡單的辦法發可以做到這一點。複合優先於繼承,這個問題還是沒有很好的解決辦法,我們不再讓ColorPoint擴充套件Point,而是在ColorPoint中加入一個私有的Point域,以及一個公有的試圖方法。此方法返回一個與該有色點在同一位置上的普通Point物件:

public class ColorPoint extends Point {
    private Color color;
    private Point point;

    public ColorPoint(int x, int y, Color color) {
        point = new Point(x, y);
        this.color = color;
    }

    public Point asPoint() {
        return point;
    }

    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的equals實現違反了對稱性,如果Timestamp和Date物件被用於同一個集合中,或者以其他方式被混合在一起,則會出現不正確的行為。Timestamp類有一個否認宣告,告誡程式設計師不要回混合使用Date和Timestamp物件。Timestamp是一個反常的類,不值得仿效。

注意,你可以在一個抽象類的子類中增加新的特徵,而不會違反equals約定。這一點對於“用類層次來代替聯合”而得到的一種類層次結構非常重要。

  • 一致性——equals約定的第四個要求是,如果兩個物件相等的話,那麼它們必須始終保持相等,除非有一個物件被修改了。由於可變物件在不同的時候可以與不同的物件相等,而非可變物件不會這樣,所以,這一條作為提醒實際上算不上equals方法的要求。當你在寫一個類的時候,應該仔細考慮它是否為非可變的。如果認為它們應該是非可變的,那麼你就必須要保證equals方法滿足這樣的限制條件:相等的物件永遠相等,不相等的物件永遠不相等。
  • 非空性——指所有的物件必須不等於null。儘管很難想象什麼情況下o.equals(null)會返回true,但是丟擲NullPointerException異常的情形並不難想象。通用約定不允許丟擲NullPointerException異常。許多類的equals方法通過一個顯式的null測試來防止這種情況:
public boolean equals(Object o){
     if(o==null)
       return false;
       …

這項測試不是必需的,為了測試當前物件的相等情況,equals方法必須首先把實參轉換為一種適當的型別,以便可以呼叫它們的訪問方法或者訪問它的域。在做轉換之前,equals方法必須使用instanceof操作符,檢查它的實參是否為正確的型別:

public boolean equals(Object o){
    if(!(o instanceof Mytype))
        return false;
        …

如果漏掉了這一步型別檢查,並且傳遞給equals方法的實參又是錯誤的型別,那麼equals方法將會丟擲一個ClassCastException異常,這違反了equals的約定。但是,如果instanceof的第一個運算元為null的話,那麼,不管第二個運算元是哪種型別,按instanceof操作符的規定,它應該返回false。因此,如果把null傳給equals方法的話,則型別檢查的結果為false,所以,你並不需要做單獨的null檢查。把所有這些結合在一起,下面是為實現高質量equals方法的一個“處方”:
1.使用==操作符檢查“實參是否為指向物件的一個引用”。如果是的話,則返回true。這只不過是一種效能優化,如果比較操作有可能非常耗時的話,這樣做是值得的。
2.使用instanceof操作符檢查”實參是否為正確的型別“。如果不是的話,則返回false。通常,這裡”正確的型別“是指equals方法所在的那個類。有些情況下,是指該類所實現的某個介面。如果一個類實現的一個介面改進了equals約定,允許在實現了該介面的類之間進行比較,那麼使用這個介面作為正確的型別,集合介面Set、List、Map和Map.Entry具有這樣的特點。
3.把實參轉換到正確的型別。因為前面已經有了instanceof測試,所以這個轉化可以確保成功。
4.對於該類中的每一個關鍵域,檢查實參中的域與當前物件中對應的域值是否匹配。如果所有的測試都成功,則返回true;否則返回false。如果第2步中的型別是一個介面,那麼你必須通過介面的方法,訪問實參中的關鍵域;如果該型別是一個類,那麼你也許能夠直接訪問實參中的關鍵域,這要取決與它們的可訪問性。對於既不是float也不是double型別的原語型別域,可以使用==操作符進行比較;對於物件引用域,可以遞迴地呼叫equals方法;對於float域,先使用Float.floatToIntBits轉換成int型別的值,然後使用==操作符比較int型別的值;對於double域,先使用Double.doubleToLongBits轉換成long型別的值,然後使用==操作符比較long型別的值。對於陣列域,把以上這些指導原則應用到每個元素上。有些物件引用域包含null是合法的,所以為了避免可能導致NullPointerExcepiton異常,使用下面的習慣用法來比較這樣的域:(filed==null?o.field==null:field.equals(o.field)
如果field和o.field通常是相同的物件引用,那麼下面的做法會更快一些:(field==o.field||(field!=null&&.filed.equals(o.field)))
對於有些類,比如前面提到的CaseInsensitiveString類,針對每個域的比較操作比簡單的相等測試要複雜的多。如果是這樣的情形,應該是在該類的規範上明確地加以說明。如果確實是這樣話,你可能會期望在每一種物件內部儲存一個”正規化”,這樣equals方法可以根據這些正規化進行低開銷的精確比較,而不是高開銷的非精確比較。這項技術對於非可變類是最為合適的,因為如果物件發生變化的話,其正規化也必須相應地更新。

域的比較順序可能會影響到equals方法的效能。為了獲得最佳的效能,最先進行比較的域應該是最有可能不一致的域,或者是比較開銷最低的域,理想情況是兩個條件同時滿足的域。如果一個冗餘域代表了這整個物件的一個概括描述,那麼,當最終比較結果為false時,通過比較這些冗餘域,可以省下比較實際資料所需要的開銷。
5.當你編寫完成equals方法之後,確保其是否是對稱的、傳遞的、一致的?

  • 當你改寫equals時候,總是要改寫hashcode

  • 不要企圖讓equals方法過於聰明。如果只是簡單的測試域中的值是否相等,則不難做到遵守equals約定。把任何一種別名形式考慮在等價的範圍內,往往不會是一個好主意。例如,作為File類,它不應該試圖把指向同一個檔案的符號連結當作相等的物件來看待。

  • 不要使equals方法依賴於不可靠的資源。例如,java.net.URL的equals方法依賴於被比較的URL中主機的IP地址。把一個主機名轉換為一個IP地址,這項工作需要訪問網路,而且不保證每次都會產生同樣的結果。這樣會導致URL的equals方法違法equals約定,在實踐中可能會引發問題。

  • 不要將equals宣告的Object物件替換為其他的型別。如下面這個,會逼瘋程式設計師……

public boolean equals(MyClass o){
}

問題在於這個方法並沒有改寫Object.equals,因為Object.equals的實參應該是Object型別,相反,它過載了Object.equals在原有equals方法的基礎上,再提供一個“強型別化”的equals方法,只要這兩個方法返回同樣的結果,那麼這是可以接受的。在某些特定情況下,它也許會帶來一些效能上的提高,但是與增加的複雜性相比,這種做法是不值得的。