Minghsin University of Science and Technology
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try a Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

1

Local AI

--

Uedu Code

--

明新科大 AQI 70 24°C PM2.5 19
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 10

多型

動態繫結、覆寫與多載的差別、向上轉型與 instanceof
Interaction實作 25 minute(s)

Learning Objectives

  • 說明父類別型別的變數可以指向子類別物件(向上轉型),以及這樣做的好處
  • 說明動態繫結:呼叫覆寫的方法時,執行的是物件實際類別的版本
  • 用父類別型別的陣列或 List 統一處理不同的子類別物件
  • 分辨多載(編譯時依變數的宣告型別選擇)與覆寫(執行時依物件的類別選擇)
  • 說明編譯器只看變數型別;用 instanceof 檢查後再向下轉型,避免 ClassCastException

為什麼需要多型

第 09 課學了繼承與覆寫。假設 Dog 和 Cat 都繼承 Animal,各自覆寫 speak(),現在要寫一個寵物旅館的程式, 裡面有狗、貓、兔子……如果每一種動物都要寫一段自己的程式碼:

for (Dog d : dogs) d.speak();
for (Cat c : cats) c.speak();
for (Rabbit r : rabbits) r.speak();   // 每多一種動物,就要多一段

多型(polymorphism)讓你只寫一段:

Animal[] pets = { new Dog("Lucky"), new Cat("Mimi"), new Rabbit("Bunny") };
for (Animal p : pets) {
    p.speak();   // 同一行程式,狗會汪汪、貓會喵喵
}

p 的型別是 Animal,可是 p.speak() 執行的是物件實際類別的版本。之後新增一種動物,只要寫一個新的子類別, 這段迴圈完全不用改。這就是多型的價值:呼叫端只認得父類別,實際行為由各個子類別決定。

本課的關鍵是分清楚兩件事:變數的宣告型別(寫在變數前面的那個型別,編譯器看的)與物件的實際類別(new 的那個類別,執行時才知道)。

向上轉型與動態繫結

子類別物件「是一種」父類別(Dog is an Animal),所以可以直接放進父類別型別的變數,不需要寫轉型:

Animal a = new Dog("Lucky");   // 向上轉型(upcasting),自動發生

變數 a 的宣告型別是 Animal,指向的物件實際類別是 Dog。這時呼叫 a.speak():

  • 編譯時:編譯器檢查 Animal 有沒有 speak() 方法。有,所以可以編譯
  • 執行時:JVM 看 a 實際指向的物件是 Dog,於是執行 Dog 覆寫的 speak()

這種「執行時才依物件決定要呼叫哪個版本」的機制叫做動態繫結(dynamic binding)。 下面的動畫裡,注意右邊物件的類別名稱,以及呼叫 speak() 時新框架的名稱是 Dog.speak() 還是 Cat.speak()。

同一個變數,不同的行為

a 從頭到尾都是 Animal 型別的變數。第 35 行與第 37 行是一模一樣的 a.speak(),但因為 a 指向的物件不同,進入的框架分別是 Dog.speak() 和 Cat.speak();Animal 自己的 speak()(第 9 行)一次也沒有執行。

用父類別陣列統一處理

多型最常見的用法,是把不同子類別的物件放進同一個父類別型別的陣列或 List,再用一個迴圈處理:

Shape[] shapes = { new Rect(2, 3), new Circle(1) };        // 陣列
List<Shape> list = List.of(new Rect(2, 3), new Circle(1)); // 也可以用 List(第 13 課)

for (Shape s : shapes) {
    total += s.area();   // 每個物件用自己的方式計算面積
}

迴圈裡只寫 s.area(),不需要「如果是長方形就……、如果是圓形就……」。新增一種形狀時,只要寫一個覆寫 area() 的子類別, 計算總面積的程式不必修改。父類別 Shape 的 area() 回傳 0,只是為了讓 Shape 型別的變數可以呼叫 area(); 第 11 課會介紹更好的寫法:抽象方法與介面。

