計算機程式的思維邏輯 (18) - 為什麼說繼承是把雙刃劍

swiftma發表於2016-08-28

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (18) - 為什麼說繼承是把雙刃劍

繼承是把雙刃劍

通過前面幾節,我們應該對繼承有了一個比較好的理解,但之前我們說繼承其實是把雙刃劍,為什麼這麼說呢?一方面是因為繼承是非常強大的,另一方面是因為繼承的破壞力也是很強的。

繼承的強大是比較容易理解的,具體體現在:

  • 子類可以複用父類程式碼,不寫任何程式碼即可具備父類的屬性和功能,而只需要增加特有的屬性和行為。
  • 子類可以重寫父類行為,還可以通過多型實現統一處理。
  • 給父類增加屬性和行為,就可以自動給所有子類增加屬性和行為。

繼承被廣泛應用於各種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並沒有辦法約束,父類有的屬性和行為,子類並不一定都適用,子類還可以重寫方法,實現與父類預期完全不一樣的行為。

但通過父類引用操作子類物件的程式而言,它是把物件當做父類物件來看待的,期望物件符合父類中宣告的屬性和行為。如果不符合,結果是什麼呢?混亂

如何應對繼承的雙面性?

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

  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程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (18) - 為什麼說繼承是把雙刃劍

相關文章