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

Java大失叔發表於2020-10-05

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而不是報錯。

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

相關文章