天天看點

Effective Java 第三版——11. 重寫equals方法時同時也要重寫hashcode方法

Tips

《Effective Java, Third Edition》一書英文版已經出版,這本書的第二版想必很多人都讀過,号稱Java四大名著之一,不過第二版2009年出版,到現在已經将近8年的時間,但随着Java 6,7,8,甚至9的釋出,Java語言發生了深刻的變化。

在這裡第一時間翻譯成中文版。供大家學習分享之用。

11. 重寫equals方法時同時也要重寫hashcode方法

在每個類中,在重寫 equals 方法的時侯,一定要重寫 hashcode 方法。如果不這樣做,你的類違反了hashCode的通用約定,這會阻止它在HashMap和HashSet這樣的集合中正常工作。根據 Object 規範,以下時具體約定。

  1. 當在一個應用程式執行過程中,如果在equals方法比較中沒有修改任何資訊,在一個對象上重複調用hashCode方法時,它必須始終傳回相同的值。從一個應用程式到另一個應用程式的每一次執行傳回的值可以是不一緻的。
  2. 如果兩個對象根據equals(Object)方法比較是相等的,那麼在兩個對象上調用hashCode就必須産生的結果是相同的整數。
  3. 如果兩個對象根據equals(Object)方法比較并不相等,則不要求在每個對象上調用hashCode都必須産生不同的結果。 但是,程式員應該意識到,為不相等的對象生成不同的結果可能會提高散清單(hash tables)的性能。

當無法重寫hashCode時,所違反第二個關鍵條款是:相等的對象必須具有相等的哈希碼( hash codes)。根據類的equals方法,兩個不同的執行個體可能在邏輯上是相同的,但是對于Object 類的hashCode方法,它們隻是兩個沒有什麼共同之處的對象。是以, Object 類的hashCode方法傳回兩個看似随機的數字,而不是按約定要求的兩個相等的數字。

舉例說明,假設你使用條目 10中的

PhoneNumber

類的執行個體做為HashMap的鍵(key):

Map<PhoneNumber, String> m = new HashMap<>();

m.put(new PhoneNumber(707, 867, 5309), "Jenny");
           

你可能期望

m.get(new PhoneNumber(707, 867, 5309))

方法傳回

Jenny

字元串,但實際上,傳回了 null。注意,這裡涉及到兩個

PhoneNumber

執行個體:一個執行個體插入到 HashMap 中,另一個作為判斷相等的執行個體用來檢索。

PhoneNumber

類沒有重寫 hashCode 方法導緻兩個相等的執行個體傳回了不同的哈希碼,違反了 hashCode 約定。put 方法把

PhoneNumber

執行個體儲存在了一個哈希桶( hash bucket)中,但get方法卻是從不同的哈希桶中去查找,即使恰好兩個執行個體放在同一個哈希桶中,get 方法幾乎肯定也會傳回 null。因為HashMap 做了優化,緩存了與每一項(entry)相關的哈希碼,如果哈希碼不比對,則不會檢查對象是否相等了。

解決這個問題很簡單,隻需要為

PhoneNumber

類重寫一個合适的 hashCode 方法。hashCode方法是什麼樣的?寫一個不規範的方法的是很簡單的。以下示例,雖然永遠是合法的,但絕對不能這樣使用:

// The worst possible legal hashCode implementation - never use!

@Override public int hashCode() { return 42; }
           

這是合法的,因為它確定了相等的對象具有相同的哈希碼。這很糟糕,因為它確定了每個對象都有相同的哈希碼。是以,每個對象哈希到同一個桶中,哈希表退化為連結清單。應該線上性時間内運作的程式,運作時間變成了平方級别。對于資料很大的哈希表而言,會影響到能夠正常工作。

一個好的 hash 方法趨向于為不相等的執行個體生成不相等的哈希碼。這也正是 hashCode 約定中第三條的表達。理想情況下,hash 方法為集合中不相等的執行個體均勻地配置設定int 範圍内的哈希碼。實作這種理想情況可能是困難的。 幸運的是,要獲得一個合理的近似的方式并不難。 以下是一個簡單的配方:

  1. 聲明一個 int 類型的變量result,并将其初始化為對象中第一個重要屬性

    c

    的哈希碼,如下面步驟2.a中所計算的那樣。(回顧條目10,重要的屬性是影響比較相等的領域。)
  2. 對于對象中剩餘的重要屬性

    f

    ,請執行以下操作:

    a. 比較屬性

    f

    與屬性

    c

    的 int 類型的哈希碼:

    -- i. 如果這個屬性是基本類型的,使用

     Type.hashCode(f)

    方法計算,其中

    Type

    類是對應屬性 f 基本類型的包裝類。

    -- ii 如果該屬性是一個對象引用,并且該類的equals方法通過遞歸調用equals來比較該屬性,并遞歸地調用hashCode方法。 如果需要更複雜的比較,則計算此字段的“範式(“canonical representation)”,并在範式上調用hashCode。 如果該字段的值為空,則使用0(也可以使用其他常數,但通常來使用0表示)。

    -- iii 如果屬性

    f

    是一個數組,把它看作每個重要的元素都是一個獨立的屬性。 也就是說,通過遞歸地應用這些規則計算每個重要元素的哈希碼,并且将每個步驟2.b的值合并。 如果數組沒有重要的元素,則使用一個常量,最好不要為0。如果所有元素都很重要,則使用

    Arrays.hashCode

    方法。

    b. 将步驟2.a中屬性

    c

    計算出的哈希碼合并為如下結果:

    result = 31 * result + c;

  3. 傳回 result 值。

