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

swiftma發表於2016-08-22

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

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

上節我們介紹了繼承和多型的基本概念,基本概念是比較簡單的,子類繼承父類,自動擁有父類的屬性和行為,並可擴充套件屬性和行為,同時,可重寫父類的方法以修改行為。

但繼承和多型概念還有一些相關的細節,本節就來探討這些細節,具體包括:

  • 構造方法
  • 重名與靜態繫結
  • 過載和重寫
  • 父子型別轉換
  • 繼承訪問許可權 (protected)
  • 可見性重寫
  • 防止繼承 (final)

下面我們逐個來解釋。

構造方法

super

上節我們說過,子類可以通過super(...)呼叫父類的構造方法,如果子類沒有通過super(...)呼叫,則會自動調動父類的預設構造方法,那如果父類沒有預設構造方法呢?如下例所示:

public class Base {
    private String member;
    public Base(String member){
        this.member = member;
    }
}
複製程式碼

這個類只有一個帶引數的構造方法,沒有預設構造方法。這個時候,它的任何子類都必須在構造方法中通過super(...)呼叫Base的帶引數構造方法,如下所示,否則,Java會提示編譯錯誤。

public class Child extends Base {
    public Child(String member) {
        super(member);
    }
}
複製程式碼

構造方法呼叫重寫方法

如果在父類構造方法中呼叫了可被重寫的方法,則可能會出現意想不到的結果,我們來看個例子:

這是基類程式碼:

public class Base {
    public Base(){
        test();
    }
    
    public void test(){
    }
}
複製程式碼

構造方法呼叫了test()。這是子類程式碼:

public class Child extends Base {
    private int a = 123;
    
    public Child(){
    }
    
    public void test(){
        System.out.println(a);
    }
}
複製程式碼

子類有一個例項變數a,初始賦值為123,重寫了test方法,輸出a的值。看下使用的程式碼:

public static void main(String[] args){
    Child c = new Child();
    c.test();
}
複製程式碼

輸出結果是:

0
123
複製程式碼

第一次輸出為0,第二次為123。第一行為什麼是0呢?第一次輸出是在new過程中輸出的,在new過程中,首先是初始化父類,父類構造方法呼叫 test(),test被子類重寫了,就會呼叫子類的test()方法,子類方法訪問子類例項變數a,而這個時候子類的例項變數的賦值語句和構造方法還沒 有執行,所以輸出的是其預設值0。

像這樣,在父類構造方法中呼叫可被子類重寫的方法,是一種不好的實踐,容易引起混淆,應該只呼叫private的方法。

重名與靜態繫結

上節我們說到,子類可以重寫父類非private的方法,當呼叫的時候,會動態繫結,執行子類的方法。那例項變數、靜態方法、和靜態變數呢?它們可以重名嗎?如果重名,訪問的是哪一個呢?

重名是可以的,重名後實際上有兩個變數或方法。對於private變數和方法,它們只能在類內被訪問,訪問的也永遠是當前類的,即在子類中,訪問的是子類的,在父類中,訪問的父類的,它們只是碰巧名字一樣而已,沒有任何關係。

但對於public變數和方法,則要看如何訪問它,在類內訪問的是當前類的,但子類可以通過super.明確指定訪問父類的。在類外,則要看訪問變數的靜態型別,靜態型別是父類,則訪問父類的變數和方法,靜態型別是子類,則訪問的是子類的變數和方法。我們來看個例子:

這是基類程式碼:

public class Base {
    public static String s = "static_base";
    public String m = "base";
    
    public static void staticTest(){
        System.out.println("base static: "+s);
    }
}
複製程式碼

定義了一個public靜態變數s、一個public例項變數m、一個靜態方法staticTest。

這是子類程式碼:

public class Child extends Base {
    public static String s = "child_base";
    public String m = "child";
    
    public static void staticTest(){
        System.out.println("child static: "+s);
    }
}
複製程式碼

子類定義了和父類重名的變數和方法。對於一個子類物件,它就有了兩份變數和方法,在子類內部訪問的時候,訪問的是子類的,或者說,子類變數和方法隱藏了父類對應的變數和方法,下面看一下外部訪問的程式碼:

