《Java從入門到失業》第五章:繼承與多態(5.1-5.7):繼承

5.繼承與多態

5.1為什麼要繼承

       最近我兒子迷上了一款吃雞遊戲《香腸派對》,無奈給他買了許多玩具槍,我數了下,有一把狙擊槍AWM,一把步槍AK47,一把重機槍加特林(Gatling)。假如我們把這些玩具槍抽象成類,類圖的示意圖大致如下:

 

我們發現,這3者之間有很多相同的屬性和方法(紅色部分)。有沒有什麼辦法能夠減少這種編寫重複代碼的辦法呢?Java提供了繼承來解決這個問題。我們可以在更高一層抽象一個槍類,在槍類裏面編寫這些重複的屬性和方法,然後其餘的槍都繼承自槍類,它們只需要編寫各自獨有的屬性和方法即可,使用繼承優化後的類圖設計如下:

 

在Java中,使用extends關鍵字來實現繼承,我們把代碼示例如下:

package com.javadss.javase.ch05;  
  
// 槍類  
class Gun {  
    private String name;  
    private String color;  
  
    public String getName() {  
        return this.name;  
    }  
  
    public String getColor() {  
        return this.color;  
    }  
  
    public void shoot() {  
        System.out.println("單發");  
    }  
  
    public void loadBullet() {  
        System.out.println("裝彈");  
    }  
}  
  
// AWM類  
class AWM extends Gun {  
    private String gunsight;  
    private String gunstock;  
  
    // 安裝瞄準器  
    public void loadGunsight(String gunsight) {  
        this.gunsight = gunsight;  
    }  
  
    // 安裝支架  
    public void loadGunstock(String gunstock) {  
        this.gunstock = gunstock;  
    }  
}  
  
// AK47類  
class AK47 extends Gun {  
    private String gunsight;  
  
    // 安裝瞄準器  
    public void loadGunsight(String gunsight) {  
        this.gunsight = gunsight;  
    }  
  
    // 連發  
    public void runingShoot() {  
        System.out.println("連發");  
    }  
}  
  
// 加特林類  
class Gatling extends Gun {  
    private String gunstock;  
  
    // 安裝支架  
    public void loadGunstock(String gunstock) {  
        this.gunstock = gunstock;  
    }  
  
    // 連發  
    public void runingShoot() {  
        System.out.println("連發");  
    }  
}

我們看到,類AWM、AK47、Gatling的定義都加上了extends Gun,表示它們都繼承Gun類。在面向對象的術語中,我們把Gun叫做超類(superclass)、基類(base class)、父類(parent class),把AWM、AK47、Gatling叫做子類(subclass)、派生類(derived class)、孩子類(child class)。不過在Java中,我們一般習慣用超類和子類的方式來稱呼。

5.2繼承層次

       事實上,繼承是可以多層次的,上面我們的AWM繼承自Gun,狙擊AWM其實還有一些變種,例如AWP,我們可以再編寫一個AWP繼承自AWM。這種繼承可以無限下去。事實上,在Java中,有一個頂級超類java.lang.Object,任何沒有明確使用extends關鍵字的類,都是繼承自Object類的。

       由一個公共超類派生出來的所有類的集合稱為繼承層次,在繼承層次中,從某個類到其祖先的路徑稱為該類的繼承鏈。下圖演示了Object類在本示例的部分繼承層次:

 

       在Java中是不支持多繼承的,也就是說一個類只能繼承自一個類,不過可以通過接口變相的多繼承,關於接口的討論我們將會在後面進行。

5.3構造子類

       我們現在來構造一把AWM,我們另外編寫一個ExtendTest類專門用來測試,代碼如下:

public class ExtendTest {  
    public static void main(String[] args) {  
        AWM awm = new AWM();  
    }  
}  

這段代碼並沒有什麼問題,編譯通過。但是我們觀察一下,超類Gun和AWM類中都沒有編寫構造方法,表示都使用的默認構造器,現在假如我們給Gun增加一個構造方法如下:

public Gun(String name, String color) {  
        this.name = name;  
        this.color = color;  
    }  

這時候,我們發現,Eclipse會提示我們AWM類有個錯誤:

Implicit super constructor Gun() is undefined for default constructor. Must define an explicit constructor  

意思是超類沒有隱式的定義默認構造函數Gun(),AWM類必須顯式的定義構造器。這是因為子類在構造的時候,必須要同時構造超類。要麼顯式的在子類構造器調用超類構造方法,否則編譯器會自動的在子類構造器第一句話調用超類的默認構造器。

  前面Gun類沒有顯式定義構造器的時候,代碼不報錯,是因為系統會自動給Gun添加一個默認構造器,然後在構造AWM類時候,系統自動調用AWM的默認構造器並且自動幫我們調用Gun類的默認構造器。後面Gun增加了一個帶參構造器後,就沒有默認構造器了。這時候構造AWM的時候,系統調用AWM默認的構造器,並且嘗試幫我們調用Gun的默認構造器,但是發現Gun並沒有默認構造器,因此報錯。為了不報錯,那麼就必須在構造AWM的時候,調用Gun新增的帶參數的構造器,為此,我們也編寫一個帶參數的AWM構造器,那麼如何在子類中調用超類的構造器呢?使用super關鍵字。代碼如下:

public AWM(String name, String color, String gunsight) {  
        super(name, color);  
        this.gunsight = gunsight;  
    }  

這裡需要注意,使用super調用超類的構造器,必須是子類構造器的第一條語句。

5.4訪問超類屬性和方法

       構造子類搞定了,如何訪問超類的屬性和方法呢?討論這個問題之前,我們先把在討論包作用域的時候討論的4種修飾符的作用範圍表列出來:

 

同一個類

同一個包

不同包子類

不同包非子類

public

protected

 

default

 

 

private

 

 

 


上面我們說過,繼承的目的之一是把公共的屬性和方法放到超類中,節省代碼量。對於外部來說,雖然AWM類沒有定義name和color屬性,但是應該相當於擁有name和color屬性。上面我們通過AWM的構造方法傳入了name和color屬性。那麼當外部需要訪問的時候怎麼辦呢?因為Gun的getName方法和getColor方法是public修飾的,因此可以直接調用:

public class ExtendTest {  
    public static void main(String[] args) {  
        AWM awm = new AWM("awm", "綠色", "4倍鏡");  
        String name = awm.getName();// 返回awm  
        String color = awm.getColor();// 返回綠色  
    }  
}  

如果我們想給AWM增加一個修改顏色的方法,該怎麼辦呢?因為相當於擁用color屬性,能直接this.color訪問嗎?答案是否定的。因為AWM類相當於擁有color屬性,那也僅僅是對外部來說相當於而已,最終color屬性還是屬於超類的,並且是private修飾的,因此子類是不能直接訪問的,有辦法修改嗎?有,並且有3種。

一種是給Gun類增加一個public的setColor方法,這個就類似getColor方法一樣,結果顯而易見。採用這種方式的話,Gun的所有子類就都擁有了setColor方法。

如果只想單獨讓AWM類開放修改顏色的方法,另一種方法是將Gun類的color屬性修改成protected修飾的,然後給AWM增加一個setColor方法,代碼如下:

public void setColor(String color) {  
        super.color = color;//使用super關鍵字調用超類的屬性  
    } 

我們又一次看到了super關鍵字,使用super.屬性可以訪問父類的可見屬性(因為Gun類的color屬性是protected修飾的)。不過這種方法有一個不好的地方,就是Gun的color屬性被定義為protected的,任何人都可以編寫子類,然後直接訪問color屬性,違背了封裝性原則。另外,對於同一個包下其他類,也是可以直接訪問的。一般情況下不推薦把屬性暴露為protected。

       第三種方法,就是給Gun類增加一個protected修飾的setColor方法,然後給AWM類開放一個setColor方法,代碼分別如下:

Gun類的方法:

protected void setColor(String color) {  
      this.color = color;  
}  

AWM類的方法:

public void setColor(String color) {  
        super.setColor(color);// 使用super關鍵字調用超類的方法  
    }  

我們再一次看到了super關鍵字,使用super.方法可以訪問父類的可見方法。最後,我們總結一下:

  • 對於超類public的屬性和方法,外部可以直接通過子類訪問。
  • 對於超類protected的屬性和方法,子類中可以通過super.屬性和super.方法來訪問,外部不可見
  • 對於超類private的屬性和方法,子類無法訪問。

5.5到底繼承了什麼

       引入這個問題,是因為筆者在寫上面這些知識點的時候,也翻閱了很多資料,參看了很多網文和教程,最後發現,對於繼承屬性這塊,居然存在着一些分歧:

  1. 超類的pubilc、protected屬性會被子類繼承,其他的屬性不能被繼承。理由是pubilc、protected的屬性,子類都可以隨意訪問,即可以像上面我們討論的用super.屬性訪問,其實還可以直接使用this.屬性訪問,就像使用自己的屬性一樣。但是private的屬性,子類無法訪問。
  2. 超類的所有屬性都會被子類繼承,只不過針對不同的修飾符,對於訪問的限制不同而已。

對於繼承屬性這一塊,事實上官方的指南的原文如下:

A subclass does not inherit the private members of its parent class. However, if the superclass has public or protected methods for accessing its private fields, these can also be used by the subclass.

  筆者其實更喜歡從內存角度看待問題,前面的一些章節也多次從內存角度分析問題。前面我們看到,實例化一個子類的時候,必須要先實例化超類。當我們執行完下列語句:

AWM awm = new AWM("awm", "綠色", "4倍鏡"); 

內存如下圖:

 

我們看到,實際上在awm的內部,存在着一個Gun對象。name和color屬性都是Gun對象的。awm對象實際上只擁有gunsight和gunstock屬性。this關鍵字指向的是awm對象本身,super關鍵字指向的是內部的Gun對象。事實上,不管Gun中的屬性是如何修飾的,最終都是存在於Gun對象中。

  對於外部來說,只知道存在一個AWM對象實例awm,並不知道awm內部還有一個Gun對象。外部能看見的屬性就是AWM和Gun所有的public屬性,因此只能使用awm.屬性訪問這些能看見的屬性。

  對於awm來說,自身的屬性不用說了,能看見的是超類Gun中的public和protected屬性,假如Gun和AWM同包的話,AWM還能看見Gun中的默認修飾屬性。對於這些能看見的屬性,即可以用super.屬性訪問,也可以用this.屬性訪問。

       因此筆者覺得,沒必要去摳字眼,只要心中長存一副內存圖,走到哪裡都不怕。另外,對於方法,和屬性類似,這些我相信讀者自己就能分析明白。不過有一點要記住,構造方法是不能被繼承的,例如Gun有一個構造方法:

public Gun(String name, String color) {  
        this.name = name;  
        this.color = color;  
} 

AWM有一個構造方法:

public AWM(String name, String color, String gunsight) {  
        super(name, color);  
        this.gunsight = gunsight;  
} 

AWM並不能繼承Gun的2個參數的構造方法,因此外部無法通過語句:new AWM(“awm”, “綠色”);來創建一個AWM實例。

5.6覆蓋超類的屬性

       既然從內存上,超類和子類是相對獨立存在的,那麼我們思考一個問題,子類可以編寫和超類同樣名字的屬性嗎?答案是可以。我們看代碼(隱藏了部分無關代碼)

class Gun {  
    private String name;  
    private String color;  
  
    public Gun(String name, String color) {  
        this.name = name;  
        this.color = color;  
    }  
  
    public String getColor() {  
        return this.color;  
    }  
}  

class AWM extends Gun {  
    private String gunsight;  
    private String gunstock;  
    public String color;  
  
    public AWM(String name, String color, String gunsight) {  
        super(name, "黃色");  
        this.color = color;  
        this.gunsight = gunsight;  
    }  
}  

我們看到,AWM類也定義了一個和Gun同名的屬性color,然後修改了AWM的構造方法,注意第一句話,傳入給Gun的顏色是「黃色」。我們用一段代碼測試一下:

public class ExtendTest {  
    public static void main(String[] args) {  
        AWM awm = new AWM("awm", "綠色", "4倍鏡");  
        System.out.println(awm.getColor());  
        System.out.println(awm.color);  
    }  
}  

輸入結果是:

黃色  
綠色 

結果是不是有點意外?我們照例還是用內存圖來分析,結果就一目了然了:

 

我們看到,這樣做有一個非常不好的地方,就是對於外部來說,只認為AWM有一個color屬性和一個getColor()方法,但是實際上存在着2個color屬性,維護起來很費勁,一旦出現失誤(例如本例),就出出現讓外部難以理解的問題。

  另外,本例中Gun的color是private,AWM的color是public。假如把Gun的color定義為public,AWM的color定義為private,這樣外部就看不見color屬性了,因此都無法使用awm.color來訪問color屬性了。

  事實上,我們在子類中定義和超類同名的屬性,有4種情況:

  • 子類和超類都是成員屬性
  • 子類和超類都是靜態屬性
  • 子類是靜態屬性,超類是成員屬性
  • 子類是成員屬性,超類是靜態屬性

不管是以上哪種情況,都會隱藏超類同名屬性,大家可以編寫代碼自己試驗。在實際應用中,非常不建議這樣編寫代碼。

