Java SE 6 技術手冊

8.2 多型(Polymorphism)

多型操作指的是使用同一個操作介面,以操作不同的物件實例,多型操作在物件導向上是為了降低對操作介面的依賴程度,進而增加程式架構的彈性與可維護性。多型操作是物件導向上一個重要的特性,這個小節會介紹多型的觀念,以及「抽象類別」(Abstract)與「介面」(Interface)應用的幾個實例。

8.2.1 多型導論

先來解釋一下這句話:多型操作指的是使用同一個操作介面,以操作不同的物件實例。首先來解釋一下何謂操作介面,就 Java 程式而言,操作介面通常指的就是您在類別上定義的公開方法,透過這些介面,您可以對物件實例加以操作。

透過對應介面來操作物件

圖 8.1 透過對應介面來操作物件

圖 8.1 中上方的平面相當於類別定義的介面,方塊上凸出的部份相當於物件實例上可操作的方法,要操作物件上的方法必須使用對應型態的操作介面,而如果您使用不正確的類別型態來轉換物件的操作介面,則會發生 java.lang.ClassCastException 例外,例如:

Class1 c1 = new Class1();
Class2 c2 = (Class2) c1; // 丟出ClassCastException例外

不正確的型態轉換會丟出 ClassCastException 例外

圖 8.2 不正確的型態轉換會丟出 ClassCastException 例外

回到多型操作的解釋上,現在假設 Class1 上定義了 doSomething() 方法,而 Class2 上也定義了 doSomething() 方法,而您定義了兩個 execute() 方法來分別操作 Class1 與 Class2 的實例:

public void execute(Class1 c1) {
    c1.doSomething();
}
public void execute(Class2 c2) {
    c2.doSomething();
}

很顯然的,您的程式中 execute() 分別依賴了 Class1 與 Class2 兩個類別,與其依賴兩個類別,不如定義一個父類別 ParentClass 類別,當中定義有 doSomething(),並讓 Class1 與 Class2 都繼承 ParentClass 類別並重新定義自己的 doSomething() 方法,如此您就可以將程式中的 execute() 改成:

public void execute(ParentClass c) {
    c.doSomething();
}

這是可以行的通的,因為介面與實例上的操作方法是一致的,如圖 8.3 所示。

Class1 與 Class2 是 ParentClass 的子類,可以透過 ParentClass 來操作

圖 8.3 Class1 與 Class2 是 ParentClass 的子類,可以透過 ParentClass 來操作

這就是多型操作所指的,使用同一個操作介面,以操作不同的物件實例。由於從分別依賴 Class1 與 Class2 改為只依賴 ParentClass,程式對個別物件的依賴程式降低了,日後在修改、維護或調整程式時的彈性也增加了,這是繼承上多型操作的一個實例。

以上是對多型的一個簡介,實際上在設計並不依賴於具體類別,而是依賴於抽象,Java 中在實現多型時,可以讓程式依賴於「抽象類別」(Abstract class)或是「介面」(Interface),雖然兩者都可以實現多型操作,但實際上兩者的語義與應用場合是不同的,接下來我將分別介紹兩者的使用方式與應用實例。

8.2.2 抽象類別(Abstract class)

在 Java 中定義類別時,可以僅宣告方法名稱而不實作當中的邏輯,這樣的方法稱之為「抽象方法」(Abstract method),如果一個方法中包括了抽象方法,則該類別稱之為「抽象類別」(Abstract class),抽象類別是擁有未實作方法的類別,所以它不能被用來生成物件,它只能被繼承擴充,並於繼承後實作未完成的抽象方法,在 Java 中要宣告抽象方法與抽象類別,您要使用 "abstract" 關鍵字,以下舉個實際的例子,先假設您設計了兩個類別:ConcreteCircle 與 HollowCircle。

public class ConcreteCircle {
    private double radius;
    public void setRedius(int radius) { this.radius = radius; }
    public double getRadius() { return radius; }
    public void render() {
        System.out.printf("畫一個半徑 %f 的實心圓\n", getRadius());
    }
}

public class HollowCircle {
    private double radius;
    public void setRedius(int radius) { this.radius = radius; }
    public double getRadius() { return radius; }
    public void render() {
        System.out.printf("畫一個半徑 %f 的空心圓\n", getRadius());
    }
}

