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方法是什麼樣的?寫一個不規範的方法的是很簡單的。如下示例,雖然永遠是合法的,但絕對不能這樣使用:ide

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

相關文章
相關標籤/搜索