當你寫完hashCode方法後,問自己是否相等的執行個體有相同的哈希碼。 編寫單元測試來驗證你的直覺(除非你使用AutoValue架構來生成你的equals和hashCode方法,在這種情況下,你可以放心地忽略這些測試)。 如果相同的執行個體有不相等的哈希碼,找出原因并解決問題。

可以從哈希碼計算中排除派生屬性(derived fields)。換句話說,如果一個屬性的值可以根據參與計算的其他屬性值計算出來,那麼可以忽略這樣的屬性。您必須排除在equals比較中沒有使用的任何屬性,否則可能會違反hashCode約定的第二條。

步驟2.b中的乘法計算結果取決于屬性的順序,如果類中具有多個相似屬性,則産生更好的散列函數。 例如,如果乘法計算從一個String散列函數中被省略,則所有的字元将具有相同的散列碼。 之是以選擇31,因為它是一個奇數的素數。 如果它是偶數,并且乘法溢出,資訊将會丢失,因為乘以2相當于移位。 使用素數的好處不太明顯,但習慣上都是這麼做的。 31的一個很好的特性,是在一些體系結構中乘法可以被替換為移位和減法以獲得更好的性能:

31 * i ==(i << 5) - i

。 現代JVM可以自動進行這種優化。

讓我們把上述辦法應用到PhoneNumber類中:

// Typical hashCode method

@Override public int hashCode() {

    int result = Short.hashCode(areaCode);

    result = 31 * result + Short.hashCode(prefix);

    result = 31 * result + Short.hashCode(lineNum);

    return result;

}
           

因為這個方法傳回一個簡單的确定性計算的結果,它的唯一的輸入是

PhoneNumber

執行個體中的三個重要的屬性,是以顯然相等的

PhoneNumber

執行個體具有相同的哈希碼。 實際上,這個方法是

PhoneNumber

的一個非常好的hashCode實作,與Java平台類庫中的實作一樣。 它很簡單,速度相當快,并且合理地将不相同的電話号碼分散到不同的哈希桶中。

雖然在這個項目的方法産生相當好的哈希函數,但并不是最先進的。 它們的品質與Java平台類庫的值類型中找到的哈希函數相當,對于大多數用途來說都是足夠的。 如果真的需要哈希函數而不太可能産生碰撞,請參閱Guava架構的的com.google.common.hash.Hashing [Guava]方法。

Objects

類有一個靜态方法,它接受任意數量的對象并為它們傳回一個哈希碼。 這個名為hash的方法可以讓你編寫一行hashCode方法,其品質與根據這個項目中的上面編寫的方法相當。 不幸的是,它們的運作速度更慢,因為它們需要建立數組以傳遞可變數量的參數,以及如果任何參數是基本類型,則進行裝箱和取消裝箱。 這種哈希函數的風格建議僅在性能不重要的情況下使用。 以下是使用這種技術編寫的

PhoneNumber

的哈希函數:

// One-line hashCode method - mediocre performance

@Override public int hashCode() {

   return Objects.hash(lineNum, prefix, areaCode);

}
           

如果一個類是不可變的,并且計算哈希碼的代價很大,那麼可以考慮在對象中緩存哈希碼,而不是在每次請求時重新計算哈希碼。 如果你認為這種類型的大多數對象将被用作哈希鍵,那麼應該在建立執行個體時計算哈希碼。 否則,可以選擇在首次調用hashCode時延遲初始化(lazily initialize)哈希碼。 需要注意確定類在存在延遲初始化屬性的情況下保持線程安全(項目83)。

PhoneNumber

類不适合這種情況,但隻是為了展示它是如何完成的。 請注意,屬性hashCode的初始值(在本例中為0)不應該是通常建立的執行個體的哈希碼:

// hashCode method with lazily initialized cached hash code

private int hashCode; // Automatically initialized to 0

@Override public int hashCode() {

    int result = hashCode;

    if (result == 0) {

        result = Short.hashCode(areaCode);

        result = 31 * result + Short.hashCode(prefix);

        result = 31 * result + Short.hashCode(lineNum);

        hashCode = result;

    }

    return result;

}
           

不要試圖從哈希碼計算中排除重要的屬性來提高性能。 由此産生的哈希函數可能運作得更快,但其品質較差可能會降低哈希表的性能,使其無法使用。 具體來說,哈希函數可能會遇到大量不同的執行個體,這些執行個體主要在你忽略的區域中有所不同。 如果發生這種情況,哈希函數将把所有這些執行個體映射到少許哈希碼上,而應該以線性時間運作的程式将會運作平方級的時間。

這不僅僅是一個理論問題。 在Java 2之前,String 類哈希函數在整個字元串中最多使用16個字元,從第一個字元開始,在整個字元串中均勻地選取。 對于大量的帶有層次名稱的集合(如URL),此功能正好顯示了前面描述的病态行為。

不要為hashCode傳回的值提供詳細的規範,是以用戶端不能合理地依賴它; 你可以改變它的靈活性。 Java類庫中的許多類(例如String和Integer)都将hashCode方法傳回的确切值指定為執行個體值的函數。 這不是一個好主意,而是一個我們不得不忍受的錯誤:它妨礙了在未來版本中改進哈希函數的能力。 如果未指定細節并在散列函數中發現缺陷,或者發現了更好的哈希函數,則可以在後續版本中對其進行更改。

總之,每次重寫equals方法時都必須重寫hashCode方法,否則程式将無法正常運作。你的hashCode方法必須遵從Object類指定的正常約定,并且必須執行合理的工作,将不相等的哈希碼配置設定給不相等的執行個體。如果使用第51頁的配方,這很容易實作。如條目 10所述,AutoValue架構為手動編寫equals和hashCode方法提供了一個很好的選擇,IDE也提供了一些這樣的功能。