標籤:ber 保護 閱讀 clear for 思維 情況 需要 組合
繼承是把雙刃劍
通過前面幾節,我們應該對繼承有了一個比較好的理解,但之前我們說繼承其實是把雙刃劍,為什麼這麼說呢?一方面是因為繼承是非常強大的,另一方面是因為繼承的破壞力也是很強的。
繼承的強大是比較容易理解的,具體體現在:
- 子類可以複用父類代碼,不寫任何代碼即可具備父類的屬性和功能,而只需要增加特有的屬性和行為。
- 子類可以重寫父類行為,還可以通過多態實現統一處理。
- 給父類增加屬性和行為,就可以自動給所有子類增加屬性和行為。
繼承被廣泛應用於各種Java API、架構和類庫之中,一方面它們內部大量使用繼承,另一方面,它們設計了良好的架構結構,提供了大量基類和基礎公用代碼。使用者可以使用繼承,重寫適當方法進行定製,就可以簡單方便的實現強大的功能。
但,繼承為什麼會有破壞力呢?主要是因為繼承可能破壞封裝,而封裝可以說是程式設計的第一原則,另一方面,繼承可能沒有反映出"is-a"關係。下面我們詳細來說明。
繼承破壞封裝
什麼是封裝呢?封裝就是隱藏實現細節。使用者只需要關注怎麼用,而不需要關注內部是怎麼實現的。實現細節可以隨時修改,而不影響使用者。函數是封裝,類也是封裝。通過封裝,才能在更高的層次上考慮和解決問題。可以說,封裝是程式設計的第一原則,沒有封裝,代碼之間到處存在著實現細節的依賴,則構建和維護複雜的程式是難以想象的。
繼承可能破壞封裝是因為子類和父類之間可能存在著實現細節的依賴。子類在繼承父類的時候,往往不得不關注父類的實現細節,而父類在修改其內部實現的時候,如果不考慮子類,也往往會影響到子類。
我們通過一些例子來說明。這些例子主要用於示範,可以基本忽略其實際意義。
封裝是如何被破壞的
我們來看一個簡單的例子,這是基類代碼:
public class Base { private static final int MAX_NUM = 1000; private int[] arr = new int[MAX_NUM]; private int count; public void add(int number){ if(count<MAX_NUM){ arr[count++] = number; } } public void addAll(int[] numbers){ for(int num : numbers){ add(num); } }}
Base提供了兩個方法add和addAll,將輸入數字添加到內部數組中。對使用者來說,add和addAll就是能夠添加數字,具體是怎麼添加的,應該不用關心。
下面是子類代碼:
public class Child extends Base { private long sum; @Override public void add(int number) { super.add(number); sum+=number; } @Override public void addAll(int[] numbers) { super.addAll(numbers); for(int i=0;i<numbers.length;i++){ sum+=numbers[i]; } } public long getSum() { return sum; }}
子類重寫了基類的add和addAll方法,在添加數位同時匯總數字,儲存數位和到執行個體變數sum中,並提供了方法getSum擷取sum的值。
使用Child的代碼如下所示:
public static void main(String[] args) { Child c = new Child(); c.addAll(new int[]{1,2,3}); System.out.println(c.getSum());}
使用addAll添加1,2,3,期望的輸出是1+2+3=6,實際輸出呢?
12
實際輸出是12。為什麼呢?查看代碼不難看出,同一個數字被匯總了兩次。子類的addAll方法首先調用了父類的addAll方法,而父類的addAll方法通過add方法添加,由於動態綁定,子類的add方法會執行,子類的add也會做匯總操作。
可以看出,如果子類不知道基類方法的實現細節,它就不能正確的進行擴充。知道了錯誤,現在我們修改子類實現,修改addAll方法為:
@Overridepublic void addAll(int[] numbers) { super.addAll(numbers);}
也就是說,addAll方法不再進行重複匯總。這下,程式就可以輸出正確結果6了。
但是,基類Base決定修改addAll方法的實現,改為下面代碼:
public void addAll(int[] numbers){ for(int num : numbers){ if(count<MAX_NUM){ arr[count++] = num; } }}
也就是說,它不再通過調用add方法添加,這是Base類的實現細節。但是,修改了基類的內部細節後,上面使用子類的程式卻錯了,輸出由正確值6變為了0。
從這個例子,可以看出,子類和父類之間是細節依賴,子類擴充父類,僅僅知道父類能做什麼是不夠的,還需要知道父類是怎麼做的,而父類的實現細節也不能隨意修改,否則可能影響子類。
更具體的說,子類需要知道父類的可重寫方法之間的依賴關係,上例中,就是add和addAll方法之間的關係,而且這個依賴關係,父類不能隨意改變。
但即使這個依賴關係不變,封裝還是可能被破壞。
還是以上面的例子,我們先將addAll方法改回去,這次,我們在基類Base中添加一個方法clear,這個方法的作用是將所有添加的數字清空,代碼如下:
public void clear(){ for(int i=0;i<count;i++){ arr[i]=0; } count = 0;}
基類添加一個方法不需要告訴子類,Child類不知道Base類添加了這麼一個方法,但因為繼承關係,Child類卻自動擁有了這麼一個方法!因此,Child類的使用者可能會這麼使用Child類:
public static void main(String[] args) { Child c = new Child(); c.addAll(new int[]{1,2,3}); c.clear(); c.addAll(new int[]{1,2,3}); System.out.println(c.getSum());}
先添加一次,之後調用clear清空,又添加一次,最後輸出sum,期望結果是6,但實際輸出呢?是12。為什麼呢?因為Child沒有重寫clear方法,它需要增加如下代碼,重設其內部的sum值:
@Overridepublic void clear() { super.clear(); this.sum = 0;}
以上,可以看出,父類不能隨意增加公開方法,因為給父類增加就是給所有子類增加,而子類可能必須要重寫該方法才能確保方法的正確性。
總結一下,對於子類而言,通過繼承實現,是沒有安全保障的,父類修改內部實現細節,它的功能就可能會被破壞,而對於基類而言,讓子類繼承和重寫方法,就可能喪失隨意修改內部實現的自由。
繼承沒有反映"is-a"關係
繼承關係是被設計用來反映"is-a"關係的,子類是父類的一種,子類對象也屬於父類,父類的屬性和行為也一定適用於子類。就像橙子是水果一樣,水果有的屬性和行為,橙子也必然都有。
但現實中,設計完全符合"is-a"關係的繼承關係是困難的。比如說,絕大部分鳥都會飛,可能就想給鳥類增加一個方法fly()表示飛,但有一些鳥就不會飛,比如說企鵝。
在"is-a"關係中,重寫方法時,子類不應該改變父類預期的行為,但是,這是沒有辦法約束的。比如說,還是以鳥為例,你可能給父類增加了fly()方法,對企鵝,你可能想,企鵝不會飛,但可以走和遊泳,就在企鵝的fly()方法中,實現了有關走或遊泳的邏輯。
繼承是應該被當做"is-a"關係使用的,但是,Java並沒有辦法約束,父類有的屬性和行為,子類並不一定都適用,子類還可以重寫方法,實現與父類預期完全不一樣的行為。
但通過父類引用操作子類對象的程式而言,它是把對象當做父類對象來看待的,期望對象符合父類中聲明的屬性和行為。如果不符合,結果是什麼呢?混亂。
如何應對繼承的雙面性?
繼承既強大又有破壞性,那怎麼辦呢?
- 避免使用繼承
- 正確使用繼承
我們先來看怎麼避免繼承,有三種方法:
- 使用final關鍵字
- 優先使用組合而非繼承
- 使用介面
使用final避免繼承
在上節,我們提到過final類和final方法,final方法不能被重寫,final類不能被繼承,我們沒有解釋為什麼需要它們。通過上面的介紹,我們就應該能夠理解其中的一些原因了。
給方法加final修飾符,父類就保留了隨意修改這個方法內部實現的自由,使用這個方法的程式也可以確保其行為是符合父類聲明的。
給類加final修飾符,父類就保留了隨意修改這個類實現的自由,使用者也可以放心的使用它,而不用擔心一個父類引用的變數,實際指向的卻是一個完全不符合預期行為的子類對象。
優先使用組合而非繼承
使用組合可以抵擋父類變化對子類的影響,從而保護子類,應該被優先使用。還是上面的例子,我們使用組合來重寫一下子類,代碼如下:
public class Child { private Base base; private long sum; public Child(){ base = new Base(); } public void add(int number) { base.add(number); sum+=number; } public void addAll(int[] numbers) { base.addAll(numbers); for(int i=0;i<numbers.length;i++){ sum+=numbers[i]; } } public long getSum() { return sum; }}
這樣,子類就不需要關注基類是如何?的了,基類修改實現細節,增加公開方法,也不會影響到子類了。
但,組合的問題是,子類對象不能被當做基類對象,被統一處理了。解決方案是,使用介面。
使用介面
關於介面我們暫不介紹,留待下節。
正確使用繼承
如果要使用繼承,怎麼正確使用呢?使用繼承大概主要有三種情境:
- 基類是別人寫的,我們寫子類。
- 我們寫基類,別人可能寫子類。
- 基類、子類都是我們寫的。
第一種情境中,基類主要是Java API,其他架構或類庫中的類,在這種情況下,我們主要通過擴充基類,實現自訂行為,這種情況下需要注意的是:
- 重寫方法不要改變預期的行為。
- 閱讀文檔說明,理解可重寫方法的實現機制,尤其是方法之間的調用關係。
- 在基類修改的情況下,閱讀其修改說明,相應修改子類。
第二種情境中,我們寫基類給別人用,在這種情況下,需要注意的是:
- 使用繼承反映真正的"is-a"關係,只將真正公用的部分放到基類。
- 對不希望被重寫的公開方法添加final修飾符。
- 寫文檔,說明可重寫方法的實現機制,為子類提供指導,告訴子類應該如何重寫。
- 在基類修改可能影響子類時,寫修改說明。
第三種情境,我們既寫基類、也寫子類,關於基類,注意事項和第二種情境類似,關於子類,注意事項和第一種情境類似,不過程式都由我們控制,要求可以適當放鬆一些。
小結
本節,我們介紹了繼承為什麼是把雙刃劍,繼承雖然強大,但繼承可能破壞封裝,而封裝可以說是程式設計第一原則,繼承還可能被誤用,沒有反映真正的"is-a"關係。
我們也介紹了如何應對繼承的雙面性,一方面是避免繼承,使用final避免、優先使用組合、使用介面。如果要使用繼承,我們也介紹了使用繼承的三種情境下的注意事項。
本節提到了一個概念,介面,介面到底是什麼呢?
----------------
電腦程式的思維邏輯 (18) - 為什麼說繼承是把雙刃劍【轉】