學完本章你將能夠:
解釋多型的核心概念:同一訊息、不同行為,並說明動態繫結的運作方式。
分辨「參考型別決定可呼叫的方法、實際型別決定執行哪個版本」,並知道欄位不具多型。
安全地向下轉型:使用 instanceof 檢查與 Java 16 的模式匹配寫法。
設計抽象類別與抽象方法,用多型陣列統一處理一族物件。
掌握介面的完整能力:常數、抽象方法、default、static 與 private 方法,並解決鑽石衝突。
依照準則在抽象類別與介面之間做出正確選擇。
上一章的繼承讓 |Manager| 重複利用了 |Employee| 的程式碼,但繼承真正的殺手級應用不是省程式碼,而是本章的主角——多型(polymorphism):讓同一個呼叫式,在不同物件身上展現不同行為。有了多型,新增一種員工、一種形狀、一種付款方式時,既有的處理邏輯一行都不用改。這是物件導向「對修改封閉、對擴充開放」精神的技術基礎。
本章由多型出發,依序引入兩種「刻意不完整」的型別:抽象類別(把共同骨架留下、把差異交給子類別)與介面(純粹的能力契約),最後以一個付款系統收尾,為進階篇的策略模式鋪路。
多型:同一訊息,不同行為
向上轉型與動態繫結
把子類別物件指派給父類別參考,稱為向上轉型(upcasting)。因為子類別物件「是一種」父類別物件,這個動作永遠安全、不需要任何轉型語法。魔法發生在呼叫方法的那一刻:
/** 動物父類別:定義共同的訊息介面 */ class Animal /** 發出叫聲:每種動物有自己的版本 */ public void speak() System.out.println("(動物的聲音)");
/** 狗 */ class Dog extends Animal
@Override public void speak() System.out.println("汪汪!");
/** Dog 特有的方法:用 Animal 參考時「看不到」它 */ public void fetch() System.out.println("狗去撿球");
/** 貓 */ class Cat extends Animal @Override public void speak() System.out.println("喵~");
/** 多型:同一訊息,不同行為 */ public class PolymorphismDemo
public static void main(String[] args)
// 向上轉型:把子類別物件交給父類別參考,永遠安全 Animal a1 = new Dog(); Animal a2 = new Cat();
a1.speak(); // 動態繫結:執行 Dog 的版本 a2.speak(); // 動態繫結:執行 Cat 的版本
// a1.fetch(); // 編譯錯誤:Animal 型別沒有宣告 fetch()
// 多型陣列:不同子類別的物件放進同一個陣列統一處理 Animal[] zoo = new Dog(), new Cat(), new Dog() ; for (Animal animal : zoo) animal.speak(); // 每個元素各自執行自己的覆寫版本
汪汪! 喵~ 汪汪! 喵~ 汪汪!
這裡有一組必須分清楚的概念。|a1| 的參考型別(宣告型別)是 |Animal|,實際型別(物件真正的類別)是 |Dog|。兩者分工如下:
參考型別決定「能呼叫什麼」:編譯器只看宣告型別,|Animal| 沒有宣告 |fetch()|,所以 |a1.fetch()| 直接編譯失敗——即使物件其實是 |Dog|。
實際型別決定「執行哪個版本」:執行期 JVM 依照物件真正的類別找出覆寫版本,這個機制稱為動態繫結(dynamic binding)或晚期繫結。
多型陣列的迴圈是這個機制的經典應用:迴圈本體只寫一次 |animal.speak()|,每一輪執行的版本卻各不相同。日後新增 |Bird| 類別,這段迴圈一個字都不用改。
欄位不具多型
動態繫結只發生在實體方法上。欄位(以及 static 方法)採用靜態繫結:編譯期就依照參考型別決定。若子類別宣告了與父類別同名的欄位,兩個欄位會同時存在(稱為欄位遮蔽),存取哪一個由參考型別決定:
/** 父類別:一個欄位、一個方法 */ class Base
String tag = "Base 的欄位";
String describe() return "Base 的方法";
/** 子類別:宣告同名欄位(遮蔽),並覆寫方法 */ class Derived extends Base
String tag = "Derived 的欄位"; // 欄位遮蔽:兩個欄位同時存在
@Override String describe() return "Derived 的方法";
/** 方法看「實際型別」,欄位看「參考型別」 */ public class FieldBinding
public static void main(String[] args)
Base obj = new Derived(); // 向上轉型
// 方法:動態繫結,執行 Derived 覆寫的版本 System.out.println(obj.describe());
// 欄位:靜態繫結,取到 Base 宣告的那一份 System.out.println(obj.tag);
Derived 的方法 Base 的欄位
為什麼欄位不做動態繫結?欄位存取只是讀一塊記憶體,沒有「行為」可言,JVM 若對每次欄位存取都做執行期查找,效能損失巨大且沒有語意上的收益。實務結論有二:第一,永遠不要在子類別宣告與父類別同名的欄位,遮蔽只會製造混亂;第二,欄位維持 private 並透過方法存取(第 1 章的封裝),自然就把「欄位無多型」的問題隔絕在外——因為方法是有多型的。
向下轉型與 instanceof
向上轉型之後,若需要呼叫子類別特有的方法,就得向下轉型(downcasting)回子類別型別。向下轉型必須寫明強制轉型語法,而且有風險:物件的實際型別若不符,執行期會拋出 |ClassCastException|。正確做法是先用 |instanceof| 檢查。Java 16 起的模式匹配 instanceof 更把「檢查、轉型、命名變數」三步併成一步:
/** 向下轉型與 instanceof 檢查(沿用 Animal、Dog、Cat) */ public class CastingDemo
public static void main(String[] args)
Animal pet = new Dog(); // 實際型別是 Dog
// 錯誤示範(已註解):轉成不相干的型別,執行期直接爆炸 // Cat cat = (Cat) pet; // 拋出 ClassCastException
// 傳統寫法:先用 instanceof 檢查,確認後再轉型 if (pet instanceof Dog) Dog dog = (Dog) pet; // 檢查過了,安全 dog.fetch();
// 模式匹配 instanceof(Java 16+):檢查、轉型、命名一氣呵成 if (pet instanceof Dog dog) dog.fetch(); // dog 已經是 Dog 型別,直接可用
// 條件不成立時,模式變數不會生效,區塊不執行 if (pet instanceof Cat cat) cat.speak(); else System.out.println("pet 不是貓,轉型分支未執行");
狗去撿球 狗去撿球 pet 不是貓,轉型分支未執行
一個設計上的提醒:如果你的程式充滿「|instanceof| 加向下轉型」的分支,通常代表多型沒有用好——與其在外面問「你是什麼型別」再分別處理,不如把差異化的行為定義成可覆寫的方法,讓每個類別自己處理。向下轉型應該是偶一為之的工具,不是日常結構。
抽象類別:留白的父類別
有時父類別的某個方法「一定存在,但無法有合理的預設實作」。例如每種形狀都能算面積,但「形狀」本身算不出面積——公式只有具體的圓形、矩形才知道。這時把方法宣告成 |abstract|(只有簽章、沒有本體),並把類別也標成 |abstract|:
/** * 抽象類別 Shape:所有形狀的共同輪廓。 * area() 是抽象方法:宣告「每種形狀都會算面積」,但把公式留給子類別。 */ abstract class Shape
private final String name; // 共同狀態放在父類別
protected Shape(String name) this.name = name;
public String getName() return name;
/** 抽象方法:沒有本體,以分號結束 */ public abstract double area();
/** 一般方法可以呼叫抽象方法:執行期動態繫結到子類別實作 */ public String report() return name + "面積 = " + area();
/** 圓形:面積公式 pi 乘以半徑平方 */ class Circle extends Shape
private final double radius;
public Circle(double radius) super("圓形"); // 把名稱交給父類別保管 this.radius = radius;
@Override public double area() return Math.PI * radius * radius;
/** 矩形:面積公式為寬乘以高 */ class Rectangle extends Shape
private final double width; private final double height;
public Rectangle(double width, double height) super("矩形"); this.width = width; this.height = height;
@Override public double area() return width * height;
注意 |report()| 這個一般方法:它呼叫了自己類別裡「還不存在實作」的 |area()|。這完全合法——執行期 |this| 一定是某個具體子類別的物件,動態繫結會找到正確的公式。「父類別定義流程、子類別填入細節」,這正是進階篇樣板方法模式的雛形。
/** 用多型陣列統一處理所有形狀 */ public class ShapeDemo
public static void main(String[] args)
// Shape s = new Shape("形狀"); // 編譯錯誤:抽象類別不能實體化
Shape[] shapes = new Circle(1.0), new Rectangle(3.0, 4.0), new Circle(2.0) ;
double total = 0; for (Shape s : shapes) System.out.println(s.report()); // 各自執行正確的 area() total += s.area(); // 累加總面積 System.out.printf("總面積 =
圓形面積 = 3.141592653589793 矩形面積 = 12.0 圓形面積 = 12.566370614359172 總面積 = 27.71
抽象類別不能實體化:|new Shape(...)| 是編譯錯誤,因為抽象方法沒有本體,這種物件根本不完整。同時注意連鎖規則:子類別若沒有實作全部的抽象方法,它自己也必須宣告成 |abstract|,否則編譯失敗。抽象類別仍然可以有建構子(如上例的 |protected Shape(String)|)——它不能被 |new|,但子類別建構子需要透過 |super(...)| 呼叫它來初始化共同欄位。
介面:能力的契約
抽象類別仍然是「類別」,受限於單一繼承:一個類別只能有一個直接父類別。但現實中的能力常常是橫切的——會飛的東西不全是鳥,可排序的東西不全是數字。Java 用介面(interface)表達這種「能力契約」:介面只規定「你會做什麼」(方法簽章),不限制「你是什麼」(繼承體系),而且一個類別可以實作(implement)任意多個介面。
初識標準介面:Comparable
標準函式庫大量使用介面表達能力。|Comparable| 介面代表「我能與同類比較大小」——只要實作它,|Arrays.sort| 就知道怎麼排序你的物件:
import java.util.Arrays;
/** * 產品:實作 Comparable,宣告「產品之間可以比較大小」。 * compareTo 回傳負數、零、正數,分別代表小於、等於、大於。 */ class Product implements Comparable<Product>
private final String name; private final int price;
Product(String name, int price) this.name = name; this.price = price;
@Override public int compareTo(Product other) // 委託 Integer.compare:以價格由小到大 return Integer.compare(this.price, other.price);
@Override public String toString() return name + "(" + price + "元)";
/** 實作 Comparable 之後,標準函式庫就知道怎麼排序 */ public class ComparableDemo public static void main(String[] args) Product[] items = new Product("鍵盤", 1200), new Product("滑鼠", 450), new Product("螢幕", 5500) ; Arrays.sort(items); // sort 內部會呼叫 compareTo System.out.println(Arrays.toString(items));
|Arrays.sort| 的作者從未見過 |Product|,卻能正確排序它——因為排序演算法只依賴 |Comparable| 這份契約。這就是「面向介面設計」的威力。與之配套的 |Comparator| 介面則把比較邏輯抽成獨立物件,讓同一種資料能依不同規則排序,第 4 章將深入使用。
介面成員的演進:常數、default、static 與 private 方法
早期的介面只能放抽象方法與常數。Java 8 為了讓標準函式庫能演進(在既有介面加方法,又不弄壞全世界已經寫好的實作類別),引入了 |default| 方法(帶預設實作,實作類別可沿用可覆寫)與 |static| 方法;Java 9 再補上 |private| 方法,供介面內部共用邏輯:
/** 音訊播放器介面:抽象方法、常數、default、static、private 全到齊 */ interface AudioPlayer
/** 介面中的欄位隱含 public static final:這是一個常數 */ int MAX_VOLUME = 100;
/** 抽象方法:實作類別必須提供本體 */ void play(String track);
/** default 方法:附帶預設實作,實作類別可以直接沿用或覆寫 */ default void playAll(String[] tracks) for (String t : tracks) if (isValid(t)) // 呼叫介面自己的 private 方法 play(t); // 呼叫抽象方法:繫結到實作類別
/** static 方法:屬於介面本身,用介面名稱呼叫 */ static int defaultVolume() return MAX_VOLUME / 2;
/** private 方法(Java 9+):介面內部共用的輔助邏輯 */ private boolean isValid(String track) return track != null && !track.isBlank();
/** 老式音響:只實作必要的抽象方法,playAll 直接用預設版本 */ class Stereo implements AudioPlayer @Override public void play(String track) System.out.println("播放中:" + track);
/** 驗證 default、static 方法與介面常數 */ public class AudioDemo
public static void main(String[] args)
Stereo stereo = new Stereo(); // Stereo 沒有寫 playAll:直接繼承介面的預設實作 stereo.playAll(new String[] "夜曲", "青花瓷" );
// static 方法屬於介面本身 System.out.println("預設音量:" + AudioPlayer.defaultVolume()); // 介面常數:隱含 public static final System.out.println("音量上限:" + AudioPlayer.MAX_VOLUME);
播放中:夜曲 播放中:青花瓷 預設音量:50 音量上限:100
介面的方法隱含 |public|,欄位隱含 |public static final|——這也是為什麼介面「沒有實體欄位、沒有建構子」:它不描述狀態,只描述能力。
實作多個介面與鑽石問題
一個類別可以同時實作多個介面:|class A implements X, Y|。當兩個介面提供了同名的 default 方法,就出現所謂的鑽石問題——編譯器無法替你決定繼承哪一份實作,因此強制你自己覆寫,並可用 |介面名.super.方法名()| 指定沿用的版本:
/** 相機能力 */ interface Camera default String describe() return "能拍照";
/** 電話能力 */ interface Phone default String describe() return "能通話";
/** * 智慧型手機同時實作兩個介面: * 兩邊都有 default 的 describe(),編譯器無法決定要繼承哪一個, * 因此「強制」我們自己覆寫來排解衝突。 */ class SmartPhone implements Camera, Phone @Override public String describe() // 「介面名.super.方法名」:明確指定沿用哪個介面的版本 return Camera.super.describe() + ",而且" + Phone.super.describe();
/** 驗證鑽石問題的解法 */ public class DiamondDemo public static void main(String[] args) System.out.println(new SmartPhone().describe());
能拍照,而且能通話
介面與抽象類別的選擇
兩者都能定義「不完整的型別」,選擇準則整理如下:
| 比較面向 | 抽象類別 | 介面 |
|---|---|---|
| 關係語意 | is-a(是一種) | can-do(具備某能力) |
| 繼承數量 | 只能繼承一個 | 可以實作多個 |
| 欄位 | 任意實體欄位 | 只能有常數 |
| 建構子 | 有(供子類別呼叫) | 無 |
| 方法種類 | 一般、抽象皆可 | 抽象、default、static、private |
| 存取修飾詞 | 不限 | 方法隱含 public |
| 典型用途 | 一族類別共享狀態與部分實作 | 跨類別族系的能力契約 |
實務上的判斷順序:預設先考慮介面,因為它不佔用寶貴的單一繼承名額,耦合也最鬆;只有當一族類別需要共享欄位(狀態)或大量共同實作時,才動用抽象類別。兩者也常常合作:抽象類別實作介面、先消化掉一部分方法,讓具體子類別更輕鬆——集合框架中 |AbstractList| 之於 |List| 就是這個模式。
綜合範例:付款系統
最後把本章觀念整合成一個付款系統:|Payment| 介面定義付款能力,兩種付款方式各自實作,結帳櫃台只面對介面。請特別觀察 |checkout| 方法——它是多型參數的典型:任何實作 |Payment| 的物件都能傳入,日後新增付款方式時這個方法完全不必修改。這種「把演算法抽成可替換物件」的手法,正是進階篇策略模式的原型。
/** * 付款方式介面:所有付款方式的共同契約。 * 只規定「能做什麼」,不限制「怎麼做」。 */ interface Payment
/** 執行扣款,回傳是否成功 */ boolean pay(int amount);
/** default 方法:多數付款方式共用的手續費(1 default int fee(int amount) return amount / 100;
/** 信用卡付款 */ class CreditCardPayment implements Payment
private final String cardNumber; // 卡號:實務上必須加密保存
CreditCardPayment(String cardNumber) this.cardNumber = cardNumber;
@Override public boolean pay(int amount) // 只顯示末四碼:資訊安全的基本禮貌 String tail = cardNumber.substring(cardNumber.length() - 4); System.out.println("信用卡(末四碼 " + tail + ")扣款 " + amount + " 元,手續費 " + fee(amount) + " 元"); return true;
/** LINE Pay 付款:覆寫 fee 提供免手續費優惠 */ class LinePayPayment implements Payment
@Override public boolean pay(int amount) System.out.println("LINE Pay 扣款 " + amount + " 元,手續費 " + fee(amount) + " 元"); return true;
/** 行銷活動:LINE Pay 免手續費 */ @Override public int fee(int amount) return 0;
/** 結帳櫃台:只認識 Payment 介面,不關心實際的付款方式 */ public class CheckoutDemo
/** 多型參數:任何實作 Payment 的物件都能傳進來 */ static void checkout(Payment method, int amount) if (method.pay(amount)) System.out.println("結帳完成");
public static void main(String[] args) // 同一個 checkout 方法,搭配不同的付款策略 checkout(new CreditCardPayment("4311-1234-5678-9012"), 1500); checkout(new LinePayPayment(), 800);
信用卡(末四碼 9012)扣款 1500 元,手續費 15 元 結帳完成 LINE Pay 扣款 800 元,手續費 0 元 結帳完成
追蹤一下輸出:信用卡扣款 1500 元時,|fee| 使用介面的 default 實作,\(1500 \div 100 = 15\) 元;LINE Pay 覆寫了 |fee| 回傳 0。同一個 |method.pay(amount)| 呼叫式,兩種物件走出兩條路——本章的一切,濃縮在這兩行輸出裡。
本章小結
多型:同一訊息(方法呼叫),不同物件展現不同行為;基礎是向上轉型與動態繫結。
參考型別決定「能呼叫哪些方法」(編譯期檢查);實際型別決定「執行哪個覆寫版本」(執行期繫結)。
欄位與 static 方法採靜態繫結、不具多型;避免宣告與父類別同名的欄位。
向下轉型前先檢查:傳統寫法用 |instanceof| 加轉型,Java 16 起可用模式匹配 !if (obj instanceof Dog dog)! 一步到位。
抽象類別不能實體化;抽象方法只有簽章,子類別必須全部實作,否則自己也得是抽象類別。
抽象類別的一般方法可以呼叫抽象方法:父類別定骨架、子類別填細節。
介面是能力契約:欄位隱含 |public static final|,方法隱含 |public|;Java 8 起有 default 與 static 方法,Java 9 起有 private 方法。
default 方法的存在理由是介面演進:替既有介面加方法而不破壞既有實作。
兩個介面的 default 方法衝突(鑽石問題)時,實作類別必須覆寫,並可用「介面名.super.方法名」指定沿用版本。
選擇準則:預設先想介面;需要共享狀態與大量共同實作時才用抽象類別。
本章練習
觀念題:|Animal a = new Dog();| 之後,|a.fetch()| 無法編譯,但 |a.speak()| 執行的卻是 |Dog| 的版本。請分別用「參考型別」與「實際型別」解釋這兩個現象。(提示:一個發生在編譯期,一個發生在執行期。)
實作題:擴充本章的 |Shape| 體系,新增 |Triangle| 類別(底與高),並確認 |ShapeDemo| 的迴圈不需修改就能處理它。(提示:面積公式為底乘高除以二。)
實作題:定義 |Describable| 介面(含 |String describe()| 抽象方法與一個 default 方法 |void printDescription()| 負責印出描述),讓第 1 章的 |Employee| 實作它。(提示:default 方法內可以呼叫 |describe()|。)
觀念題:為什麼 Java 允許實作多個介面,卻只允許繼承一個類別?若允許多重繼承類別,會出現什麼歧義?(提示:想想兩個父類別各有一份同名「欄位」與有狀態的方法實作時,子類別該用哪一份。)
實作題:為付款系統新增 |BankTransferPayment|(轉帳手續費固定 15 元,覆寫 |fee|),並在 |main| 中用同一個 |checkout| 完成三種付款。(提示:只需新增類別,|checkout| 不准修改。)
挑戰題:寫一個 |Payment[]| 多型陣列裝入三種付款方式,走訪陣列對每筆訂單試算手續費,並用模式匹配 |instanceof| 找出其中的信用卡付款、印出其末四碼。(提示:!if (p instanceof CreditCardPayment c)!。)