Java高效程式設計之二【對所有物件都通用的方法】

九天高遠發表於2013-08-08

對於所有物件都通用的方法,即Object類的所有非final方法(equals、hashCode、toString、clone和finalize)都有明確的通用約定,都是為了要被改寫(override)而設計的。

七、在改寫equals的時候請遵循約定

  • 一個類的每個例項實質上都是唯一的。對於代表了實體活動實體而不是值(value)的類,確實是這樣的,比如Thread。Object所提供equals實現對於這些類是正確的。
  • 不關心一個類是否提供了“邏輯相等(logical equality)”的測試功能。如java.util.Random改寫的equals,用於檢查兩個Random是否產生隨機數序列是否相等,但是設計者並不認為客戶會需要或者期望這樣的功能。在這樣的情況下,從Object繼承得到的equals實現就已經足夠了。
  • 超類已經改寫了equals,從超類繼承過來的行為對於子類也是合適的。例如,Set實現都從AbstractSet繼承了equals實現,List實現從AbstractList繼承了equals實現,Map實現從AbstractMap繼承了equals實現。
  • 一個類是私有的,或者是包級私有的,並且可以確定它的equals方法永遠也不會被呼叫。儘管這樣,應該也要改寫equals方法,以免萬一以後會被呼叫。改寫如下
public boolean equals(Object o){
  throw new UnsupportedOperationException();
}

那麼什麼時候要改寫equals呢?

“值類”的情形:程式設計師在利用equals類比較指向值物件的應用的時候,希望知道它們邏輯上是否相等,而不是是否指向同一個物件。
                    有一種“值類”可以不要求改寫equals方法,即型別安全列舉型別。因為型別安全列舉型別保證每一個值至多隻存在一個物件,所以對於這樣的類而言,Object的equals方法等同於邏輯上的equals方法。

改寫equals的時候要遵循如下的通用約定,來自於java.lang.Object的通用規範:

實現了等價關係。自反性、對稱性、傳遞性、一致性,且對於任意的非空引用值x(即要求所有的物件都不為空),x.equals(null)一定返回false。

要想在擴充套件一個可例項化的類的同時,既要增加行的特性,同時還要保留equals約定,沒有一個簡單的辦法可以做到這一點。

根據十四的建議,組合優先於繼承,滿足對稱性的實現如下:

// 在沒有破壞對稱性的前提下增加了檢視 
public class ColorPoint { 
   private Point point; 
   private Color color; 
 
   public ColorPoint(int x, int y, Color color) { 
      point = new Point(x, y); 
      this.color = color; 
   } 
 
   /** 
     * 返回有色點的點檢視 
     */ 
   public Point asPoint() { 
      return point; 
   } 
   public boolean equals(Object o) { 
      if (!(o instanceof ColorPoint)) 
         return false; 
      ColorPoint cp = (ColorPoint)o; 
       return cp.point.equals(point) && cp.color.equals(color); 
   } 
 
   ...  // 餘下省略
} 

特例:TimeStamp類,java.util.TimeStamp對java.util.Date進行子類化,並且增加了nanoseconds(納秒)域,TimeStamp違反了對稱性。 可以在一個抽象(abstract)類的子類中增加新的特性,而不會違反equals約定。這一點根據第20條的建議“用類層次(class hierarchies)來代替聯合(union)”

為了測試實參與當前物件的相等情況,equals必須首先把實參轉換為一種適當的型別,以便可以呼叫它的訪問方法或者訪問它的域。在做轉換之前,equals方法必須使用instanceof運算子,檢查它的實參是否為正確的型別。檢查後不必單獨做null的檢查,因為如果instanceof 的第一個操作位為null,不管第二個運算元是哪種型別,按照instanceof運算子的規定,都返回false。

public boolean equals(Object o){
     if(!(o instanceof MyType))
       return false;
      ……
}

如果漏掉了檢查,且傳遞給equals方法的實參有事錯誤的型別,那麼equals方法將會丟擲一個ClassCastException異常,這違反了equals約定。
實現高質量的equals的一個處方:

  1. 使用==運算子檢查“實參是否為指向物件的一個引用”。如果是,返回true。如果比較運算子比較耗時,這樣做能使效能得到最佳化。
  2. 使用instanceof檢視“實參是否為正確的型別”。
  3. 把實參轉化為正確的型別。注:使用強制型別轉換。
  4. 對於該類中每一個“關鍵(significant)”域,檢查實參中的域與當前物件中的域值是否相匹配。注:先比較最有可能不一致的域。
  5. 自檢:是否是對稱的、傳遞的、一致的。

