1. 程式人生 > >Effective Java 3rd 條目13 謹慎覆寫clone

Effective Java 3rd 條目13 謹慎覆寫clone

Cloneable介面目的是作為類的一個混入介面(mixin interface)(條目20),宣稱這些允許克隆。不幸的是,它未能作為這個目的。它的主要缺點在於,缺少了clone方法,而且Object的clone方法是受保護的。沒有訴諸於反射(reflection)(條目65),你不能僅僅因為它實現了Cloneable而呼叫物件的clone。甚至反射呼叫可能失敗,因為沒有保證物件有一個可獲取的clone方法。儘管這個缺點和其他的缺點,這個技巧在合理的廣泛使用中,所以理解它是值得的。這個條目告訴你怎麼實現一個行為良好的clone方法,在合適的時候討論,而且提出替代方法。

考慮到Cloneable沒有包含任何方法,那麼它做什麼的呢?它決定了Object的受保護的clone實現的行為:如果一個類實現了Cloneable,Object的clone方法返回這個物件逐個域的拷貝;否則它丟擲CloneNotSupportedException。這是介面的非常不典型的用法,而且不是應該效仿的一個。通常地,實現一個介面說明一個類能為它的客戶端做什麼。這個情形中,它改變了超類的受保護方法的行為。

儘管文件沒有說,在實踐中,一個實現了Cloneable的類被期待提供一個正常功能的公開clone方法。為了達到這個目的,這個類和它的所有超類必須遵從一個複雜的、非強制的和輕文件的協議。這個最終機制是脆落的、危險的和超語言的(extralinguistic):它建立了物件而沒有呼叫一個構造子。

clone方法的通用協定是不牢固的。就在這裡,從Object規範拷貝的:

建立和返回這個物件的一個拷貝。“拷貝”的確切含義可能取決於這個物件的類。通常的意圖是,對於任意物件x,表示式
x.clone() != x
將會是真,而且表示式
x.clone().getClass() == x.getClass()
將會是真,但是這些不是絕對的要求。雖然通常情況是這樣
x.clone().equals(x)
將會是真,但是這不是絕對的要求。
按照慣例,這個方法返回的物件應該通過呼叫super.clone獲得。如果一個類和它的所有超類(除了Object)遵從這個慣例,它將會是這個情形
x.clone().getClass() == x.getClass()
按照慣例,返回的物件應該獨立於克隆的物件。為了獲得這個獨立,在物件返回之前,應該有必要改變super.clone返回物件的一個或者多個域。

這個機制與構造子鏈是稍微相似的,除了這不是強制的:如果一個類的clone方法返回了一個例項,這個例項不是通過呼叫super.clone獲取的,而是通過呼叫一個構造子,那麼編譯器不會報錯,但是如果那個類的一個子類呼叫super.clone,最終的物件將有錯誤的類,這阻止了clone方法正常工作。如果一個覆寫了clone方法的類是final的,這個慣例可以安全地忽略,因為沒有子類可以擔心。但是如果一個final類有一clone方法,它沒有調動super.clone,那麼沒有理由為這個類實現Cloneable,因為它沒有依賴於Object的clone實現的行為。

假設一個類的超類提供了一個行為良好的clone方法,你想在這個類中實現Cloneable。首先,呼叫super.clone。你取回的物件將會是原件的完整功能的副本。在你的類中宣告的任何域,將有相等於原件那些域的值,如果每個域含有一個初始值,或者不可變物件的應用,那麼返回的物件可能就是你想要的,在這個情形中沒有必要進一步處理。比如,這是條目11中的PhoneNumber類的情形,但是注意,不可變的類應該從不提供一個clone方法

,因為它僅僅會鼓勵浪費拷貝。儘管如此,下面是PhoneNumber一個clone方法的樣子:

// clone方法,為沒有可變狀態的引用這個類
@Override public PhoneNumber clone() { 
    try { 
        return (PhoneNumber) super.clone(); 
    } catch (CloneNotSupportedException e) { 
        throw new AssertionError(); // Can't happen 
    } 
}

為了這個方法起作用,PhoneNumber類的宣告將被修改為表明它實現了Cloneable。儘管Object的clone方法返回Object,但是這個clone方法返回PhoneNumber。這樣做是合法和合理的,因為Java支援協變返回型別(covariant return type)。換句話說,覆寫方法的返回型別可以是被覆寫方法返回型別的一個子型別。這樣不需要在客戶端強轉。在返回Object的super.clone的返回值之前,我們必須強轉它,而且強轉保證一定成功。

super.clone的呼叫包含在一個try-catch程式碼塊中。這是因為Object宣告它的clone方法丟擲了CloneNotSupportedException,這是個受檢查的異常。因為PhoneNumber實現了Cloneable,所以我們知道super.clone的呼叫將會成功。樣板程式碼的必要性表明CloneNotSupportedException應該是沒有受檢查的(條目71)。

如果一個物件含有引用可變物件的域,那麼上面顯示的簡單的克隆實現可能是災難性的。比如,考慮條目7的Stack類:

public class Stack {
    private Object[] elements; 
    private int size = 0; 
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public Stack() { 
        this.elements = new Object[DEFAULT_INITIAL_CAPACITY]; 
    }

    public void push(Object e) { 
        ensureCapacity(); 
        elements[size++] = e; 
    }

    public Object pop() { 
        if (size == 0) 
            throw new EmptyStackException(); 
        Object result = elements[--size]; 
        elements[size] = null; // 消除過期引用 
        return result; 
    }

    // 保證至少有一個元素的空間 
    private void ensureCapacity() {
        if (elements.length == size)
            elements = Arrays.copyOf(elements, 2 * size + 1); 
    }
}

假設你想使得這個類是可克隆的。如果clone方法僅僅返回super.clone(),那麼產生的Stack例項將會在它的size域有正確的值,但是它的elements域,將會引用與原來Stack例項一樣的佇列。修改原件將會在克隆中破壞了這個不變數,反之亦然。你將很快發現,你的程式產生了無意義的結果或者丟擲一個NullPointerException。

這種情形應該永遠不會發生,作為呼叫Stack類的唯一構造子的結果。實際上,clone方法是起一個構造子的作用;你必須保證它不會影響原來的物件,而且保證它在克隆上正確地建立不變數。為了Stack的clone方法正常工作,它必須拷貝Stack的內部元件。這麼做最容易的方式是在elements佇列上遞迴地呼叫clone方法:

// Clone method for class with references to mutable state 
@Override public Stack clone() {
    try { 
        Stack result = (Stack) super.clone(); 
        result.elements = elements.clone(); 
        return result; 
    } catch (CloneNotSupportedException e) { 
        throw new AssertionError(); 
    }
}

注意,我們沒必要將elements.clone的結果強轉到Object[]。在一個佇列上呼叫clone方法返回一個佇列,它的執行時和編譯時的型別,同被克隆的佇列的那些型別是相同的。這是複製一個佇列的優選習慣用法。事實上,佇列是clone附加功能的唯一強制使用。

同時注意到,如果elements域是final的,前面的解決方法不起作用,因為clone被禁止賦新值到域。這是一個基本的問題:就像系列化,Cloneable架構,與引用可變物件的final域的正常使用,是不相容的,除了在這些情況下:可變物件可以在物件和它的克隆之間安全地共享。為了使得一個類可克隆,移除一些域的final修飾符可能是必要的。

僅僅遞迴地呼叫clone不總是足夠的。比如,假設你為雜湊表編寫一個clone方法,它的內部有桶佇列組成,每個桶引用鍵值對連結串列中的第一項。為了效能,類單獨實現了它自己的輕量級連結串列,而不是內部使用java.util.LinkedList:

public class HashTable implements Cloneable { 
    private Entry[] buckets = ...;
    private static class Entry { 
        final Object key; 
        Object value; 
        Entry next;

        Entry(Object key, Object value, Entry next) { 
            this.key = key; 
            this.value = value; 
            this.next = next; 
        }
    } 
    ... // 其餘省略
}

假設你僅僅遞迴克隆桶佇列,就像我們已經為Stack做的:

// 已破壞的clone方法 - 導致共享的可變狀態!
@Override public HashTable clone() {
    try { 
        HashTable result = (HashTable) super.clone(); 
        result.buckets = buckets.clone(); 
        return result; 
    } catch (CloneNotSupportedException e) { 
        throw new AssertionError(); 
    }
}

儘管克隆有它自己桶佇列,但是這個佇列與原來的佇列引用同一個連結串列,這可能在克隆和原件中容易造成不確定的行為。為了解決這個問題,你不得不拷貝由每個桶組成的連結串列。下面是一個通用的方法:

// 複雜可變狀態類的遞迴clone方法
public class HashTable implements Cloneable {
    private Entry[] buckets = ...;

    private static class Entry {
        final Object key;
        Object value;
        Entry  next;

        Entry(Object key, Object value, Entry next) {
            this.key   = key;
            this.value = value;
            this.next  = next;  
        }
        // 遞迴地拷貝由這個Entry開頭的連結串列
        Entry deepCopy() {
            return new Entry(key, value,
                next == null ? null : next.deepCopy());
        }
    }