public static void main(String[] args) {
    Child c = new Child();
    Base b = c;
    
    System.out.println(b.s);
    System.out.println(b.m);
    b.staticTest();
    
    System.out.println(c.s);
    System.out.println(c.m);
    c.staticTest();
}
複製程式碼

以上程式碼建立了一個子類物件,然後將物件分別賦值給了子類引用變數c和父類引用變數b,然後通過b和c分別引用變數和方法。這裡需要說明的是,靜態變數和靜態方法一般通過類名直接訪問,但也可以通過類的物件訪問。程式輸出為:

static_base
base
base static: static_base
child_base
child
child static: child_base 
複製程式碼

當通過b (靜態型別Base) 訪問時,訪問的是Base的變數和方法,當通過c (靜態型別Child)訪問時,訪問的是Child的變數和方法,這稱之為靜態繫結,即訪問繫結到變數的靜態型別,靜態繫結在程式編譯階段即可決定,而動態繫結則要等到程式執行時。例項變數、靜態變數、靜態方法、private方法,都是靜態繫結的

過載和重寫

過載是指方法名稱相同但引數簽名不同(引數個數或型別或順序不同),重寫是指子類重寫父類相同引數簽名的方法。對一個函式呼叫而言,可能有多個匹配的方法,有時候選擇哪一個並不是那麼明顯,我們來看個例子:

這裡基類程式碼:

public class Base {
    public int sum(int a, int b){
        System.out.println("base_int_int");
        return a+b;
    }
}
複製程式碼

它定義了方法sum,下面是子類程式碼:

public class Child extends Base {
    public long sum(long a, long b){
        System.out.println("child_long_long");
        return a+b;
    }
}
複製程式碼

以下是呼叫的程式碼:

public static void main(String[] args){
    Child c = new Child();
    int a = 2;
    int b = 3;
    c.sum(a, b);
}
複製程式碼

這個呼叫的是哪個sum方法呢?每個sum方法都是相容的,int型別可以自動轉型為long,當只有一個方法的時候,那個方法就會被呼叫。但現在有多個方法可用,子類的sum方法引數型別雖然不完全匹配但是是相容的,父類的sum方法引數型別是完全匹配的。程式輸出為:

base_int_int
複製程式碼

父類型別完全匹配的方法被呼叫了。如果父類程式碼改成下面這樣呢?

public class Base {
    public long sum(int a, long b){
        System.out.println("base_int_long");
        return a+b;
    }
}
複製程式碼

父類方法型別也不完全匹配了。程式輸出為:

base_int_long
複製程式碼

呼叫的還是父類的方法。父類和子類的兩個方法的型別都不完全匹配,為什麼呼叫父類的呢?因為父類的更匹配一些。現在修改一下子類程式碼,更改為:

public class Child extends Base {
    public long sum(int a, long b){
        System.out.println("child_int_long");
        return a+b;
    }
}
複製程式碼

程式輸出變為了:

child_int_long
複製程式碼

終於呼叫了子類的方法。可以看出,當有多個重名函式的時候,在決定要呼叫哪個函式的過程中,首先是按照引數型別進行匹配的,換句話說,尋找在所有過載版本中最匹配的,然後才看變數的動態型別,進行動態繫結

父子型別轉換

之前我們說過,子型別的物件可以賦值給父型別的引用變數,這叫向上轉型,那父型別的變數可以賦值給子型別的變數嗎?或者說可以向下轉型嗎?語法上可以進行強制型別轉換,但不一定能轉換成功。我們以上面的例子來示例:

Base b = new Child();
Child c = (Child)b;
複製程式碼

Child c = (Child)b就是將變數b的型別強制轉換為Child並賦值為c,這是沒有問題的,因為b的動態型別就是Child,但下面程式碼是不行的:

Base b = new Base();
Child c = (Child)b;
複製程式碼

語法上Java不會報錯,但執行時會丟擲錯誤,錯誤為型別轉換異常。

一個父類的變數,能不能轉換為一個子類的變數,取決於這個父類變數的動態型別(即引用的物件型別)是不是這個子類或這個子類的子類。

給定一個父類的變數,能不能知道它到底是不是某個子類的物件,從而安全的進行型別轉換呢?答案是可以,通過instanceof關鍵字,看下面程式碼:

public boolean canCast(Base b){
    return b instanceof Child;
}
複製程式碼