最後的告誡:

  • 當你改寫equals的時候,總是要改寫hashCode(見八)。
  • 不要企圖讓equals方法過於聰明。
  • 不要使equals方法過於依賴不可靠的資源。如,java.net.URL,當把主機名轉換成ip地址的時候需要訪問網路,當不能保證每次都會產生相同的結果。
  • 不要將equals宣告中的Object物件替換為其他的型別。如下替換,可能導致出現問題很久都發現不了。  
public boolean equals(MyClass o){
         ……
}

因為Object.equals的實參型別為Object,而這個方法並沒有改寫override(override)Object.equals,相反它過載(overload)了Object.equals(見第二十六),在原有equals的基礎上提供了一個“強型別化(strongly typed)”的equals方法,通常不推薦這樣做。

八、改寫的equals方法總是要改寫hashCode

在每一個改寫了equals的的方法中,你必須也要改寫hashCode方法。如果不這樣做,就會違反Object.hashCode的通用約定,從而導致該類無法與所有基於雜湊值(hash)集合類地在一起正常運作,這樣的集合包括HashMap、HashSet和HashTable,它們儲存雜湊鍵(hash keys)。

三條通用約定:

  • 在一個應用程式執行期間,無論呼叫多少次hashCode返回的結果都是同一個整數。在同一個應用程式的多次執行過程中,這個整數可以不相同。
  • 相等的物件必須具有相等的雜湊碼(hash Code)。 
  • 不等的物件必須產生不相等的雜湊碼。

理想情況下,一個雜湊函式應該把一個集合中不相等的例項均勻分佈到可能的雜湊值上。

簡單的處方如下:

  1. 把一個非零變數值,比如說23,儲存在一個叫result的int型別的變數中。(23這個值任選,但最好不要選擇0)
  2. 對於該物件中的一個關鍵域(欄位)f(指equals方法中考慮的每一個域),完成以下步驟:
    a. 為每個域欄位計算int型別的雜湊碼:
    i. 若是 boolean, 則計算 (f ? 0 : 1).
    ii. 若是 byte, char, short, or int,則計算(int)f.
    iii.若是 long,則計算(int)(f ^ (f >>> 32)).
    iv. 若是 float,則計算Float.floatToIntBits(f).
    v. 若是 double,則計算Double.doubleToLongBits(f),然後按照步驟2.a.iii對long計算hash值.
    vi. 若該域是一個引用物件,並且equals方法透過遞迴呼叫方式來比較這個域,則遞迴呼叫這個域的hashCode. 
       如果這個域的值為null,則返回0.
    vii. 如果域是一個陣列,則把每一個元素當做一個單獨的域來處理。也就是說遞迴的應用上述規則,對於每一個重要的元素計算一個雜湊碼,然後根據2.b把這些雜湊組合起來。

           b. 按照下面的公式把步驟a中計算得到的雜湊碼c組合到result中: 
                result = 37*result + c;
         3. 返回result

         4. 寫好方法之後,思考是否滿足“相等的例項具有相等的雜湊碼”

 在相等的比較中沒有用到的任何域,要將它們排除在外,而且是必須要求 。

下面是帶有hashCode方法的PhoneNumber類:

public final class PhoneNumber { 
    private final short areaCode; 
    private final short exchange; 
    private final short extension; 
 
    public PhoneNumber(int areaCode, int exchange, 
                       int extension) { 
        rangeCheck(areaCode,   999, "area code"); 
  rangeCheck(exchange,   999, "exchange"); 
        rangeCheck(extension, 9999, "extension"); 
            this.areaCode  = (short) areaCode; 
            this.exchange  = (short) exchange; 
            this.extension = (short) extension; 
        } 
 
        private static void rangeCheck(int arg, int max, 
                                       String name) { 
            if (arg < 0 || arg > max) 
               throw new IllegalArgumentException(name +": " + arg); 
       } 
 
       public boolean equals(Object o) { 
           if (o == this) 
               return true; 
           if (!(o instanceof PhoneNumber)) 
               return false; 
           PhoneNumber pn = (PhoneNumber)o; 
           return pn.extension == extension && 
                  pn.exchange  == exchange  && 
                  pn.areaCode  == areaCode; 
       } 
 
       // hashCode方法 
      public int hashCode() { 
    int result = 23; 
    result = 37*result + areaCode;
    result = 37*result + exchange;
    result = 37*result + extension
    return result; 
   } 
       ... // 餘下省略
} 

如果一個類是非可變的,並且計算雜湊碼的代價也比較大,那麼你應該把雜湊鍵快取在物件內部,而不是每次請求的時候都重新計算雜湊碼。如果你覺得這種型別的大多數物件會被用做雜湊鍵(hash keys),那麼你應該是在例項被建立的時候計算雜湊碼。否則,可以選擇“延遲初始化”雜湊碼,一直等到hashCode第一次被呼叫的時候才初始化。(見四十八)

