1. 程式人生 > >【第8條】改寫equals時總是要改寫hashCode

【第8條】改寫equals時總是要改寫hashCode

一個很常見的錯誤根源在於沒有改寫hashCode方法。在每一個改寫了equals的方法的類中,你必須也要改寫hashCode方法。如果不這麼做的話,就會違反Object.hashCode的通用約定,從而導致該類無法與所有基於雜湊值(hash)的集合類結合在一起正常執行,這樣的集合類包括HashMap、HashSet、Hashtable。


    可能有人會問:什麼是hash?它是幹什麼用的?


    的確,一開始我也沒有在意到hashCode。翻了一下底層API的程式碼,我自己的類確實也犯了這個錯誤,而且還發現其他專案組的API也是。那個專案是從Java1.0版本就做起的,直到現在使用的是1.5,但是他們說了:我們改寫了equals的類只是一些JavaBean,他們很少有機會(不太嚴謹的說是從不會)被放到HashMap、HashSet、Hashtable中,就更不會放進去而且還當Key用了。


    那麼,還是回答一下剛才的問題吧,什麼是hash?它是幹什麼用的?如果違反了此條到底在什麼情況下,會有怎樣的後果?


    hash是一個雜湊值,或者叫“雜亂編碼”,對一個物件做hashCode()運算,簡單地說就是給這個物件算出一個無規則的ID。我們知道,陣列的優勢在於遍歷,而HashMap等的優勢在於快速查詢。當我們在HashMap中放入了一些Key-Vaule的值對後,可以通過Key值很快地檢索出它所對應的Value(很像對資料庫表的查詢),關鍵的是這個檢索的時間耗費是固定的(嚴謹地說應該是與容量無關的),而非與內容多少線性的。很明顯它不是像陣列一樣迴圈遍歷、比較來做查詢的,而是直達目標。那麼它是如何做到的呢?


    這裡再多囉嗦幾句,HashMap的原理:當你初始化一個HashMap時,系統會預先開創一些空間用於放置將要被放入的物件,之後隨著物件的放入,當容量不夠用的時候就將容積擴倍。但是,什麼時候叫“不夠用”呢?並不是現有的“格子”都被放滿了,而是75%(這個百分比叫loadFactor,Java用的是0.75,其它系統可能不一樣,微軟好像是0.72)。這是為什麼呢?其實這個百分比是可以自己指定的,但是如果沒有特別的情況,建議你不要這麼做,相信很多數學專家已經經過大量的計算和論證才選用的0.75這個值的。再多說一句,其實所謂“擴倍”也是不嚴格的說法,各個系統在選擇容量上都有自己的策略,比如Java是不小於擴倍數的一個奇數,而微軟好像是用質數。再有就是容量的初始值各系統也不相同,Java是11。


    當一個Key-Value值對被put進來時,首先計算Key的hashCode,然後對容量取餘,這個餘數就是這個值對將要被存放的位置。get的時候,也是首先對Key算hashCode,然後對容量取餘數,之後直接到餘數的位置就找的目標了(那麼如果在set和get之間,這個HashMap擴容過,那麼該如何呢?這就不在這裡詳細討論了)。這就是為什麼檢索的時間耗費是與容量無關的了。但是,兩個不同的物件也可能具有相同的hashCode,或者兩個具有不同hashCode的物件恰巧它們的HashCode之差是容量的整數倍,這樣都會導致它們得到的餘數是相同的,就又怎麼辦呢?HashMap會在每個位置上其實都是一個連結串列,如果有兩個以上的物件落在了相同的位置上,那麼就讓連結串列上第一個元素的next指向下一個元素即可(如果某位置上僅有一個元素,那它的next就是null),這就是為什麼嚴謹地說不能是“時間耗費是固定的”,在連結串列上查詢還是需要時間一個一個的遍歷比較的。現在我們知道了吧,即使約定並不要求通過equals方法判斷是不相等的兩個物件的hashCode也一定不能相同,但是最好還是讓他們不同的好。如果這樣一個hashCode()演算法:

Java程式碼 收藏程式碼
  1. publicint hashCode(){   
  2.     return42;   
  3. }  

 
    雖然不是錯誤的,也滿足約定,但它是從來不該出現的,如果採用這樣的hashCode()方法的類的物件被裝入HashMap的話,它們的位置會都在一處,也就是成了一個連結串列了。
   
    現在知道了為什麼一定要改寫hashCode,該將如何改寫了。書上又給出了一個“處方”:
    1)int result = 17;
    2)對每個關鍵域(我覺得就是那些影響equals的域)如下處理:
      2.1)int c; 並根據該域的型別:
       2.1.1)如果該域f是boolean型,c = (f ? 0 : 1);
       2.1.2)如果該域f是byte、char、short、int型,c = (int)f;
       2.1.3)如果該域f是long型,c = (f ^ (f >>> 32));
       2.1.4)如果該域f是float型,c = Float.floatToIntBits(f);
       2.1.5)如果該域f是double型,c = (int)Double.doubleToLongBits(f);
       2.1.6)如果該域f是一個物件,c = f.hashCode();
       2.1.7)如果該域f是一個數組,遍歷陣列的每個元素,並按2.2)中的做法吧這些雜湊值組合起來;
      2.2)result = 37 * result + c
    3)return result
   
    例子:
    我們還是看在【第1條】出現的那個複數的例子,如果該類沒有改寫hashCode方法,不能滿足Object的規範:
    如果兩個物件根據equals(Object)方法是相等的,那麼呼叫其中任一個物件的hashCode方法必須產生同樣的整數結果。
    則會出現這樣的尷尬:

Java程式碼 收藏程式碼
  1. Complex c1 = new Complex(15.5,10.2);  
  2. Map hm = new HashMap();  
  3. hm.put(c1, "c1");  

    之後如果你想檢查某一個使用者輸入的複數,是否在這個HashMap中,而恰巧使用者輸入的re和im值正好是15.5和10.2

Java程式碼 收藏程式碼
  1. String cName = hm.get(new Complex(re, im));  //返回null

    卻返回的是null,而並非所設想的"c1"。原因就是兩次new Complex(15.5,10.2)的hashCode不是同一個整數。雖然在這個例子中,可以通過兩次都用 Complex.valueOf(15.5,10.2) 來代替 new Complex(15.5,10.2),或者將Complex類做成非可變類,從而得到正確的結果(為什麼?如果不清楚,請看【第1條】【第13條】),但這個例子還是要告訴你改寫hashCode的必要性。
   
    那麼Complex類的hashCode方法按照“處方”應該寫成這樣:

Java程式碼 收藏程式碼
  1. publicint hashCode(){  
  2.   int result = 17;  
  3.   result = 37 * result + Float.floatToIntBits(this.re);  
  4.   result = 37 * result + Float.floatToIntBits(this.im);  
  5.   return result;  
  6. }