繼承是把雙刃劍
通過前面幾節,我們應該對繼承有了一個比較好的理解,但之前我們說繼承其實是把雙刃劍,為什麼這麼說呢?一方面是因為繼承是非常強大的,另一方面是因為繼承的破壞力也是很強的。
繼承的強大是比較容易理解的,具體體現在:
繼承被廣泛應用於各種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方法為:
@Override public 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值:
@Override public 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修飾符,父類就保留了隨意修改這個類實現的自由,使用者也可以放心的使用它,而不用擔心一個父類引用的變量,實際指向的卻是一個完全不符合預期行為的子類對象。
優先使用組合而非繼承
使用組合可以抵擋父類變化對子類的影響,從而保護子類,應該被優先使用。還是上面的例子,我們使用組合來重寫一下子類,代碼如下:
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避免、優先使用組合、使用接口。如果要使用繼承,我們也介紹了使用繼承的三種場景下的注意事項。
本節提到了一個概念,接口,接口到底是什麼呢?
----------------
未完待續,查看最新文章,敬請關注微信公眾號“老馬說編程”(掃描下方二維碼),從入門到高級,深入淺出,老馬和你一起探索Java編程及計算機技術的本質。用心寫作,原創文章,保留所有版權。