// 延遲初始化, 快取 hashCode 
private volatile int hashCode = 0;  // (見四十八) 
 
public int hashCode() { 
    if (hashCode == 0) { 
        int result = 17; 
        result = 37*result + areaCode; 
        result = 37*result + exchange; 
        result = 37*result + extension; 
        hashCode = result; 
    } 
    return hashCode; 
} 

注意:不要試圖從雜湊碼計算中排除掉一個物件的關鍵部分以提高效能。
String類的雜湊函式至多隻檢查16個字元,從第一個字元開始,在整個字串中均勻選取。

 九 、總是要改寫toString                       

 java.lang.Object的toString方法返回的是一個包含類名,以及一個“@”符號,接著是雜湊碼的無符號十六進位制表示,例如“PhoneNumber@163b91”,但是根據toString的通用約定指出,toString要返回“間接的,但資訊豐富的,並且易於閱讀的表達形式”雖然“PhoneNumber@163b91”是間接的,但是和“(408)867-5309”比較起來,他不是資訊豐富的。toString的約定還進一步指出,建議所有的子類都要改寫這個方法”。改寫toString並不是強制要求的,但是提供一個好的toString實現可以使一個類用起來更加愉快。當物件被傳遞給println、字串連線符號(+)以及1.4髮型版本之後的assert的時候,toString方法會自動呼叫。

  • 在實際的應用中,toString方法應該返回物件中包含所有令人感興趣的資訊。
  • 不管你是否指定格式,都應該在文件中明確的表明你的意圖。
  • 為toString返回值中包含的所有資訊,提供一種程式設計訪問途徑,總是一個好方法。

下面是PhoneNumber的格式化輸出toString方法:(例如把99格式化輸出4位就應該是0099,格式化輸出三位就是099)

 

public String toString() { 
    return "(" + toPaddedString(areaCode, 3) + ") " + 
            toPaddedString(exchange,  3) + "-" + 
            toPaddedString(extension, 4); 
} 
/** 
 * 將int型別翻譯成指定的長度的字串 
 * 左邊不夠以0填充, 假設 i >= 0, 
 * 1 <= length <= 10, 且 Integer.toString(i) <= length. 
 */ 
private static String toPaddedString(int i, int length) { 
    String s = Integer.toString(i); 
    return ZEROS[length - s.length()] + s; 
} 

private static String[] ZEROS = 
    {"", "0", "00", "000", "0000", "00000", 
     "000000", "0000000", "00000000", "000000000"};  

十、謹慎的改寫clone

Cloneable介面的目的是作為物件的一個mixin介面(mixin interface)(見十六),表明這樣的物件允許克隆(cloning),不行的是它並沒有成功達到這個目的,其主要缺陷在於缺少一個clone方法,Object的clone方法是被保護的。如果不借助於映像機制reflection(見三十五),則不能僅僅因為一個物件實現了Cloneable,就可以呼叫clone方法。即使是映像呼叫也可能會失敗,因為並不能夠保證該物件一定具有可以訪問的clone方法。事實上,對於實現了Cloneable的類,我們總是期望它也提供了一個功能適當的共有clone方法。

如果被克隆物件的每個域包含一個原語型別的值,或者包含一個指向非可變物件的引用,那麼被super.clone()返回的物件可能正是你想要的。

public Object clone(){
  try{
        super.clone();      
}catch(CloneUnsupportException e){
        throw new Error("Assertion failure");//不能發生
}
}

如果要把上面的Stack類做成可以clone的,

public class Stack { 
    private Object[] elements; 
    private int size = 0; 
     ……
}

僅僅使用super.clone()就會出現問題,在其size域中有正確的值,到那時它的elements域將引用到與原始Stack例項相同的資料上,修改原始的例項會破壞被克隆物件的陣列,反之亦然。很快就會丟擲NullPointerException異常。如果呼叫Stack類唯一的額建構函式,那麼這種情況永遠不會發生。實際上,clone方法是另一個建構函式;你必須確保它不會傷害到原始的物件,並且正確建立起來被克隆物件的約束關係

public Object clone() throws CloneNotSupportedException { 
    Stack result = (Stack) super.clone(); 
    result.elements = (Object[]) elements.clone(); 
    return result; 
} 

但是,如果elements域是final的,這種方案就不能正常工作,因為clone方法是進位制給elements域賦予一個新值的。這是一個基本呢問題,clone結構與指向可變物件的final域的正常用法是不相容的。除非在原始物件和克隆物件之間可以安全的共享此可變物件,為了使一個類成為可克隆的,可能有必要從某些域中去掉final修飾符。
clone方法淺表複製和深層複製有相似的地方。

如果擴充套件實現了Cloneable介面的類,就必須要實現一個行為良好的clone方法。否則,最好的做法是,提供某些其他途徑來代替物件複製,或者乾脆不提供這樣的能力。