計算各種形狀的總面積

右邊的 Shape[] 陣列裡有三個物件:兩個 Rect、一個 Circle。迴圈每一圈 s 指向陣列的下一個物件,第 29 行的 s.area() 依序進入 Rect.area()、Circle.area()、Rect.area()。

想一想:動態繫結

1.

下面的程式會印出什麼?

class Animal {
    String speak() { return "..."; }
}

class Cat extends Animal {
    @Override
    String speak() { return "喵"; }
}

public class Main {
    public static void main(String[] args) {
        Animal a = new Cat();
        Animal b = new Animal();
        System.out.println(a.speak() + b.speak());
    }
}
2.

父類別的方法裡呼叫了另一個被覆寫的方法。下面的程式會印出什麼?

class A {
    String name() { return "A"; }
    void hello() { System.out.println("我是 " + name()); }
}

class B extends A {
    @Override
    String name() { return "B"; }
}

public class Main {
    public static void main(String[] args) {
        A x = new B();
        x.hello();
    }
}
第 1 次作答

多載和覆寫不一樣

覆寫(override)和多載(overload)的名字很像,選擇方法的時機卻完全不同:

  • 覆寫:子類別重新定義父類別同名、同參數的方法。呼叫哪個版本,執行時看物件實際的類別決定(動態繫結)
  • 多載:同一個類別裡有好幾個同名、參數不同的方法(第 07 課)。呼叫哪一個,編譯時就依引數「宣告的型別」決定好了

所以,把同一個 Dog 物件分別用 Dog 型別和 Animal 型別的變數傳給多載的方法,會選到不同的多載版本, 即使兩個變數指向的是同一個物件:

static void describe(Animal a) { … }
static void describe(Dog d) { … }

Dog d = new Dog();
Animal a = d;      // 同一個物件
describe(d);       // 編譯器看到 Dog → describe(Dog)
describe(a);       // 編譯器只看到 Animal → describe(Animal)

同一個物件,兩種多載

d 和 a 的箭頭指向右邊同一個 Dog 物件。第 24 行進入 describe(Dog d),第 25 行卻進入 describe(Animal a):多載只看變數的宣告型別。可是兩個 describe 裡呼叫 sound() 時,進入的都是 Dog.sound():覆寫看的是物件。

覆寫與多載的比較

覆寫(override) 多載(overload)
發生在哪裡 子類別與父類別之間 同一個類別裡(也可以加上繼承來的方法)
方法名稱 相同 相同
參數 必須相同(個數、型別、順序) 必須不同
回傳型別 相同,或是原本回傳型別的子類別 可以不同(但只有回傳型別不同不算多載,會編譯錯誤)
存取修飾字 不能比父類別更嚴格(父類別 public,子類別不能改成 private) 沒有限制
何時決定呼叫哪個 執行時,看物件實際的類別 編譯時,看引數的宣告型別
@Override 標註 建議加上,寫錯參數時編譯器會提醒你 不適用

@Override 很值得養成習慣:如果你以為在覆寫,其實把參數型別寫錯了(例如把 equals(Object o) 寫成 equals(Dog d)), 沒有標註時這只是一個新的多載方法,程式能編譯,行為卻不是你要的;加上 @Override,編譯器會直接報錯。

static 方法不會動態繫結。 子類別寫一個同名的 static 方法,叫做「隱藏」(hiding),呼叫哪一個由變數的宣告型別決定。 多型只適用於一般的實例方法;欄位也沒有多型,存取欄位同樣看變數的宣告型別。

編譯器只看變數型別:向下轉型與 instanceof

向上轉型有一個代價:透過父類別型別的變數,只能呼叫父類別有宣告的方法。即使物件其實是 Dog,下面也無法編譯:

Animal a = new Dog("Lucky");
a.fetch();   // 編譯錯誤:cannot find symbol,Animal 沒有 fetch()