顯然的,這兩個類別除了 render() 方法的實作內容不同之外,其它的定義是一樣的,而且這兩個類別所定義的顯然都是「圓」的一種類型,您可以定義一個抽象的 AbstractCircle 類別,將 ConcreteCircle 與 HollowCircle 中相同的行為與定義提取(Pull up)至抽象類別中,如範例 8.15 所示。

範例 8.15 AbstractCircle.java

public abstract class AbstractCircle {
    protected double radius;

    public void setRedius(int radius) { this.radius = radius; }
    public double getRadius() { return radius; }

    public abstract void render();
}

注意到在類別宣告上使用了 "abstract" 關鍵字,所以 AbstractCircle 是個抽象類別,它只能被繼承,而 render() 方法上也使用了 "abstract" 關鍵字,表示它是個抽象方法,目前還不用實作這個方法,繼承了 AbstractCircle 的類別必須實作 render() 方法,接著您可以讓 ConcreteCircle 與 HollowCircle 類別繼承 AbstractCircle 方法並實作 render() 方法,如範例 8.16、範例 8.17 所示範的。

範例8.16 ConcreteCircle.java

public class ConcreteCircle extends AbstractCircle {
    public ConcreteCircle() {}

    public ConcreteCircle(double radius) {
        this.radius = radius;
    }

    public void render() {
        System.out.printf("畫一個半徑 %f 的實心圓\n", getRadius());
    }
}

範例 8.17 HollowCircle.java

public class HollowCircle extends AbstractCircle {
    public HollowCircle() {}

    public HollowCircle(double radius) {
        this.radius = radius;
    }

    public void render() {
        System.out.printf("畫一個半徑 %f 的空心圓\n", getRadius());
    }
}

由於共同的定義被提取至 AbstractCircle 類別中,並於擴充時繼承了下來,所以在 ConcreteCircle 與 HollowCircle 中不用重複定義,只要定義個別對 render() 的處理方式就行了,而由於 ConcreteCircle 與 HollowCircle 都是 AbstractCircle 的子類別,因而可以使用 AbstractCircle 上有定義的操作介面,來操作子類別實例上的方法,如範例 8.18 所示範的。

範例 8.18 CircleDemo.java

public class CircleDemo {
    public static void main(String[] args) {
        renderCircle(new ConcreteCircle(3.33));
        renderCircle(new HollowCircle(10.2));
    }

    public static void renderCircle(AbstractCircle circle) {
        circle.render();
    }
}

由於 AbstractCircle 上有定義 render() 方法,所以可用於操作子類別實例的方法,這是繼承上多型操作的一個實例應用,執行結果如下所示:

畫一個半徑 3.330000 的實心圓
畫一個半徑 10.200000 的空心圓

以上所舉的例子是 8.2.1 多型導論的具體例子,對 renderCircle() 方法來說,它只需依賴 AbstractCircle 類別,而不用個別為 ConcreteCircle 與 HollowCircle 類別撰寫個別的 renderCircle() 方法。

良葛格的話匣子 將抽象類別的名稱加上 Abstract 作為開頭,可表明這是個抽象類別,用意在提醒開發人員不要使用這個類別來產生實例(事實上也無法產生實例)。

在程式撰寫的過程中會像這邊所介紹的,將已有的程式加以「重構」(Refactor),讓物件職責與程式架構更有彈性,事實上這邊的例子就使用了重構中的「Pull up field」與「Pull up method」方法,重構手法是程式開發的經驗集成,如果您對這些經驗有興趣,建議您看看這本書:

Refactoring: Improving the Design of Existing Code by Martin Fowler, Kent Beck, John Brant, William Opdyke, don Roberts

8.2.3 抽象類別應用

為了加深您對抽象類別的瞭解與應用方式,再來舉一個例子說明抽象類別,在範例 8.19中 定義了一個簡單的比大小遊戲抽象類別。

範例 8.19 AbstractGuessGame.java

public abstract class AbstractGuessGame {
    private int number;

    public void setNumber(int number) {
        this.number = number;
    }

    public void start() {
        showMessage("歡迎");
        int guess = 0;
        do {
            guess = getUserInput();
            if(guess > number) {
                showMessage("輸入的數字較大");
            }
            else if(guess < number) {
                showMessage("輸入的數字較小");
            }
            else {
                showMessage("猜中了");
            }
        } while(guess != number);
    }