這個函式返回Base型別變數是否可以轉換為Child型別,instanceof前面是變數,後面是類,返回值是boolean值,表示變數引用的物件是不是該類或其子類的物件。

protected

變數和函式有public/private修飾符,public表示外部可以訪問,private表示只能內部使用,還有一種可見性介於中間的修飾符protected,表示雖然不能被外部任意訪問,但可被子類訪問。另外,在Java中,protected還表示可被同一個包中的其他類訪問,不管其他類是不是該類的子類,後續章節我們再討論包。

我們來看個例子,這是基類程式碼:

public class Base {
    protected  int currentStep;
    
    protected void step1(){
    }
    
    protected void step2(){        
    }
    
    public void action(){
        this.currentStep = 1;
        step1();
        this.currentStep = 2;
        step2();
    }
}
複製程式碼

action() 表示對外提供的行為,內部有兩個步驟step1()和step2(),使用currentStep變數表示當前進行到了哪個步驟,step1、step2 和currentStep是protected的,子類一般不重寫action,而只重寫step1和step2,同時,子類可以直接訪問 currentStep檢視進行到了哪一步。子類的程式碼是:

public class Child extends Base {
    protected void step1(){
        System.out.println("child step "
                +this.currentStep);
    }
    
    protected void step2(){    
        System.out.println("child step "
                +this.currentStep);
    }
}
複製程式碼

使用Child的程式碼是:

public static void main(String[] args){
    Child c = new Child();
    c.action();
}
複製程式碼

輸出為:

child step 1
child step 2
複製程式碼

基類定義了表示對外行為的方法action,並定義了可以被子類重寫的兩個步驟step1和step2,以及被子類檢視的變數currentStep,子類通過重寫protected方法step1和step2來修改對外的行為。

這種思路和設計在設計模式中被稱之為模板方法,action方法就是一個模板方法,它定義了實現的模板,而具體實現則由子類提供。模板方法在很多框架中有廣泛的應用,這是使用protected的一個常用場景。關於更多設計模式的內容我們暫不介紹。

可見性重寫

重寫方法時,一般並不會修改方法的可見性。但我們還是要說明一點,重寫時,子類方法不能降低父類方法的可見性,不能降低是指,父類如果是public,則子類也必須是public,父類如果是protected,子類可以是protected,也可以是public,即子類可以升級父類方法的可見性但不能降低。如下所示:

基類程式碼為:

public class Base {
    protected void protect(){
    }
    
    public void open(){        
    }
}
複製程式碼

子類程式碼為:

public class Child extends Base {
    //以下是不允許的的,會有編譯錯誤
//    private void protect(){
//    }
    
    //以下是不允許的,會有編譯錯誤
//    protected void open(){        
//    }
    
    public void protect(){        
    }
}
複製程式碼

為什麼要這樣規定呢?繼承反映的是"is-a"的關係,即子類物件也屬於父類,子類必須支援父類所有對外的行為,將可見性降低就會減少子類對外的行為,從而破壞"is-a"的關係,但子類可以增加父類的行為,所以提升可見性是沒有問題的。

防止繼承 (final)

上節我們提到繼承是把雙刃劍,具體原因我們後續章節解說,帶來的影響就是,有的時候我們不希望父類方法被子類重寫,有的時候甚至不希望類被繼承,實現這個的方法就是final關鍵字。之前我們提過final可以修飾變數,這是final的另一個用法。

一個Java類,預設情況下都是可以被繼承的,但加了final關鍵字之後就不能被繼承了,如下所示:

public final class Base {
   //....
}
複製程式碼

一個非final的類,其中的public/protected例項方法預設情況下都是可以被重寫的,但加了final關鍵字後就不能被重寫了,如下所示:

public class Base {
    public final void test(){
        System.out.println("不能被重寫");
    }
} 
複製程式碼

小結

本節我們討論了Java繼承概念引入的一些細節,有些細節可能平時遇到的比較少,但我們還是需要對它們有一個比較好的瞭解,包括構造方法的一些細節,變數和方法的重名,父子型別轉換,protected,可見性重寫,final等。

但還有些重要的地方我們沒有討論,比如,建立子類物件的具體過程?動態繫結是如何實現的?讓我們下節來探索繼承實現的基本原理。


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

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

相關文章