1. 程式人生 > >【第10條】謹慎地改寫clone

【第10條】謹慎地改寫clone

    原作者在這一條上用了8頁的篇幅,翻譯版也有7頁,足以說明這一條的重要性。我個人對此條的標註是重量級的5顆星!

    克隆——是一個很讓人“感興趣”而又“頗有爭議”的話題,無論是在生物界還是在程式碼的世界中。

    Java通過實現Cloneable介面來“說明”一個類支援clone方法。所謂clone就是返回一個當前物件的副本,注意這裡所返回的是一個複製品,雖然它的內容應該與原物件完全一致(這才叫克隆嗎),但是它們的地址指標卻是不同的,區別於簡單的地址賦值(= 操作符)。

    Object中clone方法的定義是:

    protected native Object clone() throws CloneNotSupportedException;

   首先它是保護的,其次它是native(本地)的,也就是說它是通過其他語言編寫的程式碼,是看不到原始碼的,最後它可能丟擲CloneNotSupportedException,在類不支援克隆時。

    Object.clone() 採用了本地方法,通過其他語言予以實現,可能是為了提高效能,也可能是其他什麼原因,我們就不深究了。我們還是看看Cloneable介面吧:

public interface Cloneable { 
}

  它其實是個空空的,具體方法一個也沒有。那麼它到底做了什麼呢?它決定了Object中受保護的clone方法實現的行為:如果一個類實現了Cloneable,則Object的clone方法返回該物件的逐域拷貝,否則的話丟擲一個CloneNotSupportedException異常。這是介面的一種極端非典型的用法,也不值得效仿。

    那麼既然實現了Cloneable介面後,就可以呼叫Object中的clone方法了(Cloneable介面改變了超類中一個受保護的方法的行為),那我們的目的不就達到了嗎?幹嗎還要改寫clone呢?

    Object的clone方法,只能逐域拷貝那些原語型別,對於類僅僅是地址賦值,換句話說,它只是逐域在做 = 操作。這樣並不是完全的克隆,所以我們需要改寫clone方法。

    Object中關於克隆的約定:

1) x.clone() != x ,將會為 true

2) x.clone().getClass() == x.getClass() ,將會為 true

3) x.clone().equals(x) , 將會為 true

    “將會為true”,但是這也不是一個絕對要求。拷貝往往會導致建立一個新例項,但同時也會要求拷貝內部的資料結構。這個過程中沒有呼叫建構函式。

    “沒有呼叫建構函式”和“x.clone().getClass() == x.getClass() ” 的綜合導致結果就是:如果你改寫一個非final類的clone方法,則應該返回一個通過呼叫super.clone而得到的物件(具體推到過程見書上的第40頁)。這其實也相當於給我提供了一個改寫clone方法的“處方”:

     不要使用建構函式來建立類,而是使用超類的clone方法。

     於是clone方法的改寫模板可以是這樣的:

public class MyClass{
........

    public Object clone() {
        MyClass v = null;
        try{
            v = (MyClass) super.clone();
            // 逐域克隆MyClass的非原生型別域
     } catch (CloneNotSupportedException e) {
            // this shouldn't happen, since we are Cloneable
            throw new InternalError();
       }
      return v;
     }
}

    簡而言之,所有實現了Cloneable介面的類都應該用一個公有的方法改寫clone。此方法首先呼叫super.clone,然後修正任何需要修正的域。通常情況下,這意味著要拷貝任何包含內部“深層結構”的可變物件,並且要用指向新物件的引用代替原來指向這些物件的引用。雖然,這些內部拷貝操作往往可以通過遞迴地呼叫clone來完成,但這通常並不是最佳方法。通常原語型別和非可變物件時無需修改的,但也有例外情況,譬如,一些代表唯一ID的序列號或代表物件建立時間的域,雖然是原語型別或非可變物件,也要被修改。

    其實,只裡面的一句“然後修正任何需要修正的域”,可非同小可。“這意味著要拷貝任何包含內部‘深層結構’的可變物件”,這可就複雜了。如果域是一個數組的話,需要新建立一個相同大小的陣列,並對每一個數組元素遞迴地呼叫他們的clone方法。

    但是,不幸的事情發生了。我在實踐clone的時候發現,Java的集合型別其實實現的只是“淺表克隆”,也就是說它們並沒有“逐域遞迴克隆深層結構”。為什麼呢?其實,如果你自己去實現一個數組的“深層克隆”就會發現,由於放入陣列(集合)中的元素可能是各種型別,所以只能是Object,而Object本身是無法clone的。理由很簡單,不是所有的類都實現了Cloneable介面,那麼如果一個放入陣列(集合)中的物件是一個沒有實現Cloneable介面的類,那麼就無從談起對它的克隆了。所以,約定中說的是“這意味著要拷貝任何包含內部‘深層結構’的可變物件”而不是“這意味著要遞迴克隆任何包含內部‘深層結構’的可變物件”。注意用的詞是“拷貝”而不是“克隆”,因為任何型別都可以“拷貝”,其方法就不僅限於“克隆”了。

     那麼該如何進行一個沒有實現Cloneable介面的物件的“拷貝”工作呢?我們來舉一個 BigDecimal 的例子。在呼叫完超類的clone方法之後,就該修改這些需要被修改的域了。

