1. 程式人生 > >第16條:複合優先於繼承

第16條:複合優先於繼承

術語:

轉發(forwarding):新類中的每個例項方法都可以呼叫被包含的現有類例項中對應的方法,並返回它的結果。新類中的方法被稱為“轉發方法”。

        繼承(inheritance)是實現程式碼重用的有力手段,但它並非永遠是完成這項任務的最佳工作。使用不當會導致軟體變得很脆弱。在包的內部使用繼承是非常安全的,在那裡,子類和超類的實現都處於同一個程式設計師的控制下。對於專門為了繼承而設計的並且具有很好的文件說明的類來說,使用繼承也是非常安全的。然而,對於普通的具體類進行跨超包邊界的繼承則是非常危險的。本條目並不適用於介面繼承(一個類實現一個介面,或者一個介面擴充套件另一個介面)。

        與方法呼叫不同的是,繼承打破了封裝性。子類信賴於其超類中特定功能的實現細節。超類的實現有可能會隨著發行版本的不同而有變化,子類有可能會被破壞。考慮下面的例子:

// 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(Collections<? extends E> c) {
		addCount += c.size();
		return super.addAll(c);
	}
	public int getAddCount() {
		return addCount;
	}
}
        這段程式碼看起來沒有什麼問題,我們用addCount來跟蹤新增的元素的數量,但是
InstrumentedHashSet<String> s = new InstrumentedHashSet<String>();
s.addAll(Arrays.asList("a", "b", "c");
        當執行完這句程式碼後,使用s.addCount來返回加入的元素個數時卻顯示為6,問題在於,addAll方法會呼叫add方法來完成功能,在HashSet超類鏈中,AbstractCollection實現了addAll方法,程式碼如下:
    public boolean addAll(Collection<? extends E> c) {
        boolean modified = false;
        for (E e : c)
            if (add(e))
                modified = true;
        return modified;
    }
        可見,當使用addAll方法新增元素時addCount增加了3,而後在addAll方法內部又迭代的呼叫了add方法,addCount又增加了3,結果為addCount變成了6。為了修改這個問題,可以去年被覆蓋的addAll方法,但是它的功能正確性需要信賴於HashSet的addAll方法是在add方法上實現的這一事實,這種自用性(self-use)是實現細節,而不是承諾,不能保證在java平臺的所有實現中都保持不變,不能保證隨著上發行版本的不同而不發生變化

        導致子類脆弱的一個相關的原因是:它們的超類在後續的發行版本中可以獲得新的方法,假設一個程式的安全性信賴於這樣的事實:所有被插入到某個集合的元素都滿足某個先決條件。下面的做法就可以確保這一點:對集合進行子類化,並覆蓋所有能夠新增元素的方法以便確保在加入每個元素之前它是滿足這個先決條件的。如果在後續的發行版本中,超類中沒有增加能插入元素的新方法,那麼這種方法可以正常工作。然而,一旦超類增加了這樣的新方法,則很可能僅僅由於呼叫了這個未被子類覆蓋的新方法,而將不合法的元素新增到子類的例項中。

        這兩個問題的來源都是因為“覆蓋”。如果在擴充套件一個類的時候僅僅是增加新的方法而不覆蓋現有的方法,這也許看來相對安全一些,但是設想一下,如果超類在後續的發行版本中獲得了一個新方法,並且和子類中的某一方法只是返回型別不同,那麼這樣的子類將針法通過編譯。如果給子類提供的方法帶有與新的超類方法完全相同的方法(簽名和返回型別都相同),這又變成了子類覆蓋超類的方法問題。此外,子類的方法是否則夠遵守新的超類的方法的約定也是個值得懷疑的問題,因為當編寫子類方法的時候,這個約定根本還沒有面世。

        使用“複合(composition)”可以解決上述的問題,不用擴充套件現有的類,而是在新的類中增加一個私有域。通過“轉發”來實現與現有類的互動,這樣得到手類將會非常穩固。它不信賴於現有類的實現細節。即使現有的類增加了新方法,也不會影響到新類。請看如下的例子:

// Wrapper class - uses composition in place of inheritance
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;
	}
}

// Reusable forwarding class
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(); }
}
        在上面這個例子裡構造了兩個類,一個是用來擴充套件操作的包裹類,一個是用來與現有類進行互動的轉發類,可以看到,在現在這個實現中不再直接擴充套件Set,而是擴充套件了他的轉發類,而在轉發類內部,現有Set類是作為它的一個數據域存在的,轉發類實現了Set<E>介面,這樣他就包括了現有類的基本操作,同時也可能包裹任何Set型別。每個轉發動作都直接呼叫現有類的相應方法並返回相應結果。這樣就將信賴於Set的實現細節排除在包裹類之外。有的時候,複合和轉發的結合被錯誤的稱為"委託(delegation)"。從技術的角度來說,這不是委託,除非包裝物件把自身傳遞給被包裝的物件。
        包裝類幾乎沒有什麼缺點。需要注意的一點是,包裝類不適合用在架設框架上(callback framework),在回撥框架中,物件把自身的引用傳遞給其他的物件,用於後續的呼叫。因為被包裝起來的物件並不知道它外面的包裝物件,所以它傳遞一個指向自身的引用(this),回撥時避開了外面的包裝物件。這被稱為SELF問題。

        只有當子類真正是超類的子型別(subtype)時,才適合用繼承。對於兩個類A和B,只有當兩者之間確實存在"is-a"的關係的時候,類B才應該擴充套件A。如果打算讓類B擴充套件類A,就應該確定一個問題:B確實也是A嗎?如果不能確定答案是肯定的,那麼B就不應該擴充套件A。如果答案是否定的,通常情況下B應該包含A的一個私有例項,並且暴露一個較小的、較簡單的API:A本質上不是B的一部分,只是它的實現細節而已(使用API的客戶端無需知道)。

        如果在適合於使用複合的地方使用了繼承,則會不必要地暴露實現細節。這樣得到的API會把你限制在原始的實現上。永遠限定了類的效能。更為嚴重的是,由於暴露了內部細節,客戶端就有可能直接訪問這些內部細節。這樣至少會導致語言上的混亂。最嚴重的,客戶有可能直接修改超類,從而破壞了子類的約束條件。比如說Properties的例項,設計者的意圖是隻允許字串作為鍵和值,但是如果直接去訪問它的超類Hashtable就可以違反這個約束。

        在決定使用繼承而不是複合之前,還應該問自己最後一組問題:對於你正試圖擴充套件的類,它的API中有沒有缺陷呢?如果有,你是否願意把它們傳播到類的API中?繼承機制會把超類API中的所有缺陷傳播到子類中(除非你願意重寫一遍改進版的超類),而複合則允許設計親的API來隱藏這些細節。

        簡而言之,繼承的功能非常強大,但是也存在諸多問題,因為它違反了封裝原則。只有當子類和超類之間確實存在子型別的關係時,使用繼承才是恰當的。即使如此,如果子類和超類處在不同的包中,並且超類並不是為了繼承而設計的,那麼繼承將會導致脆弱性。為了避免這種情況,可以使用複合和轉發機制來代替繼承,尤其是當存在適當的介面可以實現包裝類的時候。包裝類不僅比子類更加健壯,而且功能也更強大。