Effective Java 第三版——18. 組合優於繼承

Tips
《Effective Java, Third Edition》一書英文版已經出版,這本書的第二版想必不少人都讀過,號稱Java四大名著之一,不過第二版2009年出版,到如今已經將近8年的時間,但隨着Java 6,7,8,甚至9的發佈,Java語言發生了深入的變化。
在這裏第一時間翻譯成中文版。供你們學習分享之用。java

Effective Java, Third Edition

18. 組合優於繼承

繼承是實現代碼重用的有效方式,但並不老是最好的工具。使用不當,會致使脆弱的軟件。 在包中使用繼承是安全的,其中子類和父類的實現都在同一個程序員的控制之下。對應專門爲了繼承而設計的,而且有文檔說明的類來講(條目 19),使用繼承也是安全的。 然而,從普通的具體類跨越包級邊界繼承,是危險的。 提醒一下,本書使用「繼承」一詞來表示實現繼承(當一個類繼承另外一個類時)。 在這個項目中討論的問題不適用於接口繼承(當類實現接口或當接口繼承另外一個接口時)。程序員

與方法調用不一樣,繼承打破了封裝[Snyder86]。 換句話說,一個子類依賴於其父類的實現細節來保證其正確的功能。 父類的實現可能會從發佈版本不斷變化,若是是這樣,子類可能會被破壞,即便它的代碼沒有任何改變。 所以,一個子類必須與其超類一塊兒更新而變化,除非父類的做者爲了繼承的目的而專門設計它,並對應有文檔的說明。安全

爲了具體說明,假設有一個使用HashSet的程序。 爲了調整程序的性能,須要查詢HashSe,從建立它以後已經添加了多少個元素(不要和當前的元素數量混淆,當元素被刪除時數量也會降低)。 爲了提供這個功能,編寫了一個HashSet變體,它保留了嘗試元素插入的數量,並導出了這個插入數量的一個訪問方法。 HashSet類包含兩個添加元素的方法,分別是addaddAll,因此咱們重寫這兩個方法:app

// Broken - Inappropriate use of inheritance!
public class InstrumentedHashSet<E> extends HashSet<E> {
    // The number of attempted element insertions
    private int addCount = 0;

    public InstrumentedHashSet() {
    }

    public InstrumentedHashSet(int initCap, float loadFactor) {
        super(initCap, loadFactor);
    }
    @Override public boolean add(E e) {
        addCount++;
        return super.add(e);
    }
    @Override public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }
    public int getAddCount() {
        return addCount;
    }
}

這個類看起來很合理,可是不能正常工做。 假設建立一個實例並使用addAll方法添加三個元素。 順便提一句,請注意,下面代碼使用在Java 9中添加的靜態工廠方法List.of來建立一個列表;若是使用的是早期版本,請改成使用Arrays.asList框架

InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("Snap", "Crackle", "Pop"));

咱們指望getAddCount方法返回的結果是3,但實際上返回了6。哪裏出來問題?在HashSet內部,addAll方法是基於它的add方法來實現的,即便HashSet文檔中沒有指名其實現細節,倒也是合理的。InstrumentedHashSet中的addAll方法首先給addCount屬性設置爲3,而後使用super.addAll方法調用了HashSetaddAll實現。而後反過來又調用在InstrumentedHashSet類中重寫的add方法,每一個元素調用一次。這三次調用又分別給addCount加1,因此,一共增長了6:經過addAll方法每一個增長的元素都被計算了兩次。ide

咱們能夠經過消除addAll方法的重寫來「修復」子類。 儘管生成的類能夠正常工做,可是它依賴於它的正確方法,由於HashSetaddAll方法是在其add方法之上實現的。 這個「自我使用(self-use)」是一個實現細節,並不保證在Java平臺的全部實現中均可以適用,而且能夠隨發佈版本而變化。 所以,產生的InstrumentedHashSet類是脆弱的。工具

稍微好一點的作法是,重寫addAll方法遍歷指定集合,爲每一個元素調用add方法一次。 無論HashSetaddAll方法是否在其add方法上實現,都會保證正確的結果,由於HashSetaddAll實現將再也不被調用。然而,這種技術並不能解決全部的問題。 這至關於從新實現了父類方法,這樣的方法可能不能肯定究竟是否時自用(self-use)的,實現起來也是困難的,耗時的,容易出錯的,而且可能會下降性能。 此外,這種方式並不能老是奏效,由於子類沒法訪問一些私有屬性,因此有些方法就沒法實現。性能

