Java 繼承與多型:用一張薪資單看懂動態分派
從 extends、override、super 到 abstract 與 final,並用薪資系統實例理解向上轉型與動態分派——同時看清 Java 靜態強型別與 Python 鴨子型別的根本差異。
從一張「員工薪資單」開始:當你不想為每種員工各寫一份計算邏輯
假設你接手一套公司薪資系統。公司裡有正職(FullTime)、時薪工讀(PartTime)、外包顧問(Contractor)三種人。他們都有姓名、員工編號,都要算月薪——但每種人的「算法」完全不同:正職是固定月薪、工讀是時薪乘工時、顧問是按件計酬。
最笨的寫法,是寫三個彼此無關的類別,再用一大串 if (type.equals("fulltime")) ... else if ... 去判斷該叫哪一套邏輯。每新增一種員工,你就得回去改那串 if。在 Java 這種以物件導向為第一公民(OOP-first)的語言裡,有更乾淨的解法:把「都是員工、都要算薪水」這件事抽出來放進父類別,讓三種員工各自覆寫(override)自己的算法,最後用多型(polymorphism)讓同一行 emp.monthlyPay() 在執行期自動分派到正確的版本。
這篇文章就帶你把這套機制完整走一遍。如果你讀過本專區的 Python 篇,會發現概念相通,但 Java 把這些東西做成了語言內建的、編譯器強制檢查的、靜態強型別的規則——這正是 Java 與 Python 最根本的氣質差異。

