多型與 is-a 關係


在Java中,子類別只能繼承一個父類別,繼承除了可避免類別間重複的實作定義外,還有個重要的關係,那就是子類別與父類別間會有is-a的關係,中文稱為「是一種」的關係,這是什麼意思?以先前範例來說,SwordsMan繼承了Role,所以SwordsMan是一種RoleSwordsMan is a Role),Magician繼承了Role,所以Magician是一種RoleMagician is a Role)。

為何要知道繼承時,父類別與子類別間會有「是一種」的關係?因為要開始理解多型(Polymorphism),必須先知道你操作的物件是「哪一種」東西!

來看實際的例子,以下的程式碼片段,相信你現在沒有問題地看懂,而且知道可以通過編譯:

SwordsMan swordsMan = new SwordsMan();
Magician magician = new Magician();

那你知道以下的程式片段也可以通過編譯嗎?

Role role1 = new SwordsMan();
Role role2 = new Magician();

那你知道以下的程式片段為何無法通過編譯呢?

SwordsMan swordsMan = new Role();  
Magician magician = new Role();

編譯器就是語法檢查器,要知道以上程式片段為何可以通過編譯,為何無法通過編譯,就是將自己當作編譯器,檢查語法的邏輯是否正確,方式是從=號右邊往左讀:右邊是不是一種左邊呢(右邊型態是不是左邊型態的子類別)?

運用is a關係判斷語法正確性


從右往左讀,SwordsMan是不是一種Role呢?是的!所以編譯通過。Magician是不是一種Role呢?是的!所以編譯通過。同樣的判斷方式,可以知道為何以下編譯失敗:

SwordsMan swordsMan = new Role();   // Role是不是一種SwordsMan?
Magician magician = new Role();     // Role是不是一種Magician?

編譯器認為第一行,Role不一定是一種SwrodsMan,所以編譯失敗,對於第二行,編譯器認為Role不一定是一種Magician,所以編譯失敗。繼續把自己當成編譯器,再來看看以下的程式片段是否可以通過編譯:

Role role1 = new SwordsMan();
SwordsMan swordsMan = role1;

這個程式片段最後會編譯失敗,先從第一行看,SwordsMan是一種Role,所以這行可以通過編譯。編譯器檢查這類語法,一次只看一行,就第二行而言,編譯器看到role1Role宣告的名稱,於是檢查Role是不是一種SwordsMan,答案是不一定,所以編譯失敗在第二行!

編譯器會檢查父子類別間的「是一種」關係,如果你不想要編譯器囉嗦,可以叫它住嘴:

Role role1 = new SwordsMan();
SwordsMan swordsMan = (SwordsMan) role1;

對於第二行,原本編譯器想囉嗦地告訴你,Role不一定是一種SwordsMan,但你加上了(SwordsMan)讓它住嘴了,因為這表示,你就是要讓Role扮演(CAST)SwrodsMan,既然你都明確要求編譯器別囉嗦了,編譯器就讓這段程式碼通過編譯了,不過後果得自行負責!

以上面這個程式片段來說,role1確實參考至SwordsMan實例,所以在第二行讓SwordsMan實例扮演SwordsMan並沒有什麼問題,所以執行時期並不會出錯。

判斷是否可扮演(CAST)成功


但是以下的程式片段,編譯可以成功,但執行時期會出錯:

Role role2 = new Magician();
SwordsMan swordsMan = (SwordsMan) role2;

對於第一行,Magician是一種Role,可以通過編譯,對於第二行,role2Role型態,編譯器原本認定Role不一定是一種SwordsMan而想要囉嗦,但是你明確告訴編譯器,就是要讓Role扮演為SwordsMan,所以編譯器就讓你通過編譯了,不過後果自負,實際上,role2參考的是Magician,你要讓魔法師假扮為劍士?這在執行上會是個錯誤,JVM會拋出java.lang.ClassCastException

扮演(CAST)失敗,執行時拋出ClassCastException


使用有一種(is-a)原則,你就可以判斷,何時編譯成功,何時編譯失敗,以及將扮演(CAST)看作是叫編譯器住嘴語法,並留意參考的物件實際型態,你就可以判斷何時扮演成功,何時會拋出ClassCastException。例如以下編譯成功,執行也沒問題:

SwordsMan swordsMan = new SwordsMan();
Role role = swordsMan;    // SwordsMan是一種Role

以下程式片段會編譯失敗:

SwordsMan swordsMan = new SwordsMan();
Role role = swordsMan;         // SwordsMan是一種Role,這行通過編譯
SwordsMan swordsMan = role;   // Role不一定是一種SwordsMan,編譯失敗

以下程式片段編譯成功,執行時也沒問題:

SwordsMan swordsMan = new SwordsMan();
Role role = swordsMan;      // SwordsMan是一種Role,這行通過編譯
// 你告訴編譯器要讓Role扮演SwordsMan,以下這行通過編譯
SwordsMan swordsMan = (SwordsMan) role;  // role參考SwordsMan實例,執行成功

以下程式片段編譯成功,但執行時拋出ClassCastException

SwordsMan swordsMan = new SwordsMan();
Role role = swordsMan;     // SwordsMan是一種Role,這行通過編譯
// 你告訴編譯器要讓Role扮演Magician,以下這行通過編譯
Magician magician = (Magician) role; // role參考SwordsMan實例,執行失敗

經過以上這一連串的語法測試,好像只是在玩弄語法?不!你懂不懂以上這些東西,牽涉到寫出來的東西有沒有彈性、好不好維護的問題!

有這麼嚴重嗎?來出個題目給你吧!請設計static方法,顯示所有角色的血量!OK!上一章剛學過如何定義方法,有的人會撰寫以下的方法定義:

public static void showBlood(SwordsMan swordsMan) {
    System.out.printf("%s 血量 %d%n",
           swordsMan.getName(), swordsMan.getBlood());
}
public static void showBlood(Magician magician) {
    System.out.printf("%s 血量 %d%n",
           magician.getName(), magician.getBlood());
}

分別為SwordsManMagician設計showBlood()同名方法,這是重載方法的運用,如此就可以如下呼叫:

showBlood(swordsMan);    // swordsMan是SwordsMan型態
showBlood(magician);     // magician是Magician型態

現在的問題是,目前你的遊戲中是只有SwordsManMagician兩個角色,如果有一百個角色呢?重載出一百個方法?這種方式顯然不可能!重載的運用場合,是不同型態會有不同流程實作,如果發現重載的方法流程是類似的,設計上就有檢討的可能性。

如果所有角色都是繼承自Role,而且你知道這些角色都是一種Role,你就可以如下設計方法並呼叫:

package cc.openhome;

public class RPG {
    public static void main(String[] args) {
        SwordsMan swordsMan = new SwordsMan();
        swordsMan.setName("Justin");
        swordsMan.setLevel(1);
        swordsMan.setBlood(200);

        Magician magician = new Magician();
        magician.setName("Monica");
        magician.setLevel(1);
        magician.setBlood(100);
        
        showBlood(swordsMan);
        showBlood(magician);
    }

    static void showBlood(Role role) {
        System.out.printf("%s 血量 %d%n",
                role.getName(), role.getBlood());
    }
}

在這邊僅定義了一個showBlood()方法,參數宣告為Role型態,第一次呼叫showBlood()時傳入了SwordsMan實例,這是合法的語法,因為SwordsMan是一種Role,第一次呼叫showBlood()時傳入了Magician實例也是可行,因為Magician是一種Role。執行的結果如下:

Justin 血量 200
Monica 血量 100

這樣的寫法好處為何?就算有100種角色,只要它們都是繼承Role,都可以使用這個方法顯示角色的血量,而不需要像先前重載的方式,為不同角色寫100個方法,多型的寫法顯然具有更高的可維護性。

什麼叫多型?以抽象講法解釋,就是使用單一介面操作多種型態的物件!若用以上的範例來理解,在showBlood()方法中,既可以透過Role型態操作SwordsMan物件,也可以透過Role型態操作Magician物件。

稍後會學到Java中interface的使用,在多型定義中,使用單一介面操作多種型態的物件,這邊的介面並不是專指Java中的interface,而是指物件的公開行為,以Java具體而言,就是物件上可操作的方法。