1. 程式人生 > >Effective Java 3rd 條目14 考慮實現Comparable

Effective Java 3rd 條目14 考慮實現Comparable

不像本章討論的其他方法,compareTo方法不是在Object中宣告的。更確切地說,它是Comparable介面的唯一方法。在角色上像Object的equals方法,除了它允許簡單的相等比較之外,它還允許順序比較,而且它是支援泛型的。通過實現Comparable,一個類表明它的例項有一個自然排序(natural ordering)。由實現Comparable的物件組成的一個佇列,它的排序就像下面一樣簡單:

Arrays.sort(a);

查詢、計算極端值和維護自動排序的Comparable物件資料集,同樣簡單。比如,如下的程式,依賴於String實現了Comparable這個事實,列印了一個字母列表,去除了它的重複命令列引數:

public class WordList { 
    public static void main(String[] args) { 
        Set<String> s = new TreeSet<>(); 
        Collections.addAll(s, args); 
        System.out.println(s); 
    } 
}

通過實現Comparable,你允許你的類與之相互操作:所有的眾多泛型演算法和依賴於這個介面的資料集實現。你以小量的努力獲得到巨大的能力。事實上,Java平臺庫中的所有值類和所有列舉型別(條目34),實現了Comparable。如果你編寫明顯自然排序的一個值類,比如字母排序、數值排序或者按年代排序,那麼你應該實現Comparable介面:

public interface Comparable<T> { 
    int compareTo(T t); 
}

compareTo的通用協定是同equals相似的:

把指定物件和這個物件依順序比較。當這個類小於、等於或者大於指定物件時,返回一個負整數、零或者一個正整數。如果指定物件的類別阻止了它同這個物件比較,丟擲ClassCastException。

在下面的描述中,把符號sgn(expression)定名為數學上的signum函式,它根據表示式的值是否為負、零或者正,定義為返回-1、0或者1。

  • 對於所有的x和y,實現者必須保證sgn(x.compareTo(y)) == -sgn(y.compareTo(x))。(這意味著,如果而且只有y.compareTo(x)丟擲一個異常,x.compareTo(y)必須丟擲一個異常。)

  • 實現者也必須保證這個關係是可傳遞的:(x.compareTo(y) > 0 && y.compareTo(z) > 0)意味著x.compareTo(z) > 0。

  • 最後,對於所有的z,實現者必須保證x.compareTo(y) == 0意味著sgn(x.compareTo(z)) == sgn(y.compareTo(z))

  • (x.compareTo(y) == 0) == (x.equals(y)),是強烈建議的,但是不是必要的。通常地講,實現了Comparable介面而且違反了這個條件的任何類,應該清楚地表明這個事實。推薦的語言是:“注意:這個類有一個自然排序,與equals不一致。”

不要讓這個協定的數學特質令你退卻了。就像equals協定(條目10),這個協定不是像看上去那麼複雜。不像equals方法強制所有物件上的整體相等關係,compareTo不需要在不同型別物件之間起作用:當面對不同型別的物件,compareTo允許丟擲ClassCastException。通常,這確切是它應該做的。這個協定確實允許型別間比較,它通常在一個對比物件實現的介面中定義。

就像違反了hashCode協定的類可能破壞依賴於雜湊的其他類,違反compareTo協定的類可能破壞依賴於比較的其他類。依賴於比較的類包括,排序資料集TreeSet和TreeMap,和保函了查詢和排序演算法的效用類Collections和Arrays。

讓我們重溫compareTo協定的條款。第一個條款說,如果你反轉兩個物件引用之間比較的順序,那麼期待這樣的事情發生:如果第一個物件小於第二個,那麼第二個必須大於第一個;如果第一個物件等於第二個,那麼第二個必須等於第一個;如果第一個物件大於第二個,那麼第二個必須小於第一個。第二個條款說,如果一個物件大於第二個,而且第二個大於第三個,那麼第一個必須大於第三個。最後一個條款說,比較為相等的所有物件,當與任何其他物件相比較時,必須產生相同結果。

這三個條款的結果是,compareTo方法強加的相等檢測必須遵從equals協定強加的相同限制:反身性、對稱性和傳遞性。所以,相同的警告也適用:用新的值元件擴充套件一個不可例項化的類,而且維護compareTo協定,這是不可能的,除非你願意放棄面向物件的抽象的益處(條目10)。同樣的變通方法也適用。如果你想新增一個值元件到一個實現Comparable的類,那麼不要擴充套件它;編寫一個不相關的類,它包含第一個類的例項。然後提供了一個“檢視”方法,返回被包含的例項。這讓你解脫出來,在包含的類上實現你想的compareTo方法任何東西,而且當需要的時候,讓客戶端把包含類的例項看作被包含類的例項。

