八、覆蓋equals時請遵守通用約定:
?? ?? 對于Object類中提供的equals方法在必要的時候是必要重載的,然而如果違背了一些通用的重載準則,將會給程序帶來一些潛在的運行時錯誤。如果自定義的class沒有重載該方法,那么該類實例之間的相等性的比較將是基于兩個對象是否指向同一地址來判定的。因此對于以下幾種情況可以考慮不重載該方法:
? ? ? 1.?? ?類的每一個實例本質上都是唯一的。
? ? ? 不同于值對象,需要根據其內容作出一定的判定,然而該類型的類,其實例的自身便具備了一定的唯一性,如Thread、Timer等,他本身并不具備更多邏輯比較的必要性。
?? ?? 2.?? ?不關心類是否提供了“邏輯相等”的測試功能。
? ? ? 如Random類,開發者在使用過程中并不關心兩個Random對象是否可以生成同樣隨機數的值,對于一些工具類亦是如此,如NumberFormat和DateFormat等。
?? ?? 3.?? ?超類已經覆蓋了equals,從超類繼承過來的行為對于子類也是合適的。
?? ?? 如Set實現都從AbstractSet中繼承了equals實現,因此其子類將不在需要重新定義該方法,當然這也是充分利用了繼承的一個優勢。
?? ?? 4.?? ?類是私有的或是包級別私有的,可以確定它的equals方法永遠不會被調用。
?? ?
?? ?? 那么什么時候應該覆蓋Object.equals呢?如果類具有自己特有的“邏輯相等”概念,而且超類中沒有覆蓋equals以實現期望的行為,這是我們就需要覆蓋equals方法,如各種值對象,或者像Integer和Date這種表示某個值的對象。在重載之后,當對象插入Map和Set等容器中時,可以得到預期的行為。枚舉也可以被視為值對象,然而卻是這種情形的一個例外,對于枚舉是沒有必要重載equals方法,直接比較對象地址即可,而且效率也更高。
? ? ? 在覆蓋equals是,該條目給出了通用的重載原則:
?? ?? 1.?? ?自反性:對于非null的引用值x,x.equals(x)返回true。
? ? ? 如果違反了該原則,當x對象實例被存入集合之后,下次希望從該集合中取出該對象時,集合的contains方法將直接無法找到之前存入的對象實例。
? ? ? 2.?? ?對稱性:對于任何非null的引用值x和y,如果y.equals(x)為true,那么x.equals(y)也為true。
?? ?? 對于上例,如果執行cis.equals(s)將會返回true,因為在該class的equals方法中對參數o的類型針對String作了特殊的判斷和特殊的處理,因此如果equals中傳入的參數類型為String時,可以進一步完成大小寫不敏感的比較。然而在String的equals中,并沒有針對CaseInsensitiveString類型做任何處理,因此s.equals(cis)將一定返回false。針對該示例代碼,由于無法確定List.contains的實現是基于cis.equals(s)還是基于s.equals(cis),對于實現邏輯兩者都是可以接受的,既然如此,外部的使用者在調用該方法時也應該同樣保證并不依賴于底層的具體實現邏輯。由此可見,equals方法的對稱性是非常必要的。以上的equals實現可以做如下修改:
1 @Override public boolean equals(Object o) { 2 if (o instanceof CaseInsensitiveString) 3 return s.equalsIgnoreCase((CaseInsensitiveString)o).s); 4 return false ; 5 }
? ? ? 這樣修改之后,cis.equals(s)和s.equals(cis)都將返回false。?? ?
? ? ? 3.?? ?傳遞性:對于任何非null的引用值x、y和z,如果x.equals(y)返回true,同時y.equals(z)也返回true,那么x.equals(z)也必須返回true。
? ? ? 對于該類的equals重載是沒有任何問題了,該邏輯可以保證傳遞性,然而在我們試圖給Point類添加新的子類時,會是什么樣呢?
? ? ? 如果在ColorPoint中沒有重載自己的equals方法而是直接繼承自超類,這樣的相等性比較邏輯將會給使用者帶來極大的迷惑,畢竟Color域字段對于ColorPoint而言確實是非常有意義的比較性字段,因此該類重載了自己的equals方法。然而這樣的重載方式確實帶來了一些潛在的問題,見如下代碼:
? ? ? 從輸出結果來看,ColorPoint.equals方法破壞了相等性規則中的對稱性,因此需要做如下修改:
? ? ? 經過這樣的修改,對稱性確實得到了保證,但是卻犧牲了傳遞性,見如下代碼:
?? ?? 再次看輸出結果,傳遞性確實被打破了。如果我們在Point.equals中不使用instanceof而是直接使用getClass呢?
1 @Override public boolean equals(Object o) { 2 if (o == null || o.getClass() == getClass()) 3 return false ; 4 Point p = (Point)o; 5 return p.x == x && p.y == y; 6 }
?? ?? 這樣的Point.equals確實保證了對象相等性的這幾條規則,然而在實際應用中又是什么樣子呢?
? ? ? 如果此時我們測試的不是Point類本身,而是ColorPoint,那么按照目前Point.equals(getClass方式)的實現邏輯,ColorPoint對象在被傳入onUnitCircle方法后,將永遠不會返回true,這樣的行為違反了"里氏替換原則"(敏捷軟件開發一書中給出了很多的解釋),既一個類型的任何重要屬性也將適用于它的子類型。因此該類型編寫的任何方法,在它的子類型上也應該同樣運行的很好。
?? ?? 如何解決這個問題,該條目給出了一個折中的方案,既復合優先于繼承,見如下代碼:
????? 4.?? ?一致性:對于任何非null的引用值x和y,只要equals的比較操作在對象中所用的信息沒有被改變,多次調用x.equals(y)就會一致的返回true,或者一致返回false。
? ? ? 在實際的編碼中,盡量不要讓類的equals方法依賴一些不確定性較強的域字段,如path。由于path有多種表示方式可以指向相同的目錄,特別是當path中包含主機名稱或ip地址等信息時,更增加了它的不確定性。再有就是path還存在一定的平臺依賴性。
?? ?? 5.?? ?非空性:很難想象會存在o.equals(null)返回true的正常邏輯。作為JDK框架中極為重要的方法之一,equals方法被JDK中的基礎類廣泛的使用,因此作為一種通用的約定,像equals、toString、hashCode和compareTo等重要的通用方法,開發者在重載時不應該讓自己的實現拋出異常,否則會引起很多潛在的Bug。如在Map集合中查找指定的鍵,由于查找過程中的鍵相等性的比較就是利用鍵對象的equals方法,如果此時重載后的equals方法拋出NullPointerException異常,而Map的get方法并未捕獲該異常,從而導致系統的運行時崩潰錯誤,然而事實上,這樣的問題是完全可以通過正常的校驗手段來避免的。綜上所述,很多對象在重載equals方法時都會首先對輸入的參數進行是否為null的判斷,見如下代碼:
????? 注意以上代碼中的instanceof判斷,由于在后面的實現中需要將參數o進行類型強轉,如果類型不匹配則會拋出ClassCastException,導致equals方法提前退出。在此需要指出的是instanceof還有一個潛在的規則,如果其左值為null,instanceof操作符將始終返回false,因此上面的代碼可以優化為:
1 @Override public boolean equals(Object o) { 2 if (!(o instanceof MyType)) 3 return false ; 4 ... 5 }
? ? ? 鑒于之上所述,該條目中給出了重載equals方法的最佳邏輯:
? ? ? 1.?? ?使用==操作符檢查"參數是否為這個對象的引用",如果是則返回true。由于==操作符是基于對象地址的比較,因此特別針對擁有復雜比較邏輯的對象而言,這是一種性能優化的方式。
? ? ? 2.?? ?使用instanceof操作符檢查"參數是否為正確的類型",如果不是則返回false。
? ? ? 3.?? ?把參數轉換成為正確的類型。由于已經通過instanceof的測試,因此不會拋出ClassCastException異常。
? ? ? 4.?? ?對于該類中的每個"關鍵"域字段,檢查參數中的域是否與該對象中對應的域相匹配。
? ? ? 如果以上測試均全部成功返回true,否則false。見如下示例代碼:
? ? ? 從上面的示例中可以看出,如果域字段為Object對象,則使用equals方法進行兩者之間的相等性比較,如果為int等整型基本類型,可以直接比較,如果為浮點型基本類型,考慮到精度和Double.NaN和Float.NaN等問題,推薦使用其對應包裝類的compare方法,如果是數組,可以使用JDK 1.5中新增的Arrays.equals方法。眾所周知,&&操作符是有短路原則的,因此應該將最有可能不相同和比較開銷更低的域比較放在最前面。
? ? ? 最后需要提起注意的是Object.equals的參數類型為Object,如果要重載該方法,必須保持參數列表的一致性,如果我們將子類的equals方法寫成:public boolean equals(MyType o);Java的編譯器將會視其為Object.equals的過載(Overload)方法,因此推薦在聲明該重載方法時,在方法名的前面加上@Override注釋標簽,一旦當前聲明的方法因為各種原因并沒有重載超類中的方法,該標簽的存在將會導致編譯錯誤,從而提醒開發者此方法的聲明存在語法問題。
?? ?
九、覆蓋equals時總要覆蓋hashCode:
?? ?? 一個通用的約定,如果類覆蓋了equals方法,那么hashCode方法也需要被覆蓋。如果將會導致該類無法和基于散列的集合一起正常的工作,如HashMap、HashSet。來自JavaSE6的約定如下:
?? ?? 1.?? ?在應用程序執行期間,只要對象的equals方法的比較操作所用到的信息沒有被修改,那么對這同一個對象多次調用,hashCode方法都必須始終如一地返回同一個整數。在同一個應用程序的多次執行過程中,每次執行所返回的整數可以不一致。
? ? ? 2.?? ?如果兩個對象根據equals(Object)方法比較是相等的,那么調用這兩個對象中任意一個對象的hashCode方法都必須產生同樣的整數結果。
? ? ? 3.?? ?如果兩個對象根據equals(Object)方法比較是不相等的,那么調用這兩個對象中任意一個對象的hashCode方法,則不一定要產生不同的整數結果。但是程序員應該知道,給不相等的對象產生截然不同的整數結果,有可能提高散列表的性能。
? ? ? 如果類沒有覆蓋hashCode方法,那么Object中缺省的hashCode實現是基于對象地址的,就像equals在Object中的缺省實現一樣。如果我們覆蓋了equals方法,那么對象之間的相等性比較將會產生新的邏輯,而此邏輯也應該同樣適用于hashCode中散列碼的計算,既參與equals比較的域字段也同樣要參與hashCode散列碼的計算。見下面的示例代碼:
? ? ? 從以上示例的輸出結果可以看出,新new出來的pn2對象并沒有在Map中找到,盡管pn2和pn1的相等性比較將返回true。這樣的結果很顯然是有悖我們的初衷的。如果想從Map中基于pn2找到pn1,那么我們就需要在PhoneNumber類中覆蓋缺省的hashCode方法,見如下代碼:
? ? ? 在上面的代碼中,可以看到參與hashCode計算的域字段也同樣參與了PhoneNumber的相等性(equals)比較。對于生成的散列碼,推薦不同的對象能夠盡可能生成不同的散列,這樣可以保證在存入HashMap或HashSet中時,這些對象被分散到不同的散列桶中,從而提高容器的存取效率。對于有些不可變對象,如果需要被頻繁的存取于哈希集合,為了提高效率,可以在對象構造的時候就已經計算出其hashCode值,hashCode()方法直接返回該值即可,如:
? ? ? 另外,該條目還建議不要僅僅利用某一域字段的部分信息來計算hashCode,如早期版本的String,為了提高計算哈希值的效率,只是挑選其中16個字符參與hashCode的計算,這樣將會導致大量的String對象具有重復的hashCode,從而極大的降低了哈希集合的存取效率。
?? ?
十、始終要覆蓋toString:
? ? ? 與equals和hashCode不同的是,該條目推薦應該始終覆蓋該方法,以便在輸出時可以得到更明確、更有意義的文字信息和表達格式。這樣在我們輸出調試信息和日志信息時,能夠更快速的定位出現的異常或錯誤。如上一個條目中PhoneNumber的例子,如果不覆蓋該方法,就會輸出PhoneNumber@163b91 這樣的不可讀信息,因此也不會給我們診斷問題帶來更多的幫助。以下代碼重載了該方法,那么在我們調用toString或者println時,將會得到"(408)867-5309"。
1 @Override String toString() { 2 return String.format("(%03d) %03d-%04d",areaCode,prefix,lineNumber); 3 }
? ? ? 對于toString返回字符串中包含的域字段,如本例中的areaCode、prefix和lineNumber,應該在該類(PhoneNumber)的聲明中提供這些字段的getter方法,以避免toString的使用者為了獲取其中的信息而不得不手工解析該字符串。這樣不僅帶來不必要的效率損失,而且在今后修改toString的格式時,也會給使用者的代碼帶來負面影響。提到toString返回字符串的格式,有兩個建議,其一是盡量不要固定格式,這樣會給今后添加新的字段信息帶來一定的束縛,因為必須要考慮到格式的兼容性問題,再者就是推薦可以利用toString返回的字符串作為該類的構造函數參數來實例化該類的對象,如BigDecimal和BigInteger等裝箱類。
? ? ? 這里還有一點建議是和hashCode、equals相關的,如果類的實現者已經覆蓋了toString的方法,那么完全可以利用toString返回的字符串來生成hashCode,以及作為equals比較對象相等性的基礎。這樣的好處是可以充分的保證toString、hashCode和equals的一致性,也降低了在對類進行修訂時造成的一些潛在問題。盡管這不是剛性要求的,卻也不失為一個好的實現方式。該建議并不是源于該條目,而是去年在看effective C#中了解到的。
?? ?
十二、考慮實現Comparable接口:
?? ?? 和之前提到的通用方法equals、hashCode和toString不同的是compareTo方法屬于Comparable接口,該接口為其實現類提供了排序比較的規則,實現類僅需基于內部的邏輯,為compareTo返回不同的值,既A.compareTo(B) > 0可視為A > B,反之則A < B,如果A.compareTo(B) == 0,可視為A == B。在C++中由于提供了操作符重載的功能,因此可以直接通過重載操作符的方式進行對象間的比較,事實上C++的標準庫中提供的缺省規則即為此,如bool operator>(OneObject o)。在Java中,如果對象實現了Comparable接口,即可充分利用JDK集合框架中提供的各種泛型算法,如:Arrays.sort(a); 即可完成a對象數組的排序。事實上,JDK中的所有值類均實現了該接口,如Integer、String等。
?? ?? Object.equals方法的通用實現準則也同樣適用于Comparable.compareTo方法,如對稱性、傳遞性和一致性等,這里就不做過多的贅述了。然而兩個方法之間有一點重要的差異還是需要在這里提及的,既equals方法不應該拋出異常,而compareTo方法則不同,由于在該方法中不推薦跨類比較,如果當前類和參數對象的類型不同,可以拋出ClassCastException異常。在JDK 1.5 之后我們實現的Comparable<T>接口多為該泛型接口,不在推薦直接繼承1.5 之前的非泛型接口Comparable了,新的compareTo方法的參數也由Object替換為接口的類型參數,因此在正常調用的情況下,如果參數類型不正確,將會直接導致編譯錯誤,這樣有助于開發者在coding期間修正這種由類型不匹配而引發的異常。
? ? ? 在該條目中針對compareTo的相等性比較給出了一個強烈的建議,而不是真正的規則。推薦compareTo方法施加的等同性測試,在通常情況下應該返回和equals方法同樣的結果,考慮如下情況:
?? ?? 由以上代碼的輸出結果可以看出,TreeSet和HashSet中包含元素的數量是不同的,這其中的主要原因是TreeSet是基于BigDecimal的compareTo方法是否返回0來判斷對象的相等性,而在該例中compareTo方法將這兩個對象視為相同的對象,因此第二個對象并未實際添加到TreeSet中。和TreeSet不同的是HashSet是通過equals方法來判斷對象的相同性,而恰恰巧合的是BigDecimal的equals方法并不將這個兩個對象視為相同的對象,這也是為什么第二個對象可以正常添加到HashSet的原因。這樣的差異確實給我們的編程帶來了一定的負面影響,由于HashSet和TreeSet均實現了Set<E>接口,倘若我們的集合是以Set<E>的參數形式傳遞到當前添加BigDecimal的函數中,函數的實現者并不清楚參數Set的具體實現類,在這種情況下不同的實現類將會導致不同的結果發生,這種現象極大的破壞了面向對象中的"里氏替換原則"。
? ? ? 在重載compareTo方法時,應該將最重要的域字段比較方法比較的最前端,如果重要性相同,則將比較效率更高的域字段放在前面,以提高效率,如以下代碼:
? ? ? 上例給出了一個標準的compareTo方法實現方式,由于使用compareTo方法排序的對象并不關心返回的具體值,只是判斷其值是否大于0,小于0或是等于0,因此以上方法可做進一步優化,然而需要注意的是,下面的優化方式會導致數值類型的作用域溢出問題。
更多文章、技術交流、商務合作、聯系博主
微信掃碼或搜索:z360901061

微信掃一掃加我為好友
QQ號聯系: 360901061
您的支持是博主寫作最大的動力,如果您喜歡我的文章,感覺我的文章對您有幫助,請用微信掃描下面二維碼支持博主2元、5元、10元、20元等您想捐的金額吧,狠狠點擊下面給點支持吧,站長非常感激您!手機微信長按不能支付解決辦法:請將微信支付二維碼保存到相冊,切換到微信,然后點擊微信右上角掃一掃功能,選擇支付二維碼完成支付。
【本文對您有幫助就好】元
