Effective Java 第三版讀書筆記——條款14:考慮實現 Comparable 介面
與本章討論的其他方法不同,compareTo
方法並沒有在 Object
類中宣告。相反,它是 Comparable
介面中的唯一方法。 通過實現 Comparable
介面,一個類表明它的例項有一個自然序( natural ordering )。對實現 Comparable
介面的物件所組成的陣列排序非常簡單,如下所示:
Arrays.sort(a);
通過實現 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
不一致”。
我們來仔細看一下 compareTo
約定的內容。第一條約定,如果反轉兩個物件引用之間的比較方向,則會發生預期的事情:如果第一個物件小於第二個物件,那麼第二個物件必須大於第一個;如果第一個物件等於第二個,那麼第二個物件必須等於第一個;如果第一個物件大於第二個,那麼第二個必須小於第一個。第二條約定說,如果一個物件大於第二個物件,而第二個物件大於第三個物件,則第一個物件必須大於第三個物件。最後一條約定,所有比較相等的物件與任何其他物件相比,都必須得到相同的結果。
compareTo
約定的最後一段是一個強烈的建議,而不是一個真正的要求,只是宣告 compareTo
方法執行的相等性測試,通常應該返回與 equals
方法相同的結果。如果遵守這個約定,則 compareTo
方法施加的順序被認為與 equals
相一致。如果違反,則這個順序關係被認為與 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
方法是靜態型別的,所以你不需要輸入檢查或者轉換它的引數。如果引數是錯誤的型別,那麼編譯會報錯。如果引數為 null,則呼叫會丟擲 NullPointerException
異常。
在 compareTo
方法中,比較屬性的順序而不是相等性。要比較物件引用屬性,遞迴呼叫 compareTo
方法。可以編寫自己的比較器或使用現有的比較器,如在條款 10 中的 CaseInsensitiveString
類的 compareTo
方法中:
// Single-field Comparable with object reference field
public final class CaseInsensitiveString
implements Comparable<CaseInsensitiveString> {
public int compareTo(CaseInsensitiveString cis) {
return String.CASE_INSENSITIVE_ORDER.compare(s, cis.s);
}
... // Remainder omitted
}
在本書前兩版中曾經推薦如果比較整型基本型別的屬性,使用關係運算符 < 和 >,對於浮點型基本型別的屬性,使用 Double.compare
和 Float.compare
靜態方法。在 Java 7 中,靜態比較方法被新增到 Java 的所有包裝類中。在 compareTo
方法中使用關係運算符 < 和 > 是冗長且容易出錯的,不再推薦。
如果一個類有多個重要的屬性,那麼比較它們的順序是至關重要的。從最重要的屬性開始,逐步比較所有的重要屬性。如果比較結果不是零(零表示相等),則表示比較完成,返回結果即可。 如果最重要的欄位是相等的,比較下一個重要的屬性,依此類推,直到找到不相等的屬性。以下是條款 11 中 PhoneNumber
類中的 compareTo
方法:
// Multiple-field Comparable with primitive fields
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
介面提供了一系列比較器方法,可以流暢地構建比較器。許多程式設計師更喜歡這種方法的簡潔性,儘管它會犧牲一定地效能。在使用這種方法時,考慮使用 Java 的靜態匯入,以便可以通過其簡單名稱來引用比較器靜態方法。下面是 PhoneNumber
類中使用這種技術的 compareTo
方法:
// Comparable with comparator construction methods
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
中提取區域(area)程式碼,並返回一個 Comparator<PhoneNumber>
,根據它們的區域程式碼來對電話號碼排序。注意,lambda 表示式顯式指定了其輸入引數的型別 (PhoneNumber pn)
。事實證明,在這種情況下,Java 的型別推斷功能還不夠強大,無法自行判斷型別,因此我們不得不幫助它以使程式編譯。
如果兩個電話號碼例項具有相同的區號,則需要進一步細化比較,這正是第二個比較器構建方法,即 thenComparingInt
方法做的。它是 Comparator
上的一個例項方法,接受一個 int 型別鍵提取器函式作為引數,並返回一個比較器,該比較器首先應用原始比較器,然後使用提取的鍵來打破連線。你可以按照喜歡的方式多次呼叫 thenComparingInt
方法,從而產生一個字典順序。在上面的例子中,我們呼叫兩個 thenComparingInt
方法來產生一個排序,它的二級鍵是 prefix
,三級鍵是 lineNum
。請注意,我們不必指定傳遞給 thenComparingInt
方法中鍵提取器函式的引數型別:Java 的型別推斷足夠聰明,可以自己推斷出引數的型別。
Comparator
類具有完整的構建方法。對於 long
和 double
基本型別,也有對應的類似於 comparingInt
和 thenComparingInt的
的方法,int
版本的方法也可以應用在取值範圍小於 int
的型別上,如 short
型別。double
版本的方法也可以用在 float
型別上。這提供了對所有 Java 基本數值型別的覆蓋。
有時,你可能會看到 compareTo
或 compare
方法依賴於兩個值之間的差值,如果第一個值小於第二個值,則為負;如果兩個值相等則為零;如果第一個值大於第二個值,則為正。這是一個例子:
// BROKEN difference-based comparator - violates transitivity!
static Comparator<Object> hashCodeOrder = new Comparator<>() {
public int compare(Object o1, Object o2) {
return o1.hashCode() - o2.hashCode();
}
};
不要使用這種技術!它可能會導致整數最大長度溢位和 IEEE 754浮點運算失真。應該使用靜態 compare
方法:
// Comparator based on static compare method
static Comparator<Object> hashCodeOrder = new Comparator<>() {
public int compare(Object o1, Object o2) {
return Integer.compare(o1.hashCode(), o2.hashCode());
}
};
或者使用 Comparator
的構建方法:
// Comparator based on Comparator construction method
static Comparator<Object> hashCodeOrder =
Comparator.comparingInt(o -> o.hashCode());