    protected abstract void showMessage(String message);
    protected abstract int getUserInput();
}

這是個抽象類別,您在類別定義了 start() 方法,當中先實作比大小遊戲的基本規則,然而並不實作如何取得使用者輸入及訊息的顯示方式,只先定義了抽象方法 showMessage() 與 getUserInput(),使用 AbstractGuessGame 類別的辦法是擴充它,並實作當中的抽象方法,例如您可以實作一個簡單的文字介面遊戲類別,如範例 8.20 所示。

範例 8.20 TextModeGame.java

import java.util.Scanner;

public class TextModeGame extends AbstractGuessGame {
    private Scanner scanner;

    public TextModeGame() {
        scanner = new Scanner(System.in);
    }

    protected void showMessage(String message) {
        for(int i = 0; i < message.length()*2; i++) {
            System.out.print("*");
        }
        System.out.println("\n"+ message);
        for(int i = 0; i < message.length()*2; i++) {
            System.out.print("*");
        }
    }

    protected int getUserInput() {
        System.out.print("\n輸入數字:");
        return scanner.nextInt();
    }
}

範例 8.21 是啟動遊戲的示範類別。

範例 8.21 GuessGameDemo.java

public class GuessGameDemo {
    public static void main(String[] args) {
        AbstractGuessGame guessGame = 
                    new TextModeGame();
        guessGame.setNumber(50);
        guessGame.start();
    }
}

執行結果:

****
歡迎
****
輸入數字:10
**************
輸入的數字較小
**************
輸入數字:50
******
猜中了
******

如果您想要實作一個有視窗介面的比大小遊戲,則您可以擴充 AbstractGuessGame 並在抽象方法 showMessage() 與 getUserInput() 中實作有視窗介面的訊息顯示;藉由在抽象類別中先定義好程式的執行流程,並將某些相依方法留待子類別中執行,這是抽象類別的應用場合之一。

良葛格的話匣子 事實上這邊的例子是「Template Method 模式」的一個實例,Template Method 模式是Gof(Gang of Four)設計模式(Design Pattern)名書中23種模式中的一個,建議您在具備基本的物件導向觀念之後看看設計模式的相關書籍,可以增加您在物件導向程式設計上的功力,Gof 設計模式書是:

Design Patterns Elements of Reusable Object-Oriented Software by Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides

我的網站上也整理有一些設計模式相關資料,並附有簡單的Java程式實例,您也可以一併參考:

8.2.4 介面(Interface)

表面上看來,介面有點像是完全沒有任何方法被實作的抽象類別,但實際上兩者在語義與應用上是有差別的。「繼承某抽象類別的類別必定是該抽象類別的一個子類」,由於同屬一個類型,只要父類別中有定義同名方法,您就可以透過父類別型態來操作子類實例中被重新定義的方法,也就是透過父類別型態進行多型操作,但「實作某介面的類別並不被歸屬於哪一類」,一個物件上可以實作多個介面。

考慮您有一個方法 doRequest(),您事先並無法知道什麼型態的物件會被傳進來,或者是這個方法可以接受任何類型的物件,您想要操作物件上的某個特定方法,例如 doSomething() 方法,問題是傳進來的物件是任意的,除非您定義一個抽象類別並宣告 doSomething() 抽象方法,然後讓所有的類別都繼承這個抽象類別,否則的話您的 doRequest() 方法似乎無法實作出來,實際上這麼作也沒有價值。

介面的目的在定義一組可操作的方法,實作某介面的類別必須實作該介面所定義的所有方法,只要物件有實作某個介面,就可以透過該介面來操作物件上對應的方法,無論該物件實際上屬於哪一個類別,像上面所述及的問題,就要靠要介面來解決。

介面的宣告是使用 "interface" 關鍵字,宣告方式如下:

[public] interface 介面名稱 {
    權限設定 傳回型態 方法(參數列); 
    權限設定 傳回型態 方法(參數列); 
    // .... 
}

在宣告介面時方法上的權限設定可以省略,如果省略的話,預設是 "public",來看宣告介面的一個實例。

範例 8.22 IRequest.java