致使子類脆弱的一個相關緣由是,它們的父類在後續的發佈版本中能夠添加新的方法。假設一個程序的安全性依賴於這樣一個事實:全部被插入到集中的元素都知足一個先決條件。能夠經過對集合進行子類化,而後並重寫全部添加元素的方法,以確保在添加每一個元素以前知足這個先決條件,來確保這一問題。若是在後續的版本中,父類沒有新增添加元素的方法,那麼這樣作沒有問題。可是,一旦父類增長了這樣的新方法,則頗有肯能因爲調用了未被重寫的新方法,將非法的元素添加到子類的實例中。這不是個純粹的理論問題。在把HashtableVector類加入到Collections框架中的時候,就修復了幾個相似性質的安全漏洞。學習

這兩個問題都源於重寫方法。 若是僅僅添加新的方法而且不要重寫現有的方法,可能會認爲繼承一個類是安全的。 雖然這種擴展更爲安全,但這並不是沒有風險。 若是父類在後續版本中添加了一個新的方法,而且你不幸給了子類一個具備相同簽名和不一樣返回類型的方法,那麼你的子類編譯失敗[JLS,8.4.8.3]。 若是已經爲子類提供了一個與新的父類方法具備相同簽名和返回類型的方法,那麼你如今正在重寫它,所以將遇到前面所述的問題。 此外,你的方法是否會履行新的父類方法的約定,這是值得懷疑的,由於在你編寫子類方法時,這個約定尚未寫出來。this

幸運的是,有一種方法能夠避免上述全部的問題。不要繼承一個現有的類,而應該給你的新類增長一個私有屬性,該屬性是 現有類的實例引用,這種設計被稱爲組合(composition),由於現有的類成爲新類的組成部分。新類中的每一個實例方法調用現有類的包含實例上的相應方法並返回結果。這被稱爲轉發(forwarding),而新類中的方法被稱爲轉發方法。由此產生的類將堅如磐石,不依賴於現有類的實現細節。即便將新的方法添加到現有的類中,也不會對新類產生影響。爲了具體說用,下面代碼使用組合和轉發方法替代InstrumentedHashSet類。請注意,實現分爲兩部分,類自己和一個可重用的轉發類,其中包含全部的轉發方法,沒有別的方法:

// Reusable forwarding class
import java.util.Collection;
import java.util.Iterator;
import java.util.Set;

public class ForwardingSet<E> implements Set<E> {

    private final Set<E> s;

    public ForwardingSet(Set<E> s) {
        this.s = s;
    }

    public void clear() {
        s.clear();
    }

    public boolean contains(Object o) {
        return s.contains(o);
    }

    public boolean isEmpty() {
        return s.isEmpty();
    }

    public int size() {
        return s.size();
    }

    public Iterator<E> iterator() {
        return s.iterator();
    }

    public boolean add(E e) {
        return s.add(e);
    }

    public boolean remove(Object o) {
        return s.remove(o);
    }

    public boolean containsAll(Collection<?> c) {
        return s.containsAll(c);
    }

    public boolean addAll(Collection<? extends E> c) {
        return s.addAll(c);
    }

    public boolean removeAll(Collection<?> c) {
        return s.removeAll(c);
    }

    public boolean retainAll(Collection<?> c) {
        return s.retainAll(c);
    }

    public Object[] toArray() {
        return s.toArray();
    }

    public <T> T[] toArray(T[] a) {
        return s.toArray(a);
    }

    @Override
    public boolean equals(Object o) {
        return s.equals(o);
    }

    @Override
    public int hashCode() {
        return s.hashCode();
    }

    @Override
    public String toString() {
        return s.toString();
    }
}
// Wrapper class - uses composition in place of inheritance
import java.util.Collection;
import java.util.Set;

public class InstrumentedSet<E> extends ForwardingSet<E> {

    private int addCount = 0;

    public InstrumentedSet(Set<E> s) {
        super(s);
    }
    
    @Override public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}