編譯器不會去猜 a 執行時指向什麼,它只看 a 的宣告型別 Animal。真的需要呼叫子類別才有的方法時,要向下轉型(downcasting), 把參照轉回子類別型別:

Dog d = (Dog) a;   // 告訴編譯器:我確定 a 指向的是 Dog
d.fetch();

向下轉型是你對編譯器的保證,JVM 執行時會檢查。如果物件其實不是 Dog,會拋出 ClassCastException。 所以轉型前先用 instanceof 檢查物件是不是某個類別(或它的子類別):

if (a instanceof Dog) {          // 傳統寫法:檢查之後還要再轉型一次
    Dog d = (Dog) a;
    d.fetch();
}

if (a instanceof Dog d) {        // 樣式比對(Java 16 起):檢查通過時,直接得到轉型好的變數 d
    d.fetch();
}

第二種寫法叫做 instanceof 的樣式比對(pattern matching),在 Java 16 成為正式功能。變數 d 只在檢查成立的範圍裡可以使用。 a 是 null 時,instanceof 的結果是 false,不會拋出例外。

要注意:程式裡一長串 instanceof 判斷,常常表示應該把行為寫成覆寫的方法、交給動態繫結處理。instanceof 適合用在少數真的需要子類別特有功能的地方。

instanceof 檢查與轉型失敗

迴圈裡用 instanceof 樣式比對:第 21 行檢查成立時,框架裡多出一個變數 d,它和 p 指向同一個物件,型別卻是 Dog,所以可以呼叫 fetch()。第 28 行沒有先檢查就把 Cat 轉成 Dog,會拋出 ClassCastException,被 catch 接住。

想一想:多載、編譯與轉型

1.

下面的程式會印出什麼?

public class Main {
    static void show(Object o) { System.out.println("Object"); }
    static void show(String s) { System.out.println("String"); }

    public static void main(String[] args) {
        Object o = "hello";
        show(o);
    }
}
2.

下面的程式會怎樣?

class Animal { }

class Dog extends Animal {
    void fetch() { System.out.println("撿球"); }
}

public class Main {
    public static void main(String[] args) {
        Animal a = new Dog();
        a.fetch();
    }
}
3.

下面的程式會怎樣?

class Animal { }
class Dog extends Animal { }

public class Main {
    public static void main(String[] args) {
        Animal a = new Animal();
        Dog d = (Dog) a;
        System.out.println("轉型成功");
    }
}
4.

下面的程式會印出什麼?

class Animal { }
class Dog extends Animal { }
class Puppy extends Dog { }

public class Main {
    public static void main(String[] args) {
        Animal[] list = { new Dog(), new Animal(), new Puppy(), null };
        int n = 0;
        for (Animal x : list) {
            if (x instanceof Dog) n++;
        }
        System.out.println(n);
    }
}
第 1 次作答

動手寫:計算薪資總額

公司有一般員工、主管與實習生。完成 Manager 和 Intern 的 pay() 覆寫(主管 50000、實習生 20000),再在 main 用一個迴圈走訪 Employee[],印出每個人的薪資並加總。不要用 instanceof,讓動態繫結替你選擇。

你的輸出
預期輸出

重點回顧

  • 子類別物件可以放進父類別型別的變數(向上轉型),不需要寫轉型
  • 呼叫覆寫的方法時,執行的是物件實際類別的版本(動態繫結),這個選擇在執行時發生
  • 把不同子類別的物件放進父類別型別的陣列或 List,用同一段程式處理;新增子類別時呼叫端不用改
  • 多載在編譯時依引數的宣告型別選擇;同一個物件用不同型別的變數傳入,可能選到不同的多載
  • 編譯器只看變數的宣告型別:父類別變數不能呼叫子類別才有的方法
  • 向下轉型 (Dog) a 在執行時檢查,物件不是 Dog 就拋出 ClassCastException;先用 instanceof 檢查,Java 16 起可寫 a instanceof Dog d 一次完成檢查與轉型
這一課完成了!
Next lesson:介面與抽象類別
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。