Java 多型(Polymorphism) 完整指南:向上轉型與動態綁定【Thinking in Java筆記(7-1)】
透過生動比喻和實戰範例,深入剖析 Java 多型機制(向上轉型與動態綁定),並說明私有方法、欄位與靜態方法的多型限制,幫助初學者快速上手與程式可擴充性最佳實踐
你有沒有想過,為什麼同樣一段程式碼,卻能讓不同的物件做出不一樣的動作?這就是 多型(Polymorphism) 的魅力:它把「做什麼」跟「怎麼做」拆開,讓我們只需要關心 要做什麼,而不必糾結物件的具體細節
透過多型,我們可以用同一套介面呼叫各種不同的類別,卻能執行各自獨特的實作。這不僅能讓程式碼更 清晰好讀,也能輕鬆 擴充功能,後續新增類別時幾乎不用動到既有程式。那麼,為什麼要學多型?接下來就從最常見的 向上轉型 開始揭開它的秘密
再論向上轉型
之前已經提過 Upcasting(向上轉型) 的核心觀念(請見連結),這裡快速複習:
想像你有一支 Wind (吹管樂器),卻想用同一個 tune()
方法調音,不想為每種樂器都寫一次。向上轉型(Upcasting) 就是把具體物件「偽裝」成父類別,使統一介面能接收所有子類別,就像讓不同顏色的筆都能插進同一個筆筒
public enum Note {
MIDDLE_C, C_SHARP, B_FLAT; // Etc.
}
class Instrument {
public void play(Note n) {
print("Instrument.play()");
}
}
// Wind objects are instruments
// because they have the same interface:
public class Wind extends Instrument {
// Redefine interface method:
public void play(Note n) {
System.out.println("Wind.play() " + n);
}
}
public class Music {
public static void tune(Instrument i) {
// ...
i.play(Note.MIDDLE_C);
}
public static void main(String[] args) {
Wind flute = new Wind();
tune(flute); // Upcasting
}
} /* Output:
Wind.play() MIDDLE_C
*/
把 Wind 當作 Instrument,就像把高階彩色筆放進通用筆筒:你依然可以畫畫(呼叫 play()),但暫時忘記它是哪種顏色(具體類別)
這看似把 Wind 的「專屬功能」縮小到 Instrument 的介面,卻不會少於 Instrument 本身該有的功能。你可能會問:「為什麼不讓 tune() 直接接受 Wind?更直觀吧?」可惜一旦這麼做,每新增一種子類別(像 Stringed、Brass)就要重寫一支 tune(),程式碼瞬間膨脹、維護爆炸。向上轉型 幫你化繁為簡,只要一支 tune(Instrument),所有樂器一次搞定,新類別來了也不用再動這支方法
class Stringed extends Instrument {
public void play(Note n) {
print("Stringed.play() " + n);
}
}
class Brass extends Instrument {
public void play(Note n) {
print("Brass.play() " + n);
}
}
public class Music2 {
public static void tune(Wind i) {
i.play(Note.MIDDLE_C);
}
public static void tune(Stringed i) {
i.play(Note.MIDDLE_C);
}
public static void tune(Brass i) {
i.play(Note.MIDDLE_C);
}
public static void main(String[] args) {
Wind flute = new Wind();
Stringed violin = new Stringed();
Brass frenchHorn = new Brass();
tune(flute); // No upcasting
tune(violin);
tune(frenchHorn);
}
} /* Output:
Wind.play() MIDDLE_C
Stringed.play() MIDDLE_C
Brass.play() MIDDLE_C
*/
這樣做雖然能達到目的,就像為每支筆都準備一個專屬筆筒——筆筒數量瞬間爆炸,維護成本跟著往上飆
如果新增了 Drum 子類別,卻忘了寫對應的 tune()
,編譯器也不會提醒你,程式依然能跑。這時候,只接受基礎類別的呼叫方式,就像把所有筆統一放進同一個大筆筒:新增子類別不用動原本程式碼,只要呼叫 tune(Instrument)
,所有樂器都能被調整,這就是多型帶來的彈性與便利
動態綁定(Dynamic Binding):執行時的方法選擇
你有沒有想過當你呼叫 tune(Instrument)
時,Java 到底怎麼分辨要執行 Wind.play()
、Brass.play()
還是其他樂器的 play()
?
答案是:編譯器不知道
它是在程式執行時才根據物件的真實類型做決定。就像你把匿名包裹交給快遞,只有到了配送中心才透過掃描標籤確認目的地;這種在執行期才決定對應方法的機制,就叫做 動態綁定
什麼是「方法呼叫綁定」?
當你在程式裡呼叫一支方法,Java 必須把「呼叫」和「實作」連結起來,這個過程就叫做綁定(Binding)。你可以這麼想:
- 編譯期綁定(早期綁定):在程式編譯或連結階段,編譯器就把呼叫對應到方法實作,就像在開會前已經分好座位
- 執行期綁定(動態綁定/後期綁定):等到程式執行時,再依照物件的實際類型決定要呼叫哪一版方法,就像電影開演才讓觀眾根據票面劃位入座
Java 裡,除了用 static
、final
(private
方法也自動當成 final
) 修飾的方法,其他所有實例方法都走動態綁定的路線: 也就是在執行時才真正決定要執行哪個子類別的方法實作
實戰示範:動態綁定一次搞定
想過為什麼 Shape s = new Circle();
看似怪異,卻能正確呼叫 Circle.draw()
嗎?動態綁定 就像快遞公司:先收包裹,不看裡面是書還是衣服,只靠外包裝(型別)先接收,真正打開後(執行期)才依內容分發。這麼一來,我們只要寫一次 draw()
呼叫,就能應付所有形狀
Shape s = new Circle();
看起來好像把一個引用指向不同類型的物件是個錯誤,但 繼承 讓 Circle 本質上就是一種 Shape,因此 Java 編譯器毫不介意這樣寫。當你呼叫 s.draw()
,直覺可能會以為執行的是 Shape.draw(),但實際上,JVM 會在執行期根據 s
真正指向的物件 (也就是 Circle) 來決定呼叫 Circle.draw()
public class Shape {
public void draw() {}
public void erase() {}
}
public class Circle extends Shape {
public void draw() { print("Circle.draw()"); }
public void erase() { print("Circle.erase()"); }
}
public class Square extends Shape {
public void draw() { print("Square.draw()"); }
public void erase() { print("Square.erase()"); }
}
public class Triangle extends Shape {
public void draw() { print("Triangle.draw()"); }
public void erase() { print("Triangle.erase()"); }
}
public class RandomShapeGenerator {
private Random rand = new Random(47);
public Shape next() {
switch(rand.nextInt(3)) {
default:
case 0: return new Circle();
case 1: return new Square();
case 2: return new Triangle();
}
}
}
public class Shapes {
private static RandomShapeGenerator gen =
new RandomShapeGenerator();
public static void main(String[] args) {
Shape[] s = new Shape[9];
// Fill up the array with shapes:
for(int i = 0; i < s.length; i++)
s[i] = gen.next();
// Make polymorphic method calls:
for(Shape shp : s)
shp.draw();
}
} /* Output:
Triangle.draw()
Triangle.draw()
Square.draw()
Triangle.draw()
Square.draw()
Triangle.draw()
Square.draw()
Triangle.draw()
Circle.draw()
*/
為什麼一行 return new ...()
,就能讓陣列裡的每個 Shape 在執行 draw()
時各自發揮所長?關鍵在於在 return
的那一刻,發生了 向上轉型:
RandomShapeGenerator 把 Circle
、Square
、Triangle
通通當成 Shape 丟出去。接著,當主程式遍歷陣列並呼叫 s.draw()
,動態綁定 便會自動辨識出它們的 真面目,啟動對應的 Circle.draw()
、Square.draw()
或 Triangle.draw()
。想像 RandomShapeGenerator 就像一座多功能工廠,根據隨機指令生產不同玩具,而 Shapes 只要按下「播放」鈕,就能看到各款玩具依照自己的專屬動畫動起來
擴充無痛:新增子類別不動舊程式
當你在維護一套很多樂器的軟體,每次加入新的樂器類別(例如 Woodwind
、Percussion
),都要修改 tuneAll()
,維護量瞬間暴增。多型 的魅力就在於,只需撰寫一支 tune(Instrument)
方法,所有子類別都能自動支援,不需要動到既有程式碼,擴充變得又快又省力
class Instrument {
void play(Note n) { print("Instrument.play() " + n); }
String what() { return "Instrument"; }
void adjust() { print("Adjusting Instrument"); }
}
class Wind extends Instrument {
void play(Note n) { print("Wind.play() " + n); }
String what() { return "Wind"; }
void adjust() { print("Adjusting Wind"); }
}
class Percussion extends Instrument {
void play(Note n) { print("Percussion.play() " + n); }
String what() { return "Percussion"; }
void adjust() { print("Adjusting Percussion"); }
}
class Stringed extends Instrument {
void play(Note n) { print("Stringed.play() " + n); }
String what() { return "Stringed"; }
void adjust() { print("Adjusting Stringed"); }
}
class Brass extends Wind {
void play(Note n) { print("Brass.play() " + n); }
void adjust() { print("Adjusting Brass"); }
}
class Woodwind extends Wind {
void play(Note n) { print("Woodwind.play() " + n); }
String what() { return "Woodwind"; }
}
public class Music3 {
// Doesn't care about type, so new types
// added to the system still work right:
public static void tune(Instrument i) {
// ...
i.play(Note.MIDDLE_C);
}
public static void tuneAll(Instrument[] e) {
for(Instrument i : e)
tune(i);
}
public static void main(String[] args) {
// Upcasting during addition to the array:
Instrument[] orchestra = {
new Wind(),
new Percussion(),
new Stringed(),
new Brass(),
new Woodwind()
};
tuneAll(orchestra);
}
} /* Output:
Wind.play() MIDDLE_C
Percussion.play() MIDDLE_C
Stringed.play() MIDDLE_C
Brass.play() MIDDLE_C
Woodwind.play() MIDDLE_C
*/
在這個例子裡,我們把不同的樂器都向上轉型成 Instrument,因此 tune()
只需要對 Instrument 下達 play()
指令,就能在執行期透過動態綁定呼叫正確的子類別實作。這表示「新增或修改樂器子類別」時,完全不用動調音程式碼,功能依然正常。換句話說,多型幫助程式把可變的細節放在子類別,讓呼叫介面保持穩定,達成變化與穩定分離
換句話說,多型是「將變化的事物與不變的事物分離」的重要技術
覆蓋私有方法的陷阱
當父類別定義了 private
方法,子類無法真正覆寫它,因為這方法對子類是隱形的,編譯器也默認把它當成 final
處理。換句話說,子類的同名方法其實是全新的方法,而非覆蓋
public class PrivateOverride {
private void f() { print("private f()"); }
public static void main(String[] args) {
PrivateOverride po = new Derived();
po.f();
}
}
class Derived extends PrivateOverride {
public void f() { print("public f()"); }
} /* Output:
private f()
*/
執行 po.f()
時,Java 依然呼叫的是父類別 PrivateOverride
的 f()
,因此顯示 private f()
,而非 public f()
。可以把 private
方法想像為鎖在箱子裡的物品,子類別既看不到箱裡的東西,也打不開箱子
欄位與靜態方法的多型限制
在 Java 中,欄位的讀取屬於編譯期綁定,也就是程式編譯時就決定好要讀哪個欄位。就像每張桌子的號碼貼好後就不能再變動,無論實際座上哪位客人都是同一張桌子
class Super {
public int field = 0;
public int getField() { return field; }
}
class Sub extends Super {
public int field = 1;
public int getField() { return field; }
public int getSuperField() { return super.field; }
}
public class FieldAccess {
public static void main(String[] args) {
Super sup = new Sub(); // Upcast
System.out.println("sup.field = " + sup.field +
", sup.getField() = " + sup.getField());
Sub sub = new Sub();
System.out.println("sub.field = " +
sub.field + ", sub.getField() = " +
sub.getField() +
", sub.getSuperField() = " +
sub.getSuperField());
}
} /* Output:
sup.field = 0, sup.getField() = 1
sub.field = 1, sub.getField() = 1, sub.getSuperField() = 0
*/
以這個範例為例:
sup.field
根據參考變數sup
(編譯型別是Super
) 讀取Super.field
,結果是0
- 當呼叫
sup.getField()
,方法本身走動態綁定,執行期自動選出Sub.getField()
,結果是1
- 要讀取父類的欄位,可以在
Sub
類別內使用super.field
同樣,static 方法也並不具備多型性:
class StaticSuper {
public static String staticGet() {
return "Base staticGet()";
}
public String dynamicGet() {
return "Base dynamicGet()";
}
}
class StaticSub extends StaticSuper {
public static String staticGet() {
return "Derived staticGet()";
}
public String dynamicGet() {
return "Derived dynamicGet()";
}
}
public class StaticPolymorphism {
public static void main(String[] args) {
StaticSuper sup = new StaticSub(); // Upcast
System.out.println(sup.staticGet());
System.out.println(sup.dynamicGet());
}
} /* Output:
Base staticGet()
Derived dynamicGet()
*/
靜態方法(static
)的綁定發生在編譯期
Java 只看變數的宣告型別,就像牆上的路標不會因為路人身份不同而改變,sup.staticGet()
無論指向 StaticSub
還是 StaticSuper
,都只會呼叫 StaticSuper.staticGet()
,輸出 Base staticGet()
。相對地,dynamicGet()
是實例方法,走動態綁定,會在執行期檢查 sup
真實物件並呼叫 Derived.dynamicGet()
,輸出 Derived dynamicGet()
總結:多型讓程式更靈活
多型是 OOP 的秘密武器,讓你只寫一次程式碼,卻能應付多種情境。回顧本章重點:
- 向上轉型(Upcasting):以父類別引用接收所有子類別,實現統一介面
- 動態綁定(Dynamic Binding):在執行期自動挑選對應方法,確保每個物件都能發揮專屬功能
- 使用限制:
private
方法、欄位與static
方法不走多型機制,它們在編譯期就已綁定
透過多型設計,你的程式將具備高度彈性與低耦合度,隨時可以加入新類別或功能而不破壞既有邏輯。建議動手練習:自訂幾個子類別並試試呼叫,親身體會多型的便利與威力
Comments ()