5.7類型轉換

5.7.1向上轉型

  中國歷史上有一段非常有名的典故:白馬非馬。說的是公孫龍通過一番口才辯論,把白馬不是馬說的頭頭是道。有興趣的朋友可以自行去網上查閱完整的故事。這裡我們想討論的是,AWM是Gun嗎?廢話不多說,直接用代碼驗證:

public class ExtendTest {  
    public static void main(String[] args) {  
        Gun gun = new AWM("awm", "綠色", "4倍鏡");  
    }  
}  

我們發現,Gun類型的變量是可以引用一個AWM對象的。也就是說AWM是Gun,換句話說,也就是超類變量是可以引用子類對象的。其實理由很充分,因為對外部來說,AWM擁有全部Gun類的可見屬性和方法,外部可以用變量gun調用所有的Gun類的可見屬性和方法。在Java中,我們把這種子類對象賦值給超類變量的操作稱為向上轉型。向上轉型是安全的。

       但是這裡要注意,當AWM對象轉型為Gun後,對外部來說,就看不見AWM類中特有的屬性和方法了,因此變量gun將無法調用AWM可見的屬性和方法。例如AWM的安裝瞄準器的方法:

// 安裝瞄準器  
    public void loadGunsight(String gunsight) {  
        this.gunsight = gunsight;  
    }  

採用下面語句調用將會報錯:

gun.loadGunsight("4倍鏡"); 

雖然上面我們說向上轉型是安全的,但是實際上在數組的運用中會有一個坑,我們看如下代碼:

1 public class ExtendTest {  
2     public static void main(String[] args) {  
3         AWM[] awms = new AWM[2];  
4         Gun[] guns = awms;// 將一個AWM數組賦值給Gun數組變量  
5         guns[0] = new Gun("槍", "白色");  
6         awms[0].loadGunsight("4倍鏡");  
7     }  
8 }  

我們把一個AWM數組向上轉型賦值給一個Gun數組,然後把Gun數組的第一個元素引用一個Gun對象。我們通過內存分析,知道awms[0]和guns[0]都指向了同一個Gun對象實例,看起來好像我們通過一個合理的手段進行了一項不合理的操作,因為我們做到了「槍是狙擊槍」的操作,結果運行到第6句的時候將會報錯:

Exception in thread "main" java.lang.ArrayStoreException: com.javadss.javase.ch05.Gun  
    at com.javadss.javase.ch05.test.ExtendTest.main(ExtendTest.java:16)  

因此我們在使用數組的時候,要謹慎的賦值,需要牢記數組元素的類型,盡量避免以上這種情況發生。

5.7.2向下轉型

       在學習基本數據類型的時候,我們學習過強制類型轉換,例如可以把一個double變量強制轉換為int型:

double d = 1.5d;  
int i = (int) d;  

實際上,對象類型可以採用類似的方式進行強制類型轉換,只不過如果我們胡亂進行強制類型轉換沒有意義,一般我們需要用到對象的強制類型轉換的場景是:我們有時候為了方便或其他原因,暫時把一個子類對象賦值給超類變量(如上節中的例子),但是因為某些原因我們又想復原成子類,這個時候就需要用到強制類型轉換了,我們把這種超類類型強制轉換為子類類型的操作稱為向下轉型。例如:

Gun gun = new AWM("awm", "綠色", "4倍鏡");  
AWM awm = (AWM) gun;  

這種向下轉型是不安全的,因為編譯器無法確定轉型是否正確,只有在運行時才能真正判斷是否能夠向下轉型,如果轉型失敗,虛擬機將會拋出java.lang.ClassCastException異常。為了避免出現這種異常,我們可以在轉型之前預先判斷是否能夠轉型,Java給我們提供了instanceof關鍵字。例如:

1 public static void main(String[] args) {  
2         Gun gun = new Gun("awm", "綠色");  
3         if (gun instanceof AWM) {  
4             AWM awm = (AWM) gun;  
5         }  
6     }  

上面代碼第4句將不會執行。對於語句:a Instanceof B,實際上判斷的是a是否為B類型或B的子孫類類型,如果是則返回true,否則返回false。如果a為null,該語句會返回false而不是報錯。

       在實際工作運用中,筆者並不推薦大量使用向下轉型操作,因為大部分的向下轉型都是因為超類的設計問題而導致的,這個話題在這就不展開討論了,等大家經驗豐富後,自然會體會到。