InstrumentedSet類的設計是經過存在的Set接口來實現的,該接口包含HashSet類的功能特性。除了功能強大,這個設計是很是靈活的。InstrumentedSet類實現了Set接口,並有一個構造方法,其參數也是Set類型的。本質上,這個類把Set轉換爲另外一個類型Set, 同時添加了計數的功能。與基於繼承的方法不一樣,該方法僅適用於單個具體類,而且父類中每一個須要支持構造方法,提供單獨的構造方法,因此可使用包裝類來包裝任何Set實現,而且能夠與任何預先存在的構造方法結合使用:

Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));

InstrumentedSet類甚至能夠用於臨時替換沒有計數功能下使用的集合實例:

static void walk(Set<Dog> dogs) {
    InstrumentedSet<Dog> iDogs = new InstrumentedSet<>(dogs);
    ... // Within this method use iDogs instead of dogs
}

InstrumentedSet類被稱爲包裝類,由於每一個InstrumentedSet實例都包含(「包裝」)另外一個Set實例。 這也被稱爲裝飾器模式[Gamma95],由於InstrumentedSet類經過添加計數功能來「裝飾」一個集合。 有時組合和轉發的結合被不精確地地稱爲委託(delegation)。 從技術上講,除非包裝對象把自身傳遞給被包裝對象,不然不是委託[Lieberman86;Gamma95]。

包裝類的缺點不多。 一個警告是包裝類不適合在回調框架(callback frameworks)中使用,其中對象將自我引用傳遞給其餘對象以用於後續調用(「回調」)。 由於一個被包裝的對象不知道它外面的包裝對象,因此它傳遞一個指向自身的引用(this),回調時並不記得外面的包裝對象。 這被稱爲SELF問題[Lieberman86]。 有些人擔憂轉發方法調用的性能影響,以及包裝對象對內存佔用。 二者在實踐中都沒有太大的影響。 編寫轉發方法有些繁瑣,可是隻需爲每一個接口編寫一次可重用的轉發類,而且提供轉發類。 例如,Guava爲全部的Collection接口提供轉發類[Guava]。

只有在子類真的是父類的子類型的狀況下,繼承纔是合適的。 換句話說,只有在兩個類之間存在「is-a」關係的狀況下,B類才能繼承A類。 若是你試圖讓B類繼承A類時,問本身這個問題:每一個B都是A嗎? 若是你不能如實回答這個問題,那麼B就不該該繼承A。若是答案是否認的,那麼B一般包含一個A的私有實例,而且暴露一個不一樣的API:A不是B的重要部分 ,只是其實現細節。

在Java平臺類庫中有一些明顯的違反這個原則的狀況。 例如,stacks實例並非vector實例,因此Stack類不該該繼承Vector類。 一樣,一個屬性列表不是一個哈希表,因此Properties不該該繼承Hashtable類。 在這兩種狀況下,組合方式更可取。

若是在合適組合的地方使用繼承,則會沒必要要地公開實現細節。由此產生的API將與原始實現聯繫在一塊兒,永遠限制類的性能。更嚴重的是,經過暴露其內部,客戶端能夠直接訪問它們。至少,它可能致使混淆語義。例如,屬性p指向Properties實例,那麼 p.getProperty(key)p.get(key)就有可能返回不一樣的結果:前者考慮了默認的屬性表,然後者是繼承Hashtable的,它則沒有考慮默認屬性列表。最嚴重的是,客戶端能夠經過直接修改超父類來破壞子類的不變性。在Properties類,設計者但願只有字符串被容許做爲鍵和值,但直接訪問底層的Hashtable容許違反這個不變性。一旦違反,就不能再使用屬性API的其餘部分(loadstore方法)。在發現這個問題的時候,糾正這個問題爲時已晚,由於客戶端依賴於使用非字符串鍵和值了。

在決定使用繼承來代替組合以前,你應該問本身最後一組問題。對於試圖繼承的類,它的API有沒有缺陷呢? 若是有,你是否願意將這些缺陷傳播到你的類的API中?繼承傳播父類的API中的任何缺陷,而組合可讓你設計一個隱藏這些缺陷的新API。

總之,繼承是強大的,但它是有問題的,由於它違反封裝。 只有在子類和父類之間存在真正的子類型關係時才適用。 即便如此,若是子類與父類不在同一個包中,而且父類不是爲繼承而設計的,繼承可能會致使脆弱性。 爲了不這種脆弱性,使用合成和轉發代替繼承,特別是若是存在一個合適的接口來實現包裝類。 包裝類不只比子類更健壯,並且更強大。

相關文章
相關標籤/搜索