    @Override public HashTable clone() {
        try {
            HashTable result = (HashTable) super.clone();
            result.buckets = new Entry[buckets.length];
            for (int i = 0; i < buckets.length; i++)
                if (buckets[i] != null)
                    result.buckets[i] = buckets[i].deepCopy();
            return result;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
    ... //其餘省略
}

私有類HashTable.Entry被增強為支援一個“深度拷貝”方法。HashTable的clone方法,分配了新的恰當大小的一個桶佇列,遍歷了原來的桶佇列,深拷貝了每個非空桶。Entry的deepCopy方法,遞迴地呼叫自己來拷貝由這個項開頭的整個連結串列。如果桶不是太長,這個技巧是較好的而且工作正常,但是這不是克隆連結串列的一個好方式,因為它為列表中每個元素消耗了一個棧幀。如果列表很長,這可能容易造成棧溢位。為了防止這個發生,你可以用迭代代替deepCopy裡面的遞迴:

// 迭代地拷貝由這個Entry開頭的連結串列
Entry deepCopy() {
   Entry result = new Entry(key, value, next);
   for (Entry p = result; p.next != null; p = p.next)
      p.next = new Entry(p.next.key, p.next.value, p.next.next);
   return result;
}

克隆複雜可變物件的最後方案是呼叫super.clone,設定最終物件中的所有域為它們的初始狀態,然後呼叫更高層的方法重新生成原來物件的狀態。對於我們HashTable例子的情形,buckets域將會初始化為一個新的桶佇列,而且將會為克隆雜湊表中的每個鍵值對映,呼叫put(key, value)方法(沒有展示出來)。這個方法通常是一個簡單而非常優雅的clone方法,它不會像直接操作克隆內部結構的clone方法執行那麼快。雖然這個方法簡潔,但是與整個Cloneable結構是相違背的,因為它盲目地覆蓋逐個域的物件拷貝,它形成了這個架構的基礎。

就像構造子,clone方法在建立中決不應該在克隆上呼叫一個可覆寫的方法(條目19)。如果clone呼叫一個被子類覆寫的方法,在子類有機會克隆中修改它的狀態之前,這個方法將會執行,這很可能導致在克隆和原件中的損壞。所以,前面段落討論的put(key, value)方法,應該是final或者私有的。(如果它是私有的,那麼它大概是非final公開方法的“helper方法”。)

Object的clone方法宣告為丟擲CloneNotSupportedException,但是覆寫方法是不必要的。公開的clone方法應該省略throws子句,因為不會丟擲受檢查異常的方法更容易使用(條目71)。

當為繼承而設計類的時候你有兩個選擇(條目19),但是不管你選擇哪個,類不應該實現Cloneable。你可能選擇通過實現一個方法模擬Object的行為,這個方法是正常功能的受保護clone方法,而且clone方法是被宣告為丟擲CloneNotSupportedException。這給子類自由:是否實現Cloneable,就像它們直接擴充套件了Object。或者,你可以選擇不實現一個可行的clone方法,而且通過提供如下的反生成clone實現阻止子類實現這個介面:

//不支援Cloneable的可擴充套件類的clone方法 
@Override 
protected final Object clone() throws CloneNotSupportedException { 
    throw new CloneNotSupportedException(); 
}

另一個細節需要注意。如果你編寫實現了Cloneable的執行緒安全類,記住它的clone方法必須正確地同步,就像其他的方法(條目87)。Objective的clone方法不是同步的,所以即使它的實現在其他方法是滿足的,但是你可能必須編寫返回super.clone()的一個同步clone方法。

簡要概括,所有實現Cloneable的類應該用公開方法覆寫克隆,這個公開方法返回型別是這個類本身。這個方法應該首先呼叫super.clone,然後修改需要修改的域。通常這意味著,拷貝任何由物件內部“深層結構”組成的可變物件,而且用它們拷貝的引用代替clone的這些物件的引用。雖然這些內部拷貝經常可以遞迴呼叫克隆,但是這不總是最好的方案。如果類包含了只有原始型別的域或者對不可變物件的引用,那麼域很可能不需要修改。這個規律也有例外情況。比如,一個代表系列碼或者其它唯一ID的域,如果它是原始型別或者不可變,那麼它需要修改。

所有這些複雜性真的是有必要嗎?其實很少。如果你擴充套件已經實現了Cloneable的類,那麼你幾乎沒有選擇,只有實現一個執行良好的clone方法。否則,你通常最好提供物件拷貝的替代方法。一個更好的物件拷貝方案是,提供一個拷貝構造子(copy constructor)或者拷貝工廠(copy factory)。一個拷貝構造子僅僅是一個接受單個引數的構造子,這個引數的型別是包含構造子的類,比如,

// 拷貝構造子 
public Yum(Yum yum) { ... };

拷貝工廠是一個靜態工廠(條目1),類似於拷貝構造子:

// 拷貝工廠 
public static Yum newInstance(Yum yum) { ... };

拷貝構造子方法和它的靜態工廠變體相對於Cloneable/clone有許多優點:他們不依賴於風險大的語言之外的物件建立機制;它們不要求非強制遵循輕文件規範;它們不會與final域的正常使用相沖突;它們不會丟擲不必要的受檢查異常;而且它們不要求強轉。

此外,拷貝構造子或者工廠可以接受一個引數,它的型別是類實現的一個介面。比如,按照慣例,所有通用目的的資料集實現提供了一個構造子,它的引數是Collection或者Map型別。基於介面的拷貝構造子和工廠,更恰當地叫做轉換構造子和轉換工廠,讓客戶端選擇拷貝的實現型別而不是強制客戶端接受原件的實現型別。比如,假設你有一個HashSet,s,而且你想拷貝它為一個TreeSet。clone方法不能提供這個特性,但是對於使用轉換構造子是容易的:new TreeSet<>(s)。

考慮到與Cloneable關聯的所有問題,新介面不應該擴充套件它,而且新可擴充套件類不應該實現它。對於final類實現Cloneable,雖然有更少危害,這應該看作一個性能優化,為極少數適合的情形保留(條目67),一般來說,由構造子或者工廠提供的拷貝功能是最好的。這個規則的一個顯著例外是佇列,它最好由clone法拷貝。