v.varBigDecimal = new BigDecimal(this.varBigDecimal.toString());

由於BigDecimal既沒有實參型別為BigDecimal的valueOf靜態工廠方法,也沒有實參型別為BigDecimal的建構函式,所以只能通過String型來做“傳遞”。

    那麼對於域中包含陣列、集合,而宣告時又沒有指定其元素型別的類(尤其是那些用來被繼承的超類本來就無從知曉型別)來說,這裡的“修改”方法將變得異常複雜,你必須去判斷每個元素的型別,當然還要求儘量考慮到所有可能的型別,但這也不能保證日後使用過程中不會出現之前未知的型別。

    再加之,各集合型別的clone方法已經是寫好的了,而且很不幸,它們並沒有如上方法去處理,而只是對內部元素的引用賦值,如果你想實現一個嚴格的clone方法,而你的類當中又可能出現集合型別的域,那麼很不幸,你只有逐一補足那麼集合型別的clone方法了(在呼叫原有的clone方法後,逐一遍歷其內部元素並克隆之,裡面還有集合怎麼辦?$#%&$#% 暈倒~ 崩潰~)

    看到這,也許你對clone的看法也會和我一樣,真的要這麼複雜嗎?是的,所以書中這一條的題目才是“謹慎地”改寫clone。同時,書中也給我們指了出路——最好的方法是,提供某些其他途徑來代替物件拷貝,或乾脆不提供這樣的能力。一個好的代替方法是“拷貝建構函式”

public MyClass(MyClass myClass);

或另一種微小變形——靜態工廠方法

public static MyClass newInstance(MyClass myClass);

 下面再說一點本書以外的內容,首先是關於“深度”、“淺表”和“影子”拷貝。

    “影子”一詞是我自己“創造”的,其實和其他人所謂的“淺表”大致是一個意思。而我所謂的“淺表”,不僅僅是沒有完成對深層物件的拷貝,而且甚至連引用賦值也沒有做的才叫“淺表”,而“影子”拷貝是進行了引用賦值的。那麼“淺表”拷貝的意義在與何呢?由於它對那些類物件什麼也沒有做,還保留著“原來”的樣子,所以它的用意是“保護”那些非原語型別的域。在clone中,由於副本物件是剛剛通過super.clone()建立的,所以那些非原語型別的域實際上應該已經完成了引用的賦值,所以“保護”變成極其有限度的“加速”。它的“保護”作用更重要的體現地是copyProperties。

     第二是,克隆的應用場景。

     正如很多人不喜歡克隆,或者說clone有著很多爭議那樣,“如果你的程式碼中經常要使用clone,那麼可以錯略地說,你的設計是有問題的”。那麼什麼時候使用clone呢?一個典型的案例是:當從服務端得到查詢結果後,將結果顯示到GUI之上的同時,將其clone一份並儲存起來。當用戶對GUI上的資訊進行了編輯之後按下“儲存”按鈕想要儲存時,這時調出來剛才儲存的clone檔對比一下,如果發現並沒有區別(x.equals(y) == true),將提示使用者沒有可儲存的修改。可能是使用者改來改去後,結果又回到了當初,這種情況是較為常見的。

    而在服務端,建立一個物件的副本,然後將原物件送入某一方法,在這個方法執行過後,再去比較原物件和副本物件是否還equals,類似這樣的設計顯然是不好的。

    最後再來說一下實踐中比較實用的clone的替代品:

1) 如果一個類是可序列化的(實現系列化),那麼可以將其先序列化,然後再反序列化已得到其副本。如果物件很大,甚至可以序列化到磁碟上。

2) 更普遍的,可以將一個物件轉為字元流,然後在進而轉到副本物件中。不依賴於類必須可系列化,所以更通用。

3) 如果是JavaBean(就像我的情況),可以使用org.apache.commons.beanutils.BeanUtils.cloneBean靜態方法