Effective Java 3rd 條目18 組合優於繼承
繼承是取得程式碼複用的一種強大方式,但是對於這項工作它不總是最好的工具。如果使用不恰當,那麼這將導致脆落的軟體。在一個包裡面使用繼承是安全的,包裡面的子類和超類實現是在同一個程式設計師的控制之下。當特意為擴充套件而設計和文件化的類(條目19)時,使用繼承也是安全的。跨包界線繼承普通具體類,無論如何是危險的。提醒一下,這本書使用“繼承”意思是實現繼承(implementation inheritance)(當一個類擴充套件另外一個)。這個條目裡面討論的問題不適用於介面繼承(interface inheritance)(當一個類實現了一個介面或者當一個介面擴充套件了另外一個)。
不像方法呼叫,繼承破壞了封裝性
為了使得這個具體,讓我們假設我們有一個使用HashSet的程式。為了調優我們的程式,我們需要查詢HashSet關於自從建立它以來添加了多少個元素(不要與它當前的大小相混淆了,當移除一個元素時當前大小減少)。為了提供這個功能,我們編寫了一個HashSet變體,它儲存了元素嘗試插入的數目計數,而且對這個計數有一個訪問方法。HashSet類包含了兩個方法add和addAll,可以新增元素。所以我們覆寫了這兩個方法:
// 已破壞 - 繼承的不恰當使用!
public class InstrumentedHashSet<E> extends HashSet<E> {
// 元素嘗試插入的數目
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方法新增三個元素。順便會注意到,我們使用一個靜態工廠方法List.of(它在Java9中新增的)建立了一個列表;如果你使用一個早期的版本,那麼使用Arrays.asList代替:
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("Snap", "Crackle", "Pop"));
我們本來期待在這個時間點getAddCount方法返回三,但是它返回了六。哪裡出了問題呢?在內部,HashSet的addAll方法是在它的add方法上實現的,雖然HashSet沒有文件化這個實現細節,但是這是相當合理。InstrumentedHashSet的addAll方法新增三個到addCount,然後使用super.addAll呼叫HashSet的addAll實現。這轉而呼叫InstrumentedHashSet裡覆寫的add方法,每次為一個元素。這三個呼叫的每一個再次添加了一到addCount,總共增加了六:用addAll方法新增的每個元素是雙倍計數的。
我們可以通過消除addAll方法的子類覆寫而“修正”子類。雖然最終的類會行得通,但是這讓它的正常功能依賴於這樣的事實:HashSet的addAll方法是實現在它的add方法上的。這個“自用”是實現細節,不會保證在Java平臺的所有實現中有效,而且受制於一個釋出到另一個釋出的改變。所以,最終的InstrumentedHashSet類將會脆弱的。
覆寫addAll方法對給定的集合迭代,為每個元素每次呼叫add方法,這將稍微好一點。這將保證正確的結果,不管
HashSet的addAll是否是實現在它的add方法之上,因為不再呼叫HashSet的addAll實現。然而,這個技巧沒有解決我們的所有問題。這相當於從新實現超類方法,這些方法可能會或者可能不會自用,這是困難的、費事的、容易出錯的而且可能降低效能。此外,它不總是可能的,因為一些方法在沒有訪問私有域(子類不可獲取)時不可能實現。
子類中脆弱的一個相關原因是,它們的超類可以在後續釋出中獲得新方法。假設一個程式讓它的安全取決於這樣一個事實:插入到某個集合中的所有元素滿足某個斷言(predicate)。這可以通過以下來保證:子類化集合和覆寫可以新增元素的每個方法,確保在新增元素之前滿足這個斷言。這正常執行,直到在後續釋出中可以插入元素的一個新方法新增到超類中。一旦這個發生,通過呼叫新方法(在超類中沒有覆寫這個新方法)新增一個“非法”元素,這變得可能了。這不純粹是一個理論問題。當改造Hashtable和Vector參與到Collections框架時,許多這個性質的安全漏洞不得不解決。
這兩個問題都的根源在於覆寫方法。你可能認為擴充套件一個類是安全的,如果你僅僅新增新方法而且避免覆寫已經存在的方法。雖然這種形式的擴充套件是更加安全,但是它不是沒有風險的。如果超類在後續釋出中獲得一個新方法,而且你運氣不好,給子類一個同樣簽名而不同返回型別的方法,那麼子類不再可以編譯 [JLS, 8.4.8.3]。如果你已經給了子類與新超類方法一樣簽名和返回型別的方法,那麼現在你覆寫了它。此外,你的方法滿足新超類方法的協定,這是值得懷疑的,因為當你編寫子類方法的時候,這個協定還沒有編寫。
幸好,有一種方式避免上面描述的所有問題。不是擴充套件一個已經存在的類,而是給你的新類一個私有域,它引用已經存在類的例項。這個設計叫組合(composition),因為已經存在的類成為新類的元件。新類中的每個例項方法呼叫已經存在類的包含例項的相應方法。這被稱為轉發(forwarding),而且新類的方法被稱為轉發方法(forwarding method)。最終的類將是牢固可靠的,不依賴於已經存在類的實現細節。即使新增新方法到已經存在類,也不會對新類有影響。為了使得這個具體,下面是InstrumentedHashSet的替代,它使用組合和轉發方法。注意,這個實現被拆成兩部分,類它自己和一個複用的轉發類,轉發類包含了所有轉發方法而沒有其他:
// 包裝類 - 使用組合代替繼承
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;
}
}
// 可複用的轉發方法
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(); }
}
InstrumentedSet類的設計由已經存在的Set介面開啟,這個介面獲取HashSet類的功能。除了是強健的,這個設計是極其靈活的。InstrumentedSet類實現了Set介面,而且有唯一構造子,它的引數也是Set型別。大體上,這個類把一個Set轉換到另外一個,添加了插樁(instrumentation)功能。繼承的方法僅僅對單一具體類起作用而且對於超類中每個支援的構造子需要一個單獨構造子,不像基於繼承的方法,包裝類可以使用於插樁任何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);
... // 這個方法使用iDogs而不是dogs
}
InstrumentedSet類被稱為包裝(wrapper)類,因為每個InstrumentedSet類包含(“包裝”)了另外一個Set例項。這也叫做裝飾(Decorator)模式[Gamma95],因為InstrumentedSet類通過新增插樁而“裝飾”一個集。組合和轉發的聯合有時被粗略地認為是代理(delegation)。技術上它不是代理,除非包裝物件把它自己傳遞給被包裝的物件[Lieber-man86; Gamma95]。
包裝類的缺點很少。一個警告是,包裝類是不適合在回撥框架(callback framework)中使用,這個框架中為了後續的呼叫(“回撥”),物件把自引用傳遞到其他物件。因為包裝物件不知道它的包裝,它把一個引用傳遞到它自己(this)而且回撥避開了包裝。這叫做SELF問題[Lieberman86]。一些人擔心呼叫轉發方法的效能影響,或者包裝物件的物件佔用影響。在實踐中兩件結果都沒有顯著的影響。編寫轉發方法是枯燥無味的,但是你為每個介面不得不編寫僅此一次的可複用轉發類,而且轉發類可能為你提供。比如,Guava為所有集合介面提供了轉發類[Guava]。
僅僅在子類真正是超類的子型別的情形下,繼承是適合的。換句話說,如果兩個類之間存在“is-a”關係,那麼類B應該擴充套件類A。如果你傾向於讓類B擴充套件類A,問問自己這個問題:每個B真正是一個A嗎?如果你不能毫不懷疑地對這個問題說是,B不要擴充套件A。如果回答是否,那麼通常情形是,B應該包含一個A私有例項,而且暴露一個不同的API:A不是B的基本部分,僅僅是它實現的一個細節。
在java平臺庫中有許多明顯違反這個原則。比如,棧不是一個vector,所以Stack不應該擴充套件Vector。相似地,屬性列表不是一個雜湊表,所以Properties不應該擴充套件Hashtable。這兩個例子中,組合應該是更加可取的。
如果你在組合適合的地方使用繼承,那麼你不必要地暴露了實現細節。最終的API把你綁到原來的實現,永遠限制了你的類的效能。更為糟糕的是,通過暴露內部結構,你使得客戶端直接獲取它們。起碼,這導致了令人困惑的語義。比如,如果p引用Properties例項,那麼p.getProperty(key)可能產生與p.get(key)不同的結果:前者方法考慮了預設,而繼承於HashTable的後者方法並沒有。更為嚴重的是,客戶端可能通過直接修改超類而破壞子類的不變性。Properties情況下,設計者意圖為,只允許字串作為鍵和值,但是直接訪問底層的Hashtable,違反了這個不變性。一旦違反了,使用Properties API的其他部分(load和store),變得不再可能了。到發現這個問題的時候,更正它就太遲了,因為客戶端已經依賴於非字串的鍵和值的使用。
在決定使用繼承代替組合之前,你應該問你自己最後一類問題。你思考擴充套件的這個類在它的API上有沒有缺陷?如果是這樣,對於傳播這些缺陷到你的類的API,你感到可接受嗎?繼承傳播超類API的任何缺陷,而組合讓你設計一個隱藏這些缺陷的新API。
總之,繼承是強大的,但是它是有問題的,因為它違反了封裝性。僅僅當子類和超類之間存在一個真正子型別關係時,這是合適的。即使那樣,如果子類是來自不同於超類的包,而且超類不是設計為繼承,那麼繼承可能導致脆弱性。為了避免這個脆弱性,使用組合和轉發而不是繼承,特別是為實現一個包裝類的一個恰當介面存在時。不僅包裝類比子類更加強健,而且它們也是更加功能強大的。