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()。
想一想:動態繫結
下面的程式會印出什麼?
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());
}
}
父類別的方法裡呼叫了另一個被覆寫的方法。下面的程式會印出什麼?
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();
}
}
多載和覆寫不一樣
覆寫(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 接住。
想一想:多載、編譯與轉型
下面的程式會印出什麼?
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);
}
}
下面的程式會怎樣?
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();
}
}
下面的程式會怎樣?
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("轉型成功");
}
}
下面的程式會印出什麼?
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);
}
}
動手寫:計算薪資總額
公司有一般員工、主管與實習生。完成 Manager 和 Intern 的 pay() 覆寫(主管 50000、實習生 20000),再在 main 用一個迴圈走訪 Employee[],印出每個人的薪資並加總。不要用 instanceof,讓動態繫結替你選擇。
重點回顧
- 子類別物件可以放進父類別型別的變數(向上轉型),不需要寫轉型
- 呼叫覆寫的方法時,執行的是物件實際類別的版本(動態繫結),這個選擇在執行時發生
- 把不同子類別的物件放進父類別型別的陣列或 List,用同一段程式處理;新增子類別時呼叫端不用改
- 多載在編譯時依引數的宣告型別選擇;同一個物件用不同型別的變數傳入,可能選到不同的多載
- 編譯器只看變數的宣告型別:父類別變數不能呼叫子類別才有的方法
- 向下轉型
(Dog) a在執行時檢查,物件不是Dog就拋出ClassCastException;先用instanceof檢查,Java 16 起可寫a instanceof Dog d一次完成檢查與轉型