extends:建立「is-a」關係
Java 用 extends 關鍵字宣告繼承。子類別自動擁有父類別的(非 private)欄位與方法,這叫「is-a」關係——FullTime is a Employee。
class Employee {
protected String name;
protected int id;
public Employee(String name, int id) {
this.name = name;
this.id = id;
}
public double monthlyPay() {
return 0.0; // 預設值,子類別會覆寫
}
public String describe() {
return id + " 號員工:" + name;
}
}
class FullTime extends Employee {
private double baseSalary;
public FullTime(String name, int id, double baseSalary) {
super(name, id); // 呼叫父類別建構子
this.baseSalary = baseSalary;
}
}
幾個 Java 的硬規則,初學者要先記牢:
- Java 只支援單一繼承:一個類別只能
extends一個父類別。相較於 Python 支援多重繼承(multiple inheritance),Java 刻意限制只能繼承一個類別,避免「菱形繼承」的混亂;要組合多種能力,Java 改用interface(介面)。 protected讓子類別存取得到欄位,但對外部仍隱藏。相較於 Python 只靠_命名約定「君子協定」,Java 的存取修飾子是編譯器強制的。- 子類別建構子若要呼叫父類別建構子,必須用
super(...),而且必須是建構子的第一行。
方法覆寫(override)與 @Override
子類別可以提供與父類別簽章相同的方法,蓋掉父類別的版本,這就是覆寫。請務必加上 @Override 標註:
class FullTime extends Employee {
private double baseSalary;
public FullTime(String name, int id, double baseSalary) {
super(name, id);
this.baseSalary = baseSalary;
}
@Override
public double monthlyPay() {
return baseSalary;
}
}
class PartTime extends Employee {
private double hourlyRate;
private int hours;
public PartTime(String name, int id, double hourlyRate, int hours) {
super(name, id);
this.hourlyRate = hourlyRate;
this.hours = hours;
}
@Override
public double monthlyPay() {
return hourlyRate * hours;
}
}
@Override 不是裝飾用的。它告訴編譯器:「我打算覆寫父類別的方法,請幫我檢查。」如果你不小心把方法名拼錯成 monthlyPey(),沒有 @Override 時編譯器會以為你只是新增了一個方法,悄悄放行,留下一個永遠不會被呼叫的死方法;加了 @Override,編譯器會立刻報錯,因為父類別根本沒有 monthlyPey 可覆寫。這是 Java 靜態型別系統替你擋下 bug 的典型例子——Python 沒有等價的編譯期保護。
super:呼叫被覆寫的父類別版本
覆寫之後,若你還想用到父類別的原始邏輯,用 super.方法名():
class Contractor extends Employee {
private double perTask;
private int tasks;
public Contractor(String name, int id, double perTask, int tasks) {
super(name, id);
this.perTask = perTask;
this.tasks = tasks;
}
@Override
public double monthlyPay() {
return perTask * tasks;
}
@Override
public String describe() {
// 先拿父類別的描述,再補上自己的資訊
return super.describe() + "(外包顧問,完成 " + tasks + " 件)";
}
}
super.describe() 呼叫的是 Employee 的 describe(),不會掉進無窮遞迴。super 有兩種用途:super(...) 呼叫父類別建構子、super.method() 呼叫被覆寫的父類別方法,別搞混。
abstract:強迫子類別實作
回頭看 Employee.monthlyPay() 那個 return 0.0——它根本沒有意義,純粹是為了讓父類別能編譯。更好的做法是宣告它為抽象方法:只給簽章、不給實作,並把整個類別標為 abstract。
abstract class Employee {
protected String name;
protected int id;
public Employee(String name, int id) {
this.name = name;
this.id = id;
}
// 抽象方法:沒有方法本體,強迫子類別實作
public abstract double monthlyPay();
// 抽象類別仍可有一般方法(已實作)
public String describe() {
return id + " 號員工:" + name;
}
}
抽象類別的關鍵特性:
- 不能被
new:new Employee("a", 1)直接編譯失敗。因為「員工」是個抽象概念,沒有「就是員工而不是任何具體類型」的人。 - 子類別必須實作所有抽象方法,否則子類別自己也得是
abstract。這是編譯器強制的契約——你不會忘記實作monthlyPay(),因為忘了就無法編譯。 - 抽象類別可以有建構子、欄位、已實作的方法,這是它跟
interface的差別之一。
相較於 Python 需要 from abc import ABC, abstractmethod 才能達到類似效果,Java 把 abstract 直接做進語言關鍵字裡,且檢查發生在編譯期而非執行期。
向上轉型與動態分派:多型的核心
現在是重頭戲。我們可以把任何子類別物件,當成父類別型別來持有,這叫向上轉型(upcasting):
Employee e = new FullTime("小美", 101, 50000); // 合法,自動向上轉型
變數 e 的編譯期型別(static type)是 Employee,但它實際指向的執行期型別(runtime type)是 FullTime。當你呼叫 e.monthlyPay():
double pay = e.monthlyPay(); // 得到 50000,不是 0
Java 在執行期根據物件的真實型別,決定要呼叫哪個版本的 monthlyPay()——這叫動態分派(dynamic dispatch)或動態繫結(dynamic binding)。編譯器只負責確認「Employee 型別上確實有 monthlyPay() 這個方法」,至於跑哪一份,留到執行期才決定。
這就是多型最實用的地方:你可以用同一個父型別的容器裝一堆不同子類別,一視同仁地處理。
動手寫一段:薪資總表
把前面的零件組起來,這是一支完整、可直接編譯執行的程式:
import java.util.ArrayList;
import java.util.List;
abstract class Employee {
protected String name;
protected int id;
public Employee(String name, int id) {
this.name = name;
this.id = id;
}
public abstract double monthlyPay();
public String describe() {
return id + " 號員工:" + name;
}
}
class FullTime extends Employee {
private double baseSalary;
public FullTime(String name, int id, double baseSalary) {
super(name, id);
this.baseSalary = baseSalary;
}
@Override
public double monthlyPay() { return baseSalary; }
}
class PartTime extends Employee {
private double hourlyRate;
private int hours;
public PartTime(String name, int id, double hourlyRate, int hours) {
super(name, id);
this.hourlyRate = hourlyRate;
this.hours = hours;
}
@Override
public double monthlyPay() { return hourlyRate * hours; }
}
class Contractor extends Employee {
private double perTask;
private int tasks;
public Contractor(String name, int id, double perTask, int tasks) {
super(name, id);
this.perTask = perTask;
this.tasks = tasks;
}
@Override
public double monthlyPay() { return perTask * tasks; }
@Override
public String describe() {
return super.describe() + "(外包顧問)";
}
}
public class Payroll {
public static void main(String[] args) {
// 用父型別 Employee 統一裝載不同子類別
List<Employee> staff = new ArrayList<>();
staff.add(new FullTime("小美", 101, 50000));
staff.add(new PartTime("阿宏", 102, 200, 80));
staff.add(new Contractor("Linda", 103, 3000, 5));
double total = 0;
for (Employee e : staff) {
// 同一行 e.monthlyPay(),動態分派到各自版本
System.out.printf("%s 月薪 %.0f%n", e.describe(), e.monthlyPay());
total += e.monthlyPay();
}
System.out.printf("總薪資支出:%.0f%n", total);
}
}
// 輸出:
// 101 號員工:小美 月薪 50000
// 102 號員工:阿宏 月薪 16000
// Contractor 行:103 號員工:Linda(外包顧問)月薪 15000
// 總薪資支出:81000
留意這支程式的迴圈:它完全不知道、也不需要知道每個 e 到底是哪種員工。新增「實習生」Intern 類別時,你只要寫一個新的 extends Employee 並實作 monthlyPay(),main 裡的迴圈一行都不用改。這就是用多型取代 if-else 連環判斷的威力,也是「開放封閉原則」(對擴充開放、對修改封閉)的具體實踐。
final:封住繼承與覆寫
final 是 abstract 的反面,它說「到此為止,不准再改」。它有三個用法:
// 1. final 變數:常數,只能賦值一次
final double TAX_RATE = 0.05;
// TAX_RATE = 0.06; // 編譯錯誤
// 2. final 方法:子類別不准覆寫
class Account {
public final String accountType() { return "標準帳戶"; }
}
// 3. final 類別:不准被繼承
final class ImmutablePoint {
private final int x, y;
public ImmutablePoint(int x, int y) { this.x = x; this.y = y; }
public int getX() { return x; }
public int getY() { return y; }
}
String 在 Java 標準函式庫裡就是 final class,沒人能繼承它去搞破壞——這讓字串的不可變(immutable)保證牢不可破。設計類別時,若某個方法的邏輯攸關安全或正確性、不容子類別竄改,就用 final 鎖住。這是一種有意識的設計決策:明確宣告「這裡不是擴充點」。
常見錯誤
繼承與多型踩雷的地方很集中,以下五條請貼在螢幕旁邊:
- 覆寫時方法簽章不一致,卻沒加
@Override:把參數型別寫錯(例如父類別是monthlyPay()、你寫成monthlyPay(int bonus))會變成多載(overload)而非覆寫,動態分派根本不會叫到它。永遠加@Override讓編譯器替你把關。 - 以為覆寫能放寬存取權限或縮小回傳型別到不相容:覆寫方法的存取修飾子不能比父類別更嚴格(父類別
public,子類別不能改protected),否則編譯失敗。 super(...)沒放在建構子第一行:Java 要求父類別必須先初始化完成,super(...)一定是建構子的第一個敘述,放錯位置直接編譯錯誤。- 混淆「編譯期型別」與「執行期型別」:
Employee e = new FullTime(...)之後,e只能呼叫Employee上有定義的方法。即使FullTime有獨有的getBaseSalary(),透過e也叫不到——編譯器只看e的宣告型別。要叫到,得先向下轉型(downcast)並承擔ClassCastException的風險。 - 欄位(field)沒有多型:只有方法會動態分派。如果父子類別有同名欄位,存取哪個欄位是由編譯期型別決定的(這叫欄位遮蔽 field shadowing),不會動態分派。所以請永遠透過方法存取狀態,別直接暴露 public 欄位。
深入探討(研究所視角)
到這裡你已經會用了。接下來談談「為什麼動態分派跑得起來」,以及 Java 型別系統底下的兩塊基石。
動態分派的實作:虛擬方法表(vtable)
e.monthlyPay() 怎麼在執行期找到正確版本?JVM 的典型實作是虛擬方法表(virtual method table,vtable)。每個類別在載入時,JVM 會為它建一張方法表,表中每個槽位(slot)指向該類別實際生效的方法實作。子類別的 vtable 複製父類別的版面,再把被覆寫的槽位換成自己的實作位址。
於是 e.monthlyPay() 在位元組碼層級對應到 invokevirtual 指令,執行時做的事大致是:
- 從物件取得它的類別(
FullTime)的 vtable。 - 查
monthlyPay對應的固定槽位索引(這個索引在編譯期就確定了,因為它由Employee的方法版面決定)。 - 跳到該槽位指向的位址執行。
查表本身是 $O(1)$——固定索引、一次間接跳轉。這也解釋了前面「欄位不多型」的原因:欄位存取在編譯期就解析成固定的記憶體偏移量(getfield 指令),根本不查 vtable,自然沒有執行期分派。
值得一提的是,Java 的方法預設就是虛擬(virtual)的——這跟 C++ 必須顯式寫 virtual 關鍵字、預設靜態繫結恰好相反。Java 選擇「預設多型」,把 final 當成關掉多型的開關。JIT 編譯器(Just-In-Time)還會做去虛擬化(devirtualization):當它在執行期觀察到某個呼叫點實際上只會碰到單一型別,就把虛擬呼叫優化成直接呼叫甚至內聯(inline),抹掉查表成本。
Object:萬類之祖
Java 裡所有類別——包括你寫的每一個——只要沒有顯式 extends,都隱式繼承 java.lang.Object。所以「所有東西都 is-a Object」,這讓 Object[] 或泛型出現前的容器能裝任何物件。Object 帶來幾個你天天在覆寫卻可能沒意識到的方法:
class Money {
private final long cents;
public Money(long cents) { this.cents = cents; }
@Override
public boolean equals(Object o) { // 來自 Object
if (this == o) return true;
if (!(o instanceof Money)) return false;
return cents == ((Money) o).cents;
}
@Override
public int hashCode() { // 來自 Object,必須與 equals 一致
return Long.hashCode(cents);
}
@Override
public String toString() { // 來自 Object,println 自動呼叫
return "$" + (cents / 100.0);
}
}
這裡藏著 Java 的一條鐵律:覆寫 equals 就必須一起覆寫 hashCode,且兩者邏輯要一致(相等的物件必須有相同雜湊值)。否則把 Money 放進 HashMap/HashSet 時會出現「明明相等卻查不到」的詭異 bug,因為雜湊容器先用 hashCode 分桶、再用 equals 比對。這是動態分派與 Object 設計交織出的實務陷阱。
@Override 的真正意義:契約的編譯期驗證
最後回到 @Override。它在執行期完全不存在——它是純粹給編譯器看的標註(annotation),執行期行為加不加都一樣。它的價值全在編譯期:
- 它把「我意圖覆寫」這個意圖寫成程式碼,讓編譯器驗證父類別真的有可覆寫的對應方法。簽章打錯、父類別方法改名或刪除,編譯立刻紅燈。
- 它讓讀程式碼的人一眼看出「這個方法是覆寫來的,去父類別找原型」,是一種對人類也對機器的文件。
這正是 Java 設計哲學的縮影:把意圖明確化、把錯誤盡量提早到編譯期攔截。相較於 Python 的「鴨子型別(duck typing)」——只要物件有 monthly_pay 方法就能用、對不對留到執行期才知道——Java 寧可你多打字、多宣告,換取一整類錯誤在程式上線前就被擋下。在動輒數十萬行、多人協作數年的企業級系統裡,這份「囉嗦」往往就是可維護性的來源。理解了這層差異,你才算真正理解了 Java 為什麼是現在這個樣子。