thinking-in-java

發佈

Java 多型(Polymorphism) 完整指南:向上轉型與動態綁定【Thinking in Java筆記(7-1)】

Java 多型(Polymorphism) 完整指南:向上轉型與動態綁定【Thinking in Java筆記(7-1)】
Photo by Umar Farooq / Unsplash

透過生動比喻和實戰範例,深入剖析 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 裡,除了用 staticfinal (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 把 CircleSquareTriangle 通通當成 Shape 丟出去。接著,當主程式遍歷陣列並呼叫 s.draw()動態綁定 便會自動辨識出它們的 真面目,啟動對應的 Circle.draw()Square.draw()Triangle.draw()。想像 RandomShapeGenerator 就像一座多功能工廠,根據隨機指令生產不同玩具,而 Shapes 只要按下「播放」鈕,就能看到各款玩具依照自己的專屬動畫動起來

擴充無痛:新增子類別不動舊程式

當你在維護一套很多樂器的軟體,每次加入新的樂器類別(例如 WoodwindPercussion),都要修改 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 依然呼叫的是父類別 PrivateOverridef(),因此顯示 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 方法不走多型機制,它們在編譯期就已綁定

透過多型設計,你的程式將具備高度彈性低耦合度,隨時可以加入新類別或功能而不破壞既有邏輯。建議動手練習:自訂幾個子類別並試試呼叫,親身體會多型的便利與威力