public interface IRequest {
     public void execute();
}

在定義類別時,您可以使用"implements"關鍵字來指定要實作哪個介面,介面中所有定義的方法都要實作,範例 8.23、範例 8.24 都實作了範例 8.22 的 IRequest 介面。

範例 8.23 HelloRequest.java

public class HelloRequest implements IRequest {
    private String name;

    public HelloRequest(String name) {
        this.name = name;
    }

    public void execute() {
        System.out.printf("哈囉 %s!%n", name);
    }
}

範例 8.24 WelcomeRequest.java

public class WelcomeRequest implements IRequest {
    private String place;

    public WelcomeRequest(String place) {
        this.place = place;
    }

    public void execute() {
        System.out.printf("歡迎來到 %s!%n", place);
    }
}

假設您設計了一個 doRequest()方法,雖然 HelloRequest 與 WelcomeRequest 是兩種不同的類型(類別),但它們都實現了 IRequest,所以 doRequest() 只要知道 IRequest 定義了什麼方法,就可以操作 HelloRequest 與 WelcomeRequest 的實例,而不用知道傳入的物件到底是什麼類別的實例,範例 8.25 是這個觀念的簡單示範。

範例 8.25 RequestDemo.java

public class RequestDemo {
    public static void main(String[] args) {
        for(int i = 0; i < 10; i++) {
            int n = (int) (Math.random() * 10) % 2; // 隨機產生
            switch (n) {
                case 0:
                    doRequest(new HelloRequest("良葛格"));
                    break;
                case 1:
                    doRequest(new WelcomeRequest("Wiki 網站"));
            }
        }
    }

    public static void doRequest(IRequest request) {
        request.execute();
    }
}

在範例 8.25 中傳遞給 doRequest() 的物件是隨機的,雖然實際上傳入的物件並不知道到底是HelloRequest的實例,或者是 WelcomeRequest 的實例,但 doRequest() 知道傳入的物件都有實作 IRequest 介面上的方法,所以執行時就按照 IRequest 定義的方法來操作物件,執行結果如下:

哈囉 良葛格!
哈囉 良葛格!
哈囉 良葛格!
歡迎來到 Wiki 網站!
哈囉 良葛格!
哈囉 良葛格!
歡迎來到 Wiki 網站!
哈囉 良葛格!
歡迎來到 Wiki 網站!
歡迎來到 Wiki 網站!

在 Java 中您可以一次實作多個介面,實作多個介面的方式如下:

public class 類別名稱 implements 介面1, 介面2, 介面3 { 
    // 介面實作
}

當您實作多個介面時,記得必須實作每一個介面中所定義的方法,由於實作了多個介面,所以要操作物件時,必要時必須作「介面轉換」,如此程式才知道如何正確的操作物件,假設 someObject 實作了 ISomeInterface1 與 ISomeInterface2 兩個介面,則您可以如下對物件進行介面轉換與操作:

ISomeInterface1 obj1 = (ISomeInterface1) someObject;
obj1.doSomeMethodOfISomeInterface1();

ISomeInterface2 obj2 = (ISomeInterface2) someObject;
obj2.doSomeMethodOfISomeInterface2();

簡單的說,您每多實作一個介面,就要多遵守一個實作協議。介面也可以進行繼承的動作,同樣也是使用 "extends" 關鍵字來繼承父介面,例如:

public interface 名稱 extends 介面1, 介面2 { 
    // ... 
}

不同於類別一次只能繼承一個父類別,一個介面可以同時繼承多個父介面,實作子介面的類別必須將所有在父介面和子介面中定義的方法實作出來。

良葛格的話匣子 在定義介面名稱時,可以使用 'I' 作為開頭,例如 IRequest 這樣的名稱,表明它是一個介面(Interface)。

事實上範例 8.25 是「Command 模式」的一個簡化實例,同樣的也可以參考 Gof 的設計模式書籍,我的網站上也有 Command 模式的介紹。

在設計上鼓勵依賴關係儘量發生在介面上,雖然抽象類別也可以達到多型操作的目的,但依賴於抽象類別,表示您也依賴於某個類型(類別),而依賴於介面則不管物件實際上是哪個類型(類別)的實例,只要知道物件實作了哪個介面就可以了,比抽象類別的依賴多了一些彈性。