compareTo協定的最後一個段落,它是強烈建議而非一個真正的必要條件,簡單地陳述為,compareTo強加的相等檢測應該通常與equals方法一樣返回同樣的結果。如果遵從這個條款,這個由compareTo強加的排序被認為與equals*一致。如果違反了它,那麼排序被認為與equals不一致。一個類的compareTo方法強加一個與equals不一致*的順序,這個類仍然起作用,但是包含類元素的排序資料集可能不會遵從恰當資料集介面(Collection、Set或者Map)的通用協定。這是因為這些介面的通用協定是依據equals方法定義的,但是排序資料集使用compareTo(而不是equals)強加的相等檢測。如果這個發生了,這不會是一個災難,但是要意識到這個事情。

比如,考慮BigDecimal類,它的compareTo方法與equals是不一致的。如果你建立了一個空HashSet例項,然後新增new BigDecimal(“1.0”) 和new BigDecimal(“1.00”),那麼這個集將包含兩個元素,因為當使用equals方法比較時,新增到集的這兩個BigDecimal例項是不相等的。然而,如果你使用TreeSet而不是HashSet執行相同的過程,這個集將僅僅包含一個元素,因為當使用compareTo方法比較時,這兩個BigDecimal例項是相等的。(細節參考BigDecimal文件。)

編寫compareTo方法與編寫equals方法是相似的,但是有一些關鍵的區別。因為Comparable介面是引數化的,compareTo方式是靜態型別的,所以你沒必要型別檢查或者強轉它的引數。如果引數是錯誤的型別,那麼呼叫甚至不會通過編譯。如果引數是空,那麼呼叫將會丟擲NullPointerException,而且一旦方法嘗試獲取它的成員它將丟擲。

compareTo方法裡面,域是為順序而不是相等而比較的。為了比較物件引用的域,遞迴地呼叫compareTo方法。如果一個域沒有實現Comparable,或者你需要一個非標準的排序,那麼改為使用Comparator。你可以編寫你自己的comparator,或者使用一個存在的comparator,就像在條目10中CaseInsensitiveString的下面這個compareTo方法:

// 物件引用域的單個域Comparable
public final class CaseInsensitiveString implements Comparable<CaseInsensitiveString> { 
    public int compareTo(CaseInsensitiveString cis) { 
        return String.CASE_INSENSITIVE_ORDER.compare(s, cis.s); 
    } 
    ... // 其餘省略 
}

注意,CaseInsensitiveString實現了Comparable<CaseInsensitiveString>。這意味著,CaseInsensitiveString引用僅僅可以同其他CaseInsensitiveString引用相比較。當宣告一個類實現Comparable時,這是要遵循的標準模式。

這本書前期版本推薦說,compareTo方法使用關係操作子<和>比較整數原始域,使用靜態方法Double.compare和Float.compare比較浮點數原始域。在Java 7中,靜態compare方法加入到了所有Java原始裝箱類。使用compareTo方法的關係操作子<和>是冗餘的、容易出錯的和不再推薦的

如果一個類有多個重要域,比較它們的順序是重要的。從最重要的域開始,然後按照這種方式往下做。如果比較結果不是零(零代表相等),比較結束,僅僅返回這個結構。如果最重要的域是相等的,比較次最重要的域,等等,直至你發現了一個不相等的域或者比較最不重要的域。以下是是條目11 PhoneNumber類的compareTo方法,展示這個技巧:

// 原始域的多域比較 
public int compareTo(PhoneNumber pn) {
    int result = Short.compare(areaCode, pn.areaCode); 
    if (result == 0) {
        result = Short.compare(prefix, pn.prefix);
        if (result == 0)
            result = Short.compare(lineNum, pn.lineNum); 
    } 
    return result;
}

在Java 8中,Comparator介面配備了比較子構建方法(comparator construction methods)集,它們使得比較子流暢構建。這些比較子然後可以用作實現一個compareTo方法,就像Comparable介面要求的。許多程式設計師更喜歡這個方法的簡潔,雖然它確實是以一定效能代價實現的:PhoneNumber例項佇列的排序在我的機器上慢10%。當使用這個方法時,考慮使用Java的靜態匯入(static import)特性,因此為了清晰和簡潔,你應該通過它們的簡短名字,引用靜態比較子構建方法。使用這個方法,以下是PhoneNumber的compareTo方法的樣子:

// 比較子構建方法的Comparable
private static final Comparator<PhoneNumber> COMPARATOR = 
    comparingInt((PhoneNumber pn) -> pn.areaCode) 
        .thenComparingInt(pn -> pn.prefix) 
        .thenComparingInt(pn -> pn.lineNum);

public int compareTo(PhoneNumber pn) { 
    return COMPARATOR.compare(this, pn);
}

這個實現在類初始化的時候建造了一個比較子,使用兩個比較子構建方法。第一個是comparingInt。它是一個靜態方法,接受一個鍵抽取器函式(key extractor function),這個函式把物件引用對映到int型別的鍵,然後返回根據那個鍵排序物件的一個比較子。在前面的例子中,comparingInt接受lambda (),它從PhoneNumber抽取地區號碼,然後返回一個Comparator<PhoneNumber>,它根據它們的地區號碼對電話號碼排序。注意,lambda顯式地指定了它的輸入引數型別(PhoneNumber pn)。在這種情況下,結果是,Java的型別推導不足夠強大到它自己能弄明白這個型別,所以為了讓程式編譯成功,我們不得不幫助它。

如果兩個電話號碼有相同的地區號碼,我們需要進一步優化這個比較,那正是第二個比較子構建方法thenComparingInt所做的。它是Comparator上的一個例項方法,接受一個int鍵抽取器函式,而且返回一個比較子,它首先應用於原來的比較子然後使用抽取的鍵來來隔斷關係。你可以疊加你想要任意多的thenComparingInt呼叫,這導致了字典式排列(lexicographic ordering)。在上面的例子中,我們疊加了兩個thenComparingInt呼叫,最終是這樣的排序,它的第二個鍵是字首而且它的第三個鍵是線路號碼。注意到,我們沒有必要指定傳入到thenComparingInt呼叫之一的鍵抽取器函式的引數型別:Java型別推導足夠聰明到它自己弄明白這個型別。

Comparator類有一個全面的構建方法。對於原始型別long和double,有類似於comparingInt和thenComparingInt。int版本也可以使用在更加窄的整數型別,比如short,就像在我們的PhoneNumber例子。double型別也可以使用為float。這提供了所有Java原始數值型別的覆蓋。

而且有為物件引用型別的comparator構建方法。這個叫comparing的靜態方法,有兩個過載。一個接受鍵抽取器,使用鍵的自然排序。第二個接受一個鍵抽取器和一個使用在抽取鍵上的比較子。有叫thenComparing這個例項方法的三個過載。一個過載僅僅接受一個比較子,使用它次級排序。第二個過載僅僅接受一個鍵抽取器,使用鍵的自然排序作為次級順序。最後一個過載同時接受鍵抽取器和使用在鍵抽取鍵上的比較子。

偶爾你可以看見compareTo或者compare方法依賴於這個事實:如果第一個值小於第二個,兩個值之間的差是負的,如果兩個值相等,為零,如果第一值比較大,為正。下面是一個例子:

// 已破壞 基於差的比較子 - 違反了傳遞性! 
static Comparator<Object> hashCodeOrder = new Comparator<>() {
    public int compare(Object o1, Object o2) {
        return o1.hashCode() - o2.hashCode();
    } 
};

不要使用這個技巧。它充滿危險的:整數溢位和IEEE 754浮點數算術考究[JLS 15.20.1, 15.21.1]。再者,最後的方法不可能比使用這個條目描述的技巧編寫的那些方法更快。要麼使用這個靜態compare方法:

// 基於靜態compare方法的Comparator
static Comparator<Object> hashCodeOrder = new Comparator<>() {
    public int compare(Object o1, Object o2) {
        return Integer.compare(o1.hashCode(), o2.hashCode());
    } 
};

要麼使用comparator構建方法:

// 基於Comparator構建方法的Comparator
static Comparator<Object> hashCodeOrder = 
    Comparator.comparingInt(o -> o.hashCode());

總之,每當你實現了一個排序敏感的值類,你應該讓這個類實現Comparable,以便它的例項可以容易地排序、查詢和在基於比較的資料集中使用。當在compareTo方法實現中比較域值時,避免使用<和> 操作子。相反,在原始裝箱類中,或者在Comparator介面中的比較子構建方法中,使用靜態compare方法。