另外一個實現物件複製的好辦法是提供一個複製建構函式(copy constructor)。複製建構函式也是一個建構函式,其唯一的引數型別是包含該建構函式的類,例如:

public Yum(Yum yum);

另一種方法是它的一個微小變形:提供一個靜態工廠來替代建構函式:

public static Yum newInstance(Yum yum);

綜合說起來,複製建構函式和靜態工廠比Cloneable/clone方法更具有優勢。所有的通用集合都提供了一個複製建構函式,它的引數型別是Collection或者Map。假設你有一個LinkedList 1,並且希望把它複製成一個ArrayList。clone方法沒有提供這樣的功能,但是用複製建構函式很容易實現:new ArrayList(1)。
Cloneable有上述很多安全問題,所以其他的介面不應該擴充套件(extends)這個介面,並且為了繼承而設計的類(見十五)也不應該實現(implement)這個介面。所以專家級的程式設計師從來不去改寫clone方法,也從來不去呼叫它。

十一、考慮實現comparable介面

CompareTo方法在Object並沒有被宣告,這點與其他方法不同,它是java.lang.Comparable介面中唯一的方法。一個類實現了Comparable介面,就表明它的例項具有內在的排序關係。若一個陣列中的物件實現了Comparable(可以比較的)介面,則對整個陣列進行排序就非常簡單:Arrays.sort(a),a為陣列。

對於儲存在集合中的Comparable物件,搜尋、計算極值以及自動維護都非常簡答。

Java平臺的所有值類都是先了Comparable。如果你正在編寫一個值類,並且它具有非常明顯的內在排序關係,比如按字母表排序、按數值順序或者按年代排序,那麼你幾乎總是應該考慮實現這個介面。

compareTo的規範和equals方法具有相似的特徵,其規範如下:

  • 滿足sgn(x.compareTo(y))==-sgn(y.compareTo(x))
  • 滿足比較關係可傳遞
  • 強烈建議(x.compareTo(y)==0)==(x.equals(y))

 就行違反了hashCode約定的類會破壞其他的依賴於雜湊做法的類一樣,一個違反了compareTo約定的類也會破壞其他依賴於比交換關係的類。依賴於比較關係的類包括有序集合TreeSet和TreeMap,以及工具類Collections和Arrays,他們內部包含有搜尋和排序演算法。

有序集合TreeSet、TreeMap使用的是compareTo施加的相等測試,而Hash(HashMap、HashSet)類使用的是equals施加的相等測試。例如,BigDecimal類,它的compareTo方法與equals方法不一致。如果你建立了個HashSet,並且加入了一個new BigDecimal("1.0")和一個new BigDecimal("1.00"),這這個集合將包含兩個元素,因為他們是透過equals方法來比較它們之間不相等的,BigDecimal在實現的時候equals考慮精度,而compareTo未考慮精度。然而,如果用TreeSet來實現這樣的過程,則會發現集合中僅僅包含一個元素,這是因為TreeSet使用的是compareTo比較。

欄位的比較本身是順序比較,而不是相等比較,比較物件的引用欄位可以透過遞迴呼叫CompareTo來實現。如果一個欄位沒有實現Comparable介面,或者你需要一個標準的排序關係,那麼你可以使用一個顯示的Comparator(比較器),或者編寫專門的Comparator(實現Comparator介面,重寫其中的compare方法和equals方法),或者使用已有的Comparator。譬如針對七中的CaseInsensitiveString類,compareTo方法使用一個已有的Comparator。

public int compareTo(Object o) { 
    CaseInsensitiveString cis = (CaseInsensitiveString)o; 
    return String.CASE_INSENSITIVE_ORDER.compare(s, cis.s); 
} 

Comparable和Comparator兩者比較:
相同點:兩者都是介面,需要實現,都為為比較物件的例項而生的。

不同點:

  • Comparable在java.lang包下面,Comparator在java.util的包下面。
  • 編寫一個物件的時候,如果implements了Comparable介面,就必須重寫其compareTo方法。在實現其的類物件上強制進行整體排序,這一順序被稱為類的自然排序。實現了這一介面的物件列表或者是陣列,可以透過Collection.sort,Array.sort自動排序。
  • 而comparator相當於一個比較器介面,實現該介面的類要重寫其compare方法,或equals方法。它相當於一個比較函式,強制在集合類物件上進行排序。並且該比較器可以傳遞給一個排序方法,如 Collections.sort(List,Comparator) 、Arrays.sort(Object[],Comparator) ,以實現對排列順序的精確控制。比較器也可以被用如有序Map和有序Set(TreeMap、TreeSet)的排序,或者是提供在沒有自然Comparable的物件集合上的排序。

 

 

 

 

 

相關文章