Java編程的邏輯 (18) - 爲何說繼承是把雙刃劍

本系列文章經補充和完善,已修訂整理成書《Java編程的邏輯》,由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買,京東自營連接http://item.jd.com/12299018.htmlhtml


繼承是把雙刃劍
編程

經過前面幾節,咱們應該對繼承有了一個比較好的理解,但以前咱們說繼承實際上是把雙刃劍,爲何這麼說呢?一方面是由於繼承是很是強大的,另外一方面是由於繼承的破壞力也是很強的。 swift

繼承的強大是比較容易理解的,具體體如今:數組

  • 子類能夠複用父類代碼,不寫任何代碼便可具有父類的屬性和功能,而只須要增長特有的屬性和行爲。
  • 子類能夠重寫父類行爲,還能夠經過多態實現統一處理。
  • 給父類增長屬性和行爲,就能夠自動給全部子類增長屬性和行爲。

繼承被普遍應用於各類Java API、框架和類庫之中,一方面它們內部大量使用繼承,另外一方面,它們設計了良好的框架結構,提供了大量基類和基礎公共代碼。使用者可使用繼承,重寫適當方法進行定製,就能夠簡單方便的實現強大的功能。安全

但,繼承爲何會有破壞力呢?主要是由於繼承可能破壞封裝,而封裝能夠說是程序設計的第一原則,另外一方面,繼承可能沒有反映出"is-a"關係。下面咱們詳細來講明。微信

繼承破壞封裝框架

什麼是封裝呢?封裝就是隱藏實現細節。使用者只須要關注怎麼用,而不須要關注內部是怎麼實現的。實現細節能夠隨時修改,而不影響使用者。函數是封裝,類也是封裝。經過封裝,才能在更高的層次上考慮和解決問題。能夠說,封裝是程序設計的第一原則,沒有封裝,代碼之間處處存在着實現細節的依賴,則構建和維護複雜的程序是不可思議的。ide

繼承可能破壞封裝是由於子類和父類之間可能存在着實現細節的依賴。子類在繼承父類的時候,每每不得不關注父類的實現細節,而父類在修改其內部實現的時候,若是不考慮子類,也每每會影響到子類。
函數

咱們經過一些例子來講明。這些例子主要用於演示,能夠基本忽略其實際意義。post

封裝是如何被破壞的

咱們來看一個簡單的例子,這是基類代碼:

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並無辦法約束,父類有的屬性和行爲,子類並不必定都適用,子類還能夠重寫方法,實現與父類預期徹底不同的行爲。

但經過父類引用操做子類對象的程序而言,它是把對象當作父類對象來看待的,指望對象符合父類中聲明的屬性和行爲。若是不符合,結果是什麼呢?混亂。

如何應對繼承的雙面性?

繼承既強大又有破壞性,那怎麼辦呢?

  1. 避免使用繼承
  2. 正確使用繼承

咱們先來看怎麼避免繼承,有三種方法:

  • 使用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;
    }
}

這樣,子類就不須要關注基類是如何實現的了,基類修改實現細節,增長公開方法,也不會影響到子類了。

但,組合的問題是,子類對象不能被當作基類對象,被統一處理了。解決方法是,使用接口。

使用接口

關於接口咱們暫不介紹,留待下節。

正確使用繼承

若是要使用繼承,怎麼正確使用呢?使用繼承大概主要有三種場景:

  1. 基類是別人寫的,咱們寫子類。
  2. 咱們寫基類,別人可能寫子類。
  3. 基類、子類都是咱們寫的。 

第一種場景中,基類主要是Java API,其餘框架或類庫中的類,在這種狀況下,咱們主要經過擴展基類,實現自定義行爲,這種狀況下須要注意的是:

  1. 重寫方法不要改變預期的行爲。
  2. 閱讀文檔說明,理解可重寫方法的實現機制,尤爲是方法之間的調用關係。
  3. 在基類修改的狀況下,閱讀其修改說明,相應修改子類。

第二種場景中,咱們寫基類給別人用,在這種狀況下,須要注意的是:

  1. 使用繼承反映真正的"is-a"關係,只將真正公共的部分放到基類。
  2. 對不但願被重寫的公開方法添加final修飾符。
  3. 寫文檔,說明可重寫方法的實現機制,爲子類提供指導,告訴子類應該如何重寫。
  4. 在基類修改可能影響子類時,寫修改說明。 

第三種場景,咱們既寫基類、也寫子類,關於基類,注意事項和第二種場景相似,關於子類,注意事項和第一種場景相似,不過程序都由咱們控制,要求能夠適當放鬆一些。

小結

本節,咱們介紹了繼承爲何是把雙刃劍,繼承雖然強大,但繼承可能破壞封裝,而封裝能夠說是程序設計第一原則,繼承還可能被誤用,沒有反映真正的"is-a"關係。

咱們也介紹瞭如何應對繼承的雙面性,一方面是避免繼承,使用final避免、優先使用組合、使用接口。若是要使用繼承,咱們也介紹了使用繼承的三種場景下的注意事項。

本節提到了一個概念,接口,接口究竟是什麼呢?

----------------

未完待續,查看最新文章,敬請關注微信公衆號「老馬說編程」(掃描下方二維碼),從入門到高級,深刻淺出,老馬和你一塊兒探索Java編程及計算機技術的本質。用心寫做,原創文章,保留全部版權。

-----------

更多相關原創文章

計算機程序的思惟邏輯 (13) - 類

計算機程序的思惟邏輯 (14) - 類的組合

計算機程序的思惟邏輯 (15) - 初識繼承和多態

計算機程序的思惟邏輯 (16) - 繼承的細節

計算機程序的思惟邏輯 (17) - 繼承實現的基本原理

計算機程序的思惟邏輯 (19) - 接口的本質

計算機程序的思惟邏輯 (20) - 爲何要有抽象類?

計算機程序的思惟邏輯 (21) - 內部類的本質

相關文章
相關標籤/搜索