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


Tips
《Effective Java, Third Edition》一書英文版已經出版,這本書的第二版想必很多人都讀過,號稱Java四大名著之一,不過第二版2009年出版,到現在已經將近8年的時間,但隨着Java 6,7,8,甚至9的發布,Java語言發生了深刻的變化。
在這里第一時間翻譯成中文版。供大家學習分享之用。

Effective Java, Third Edition

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也提供了一些這樣的功能。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM