Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 28 · 臺南 AQI 24 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

繼承與多型

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 最根本的氣質差異。

Java 繼承與多型概念示意圖

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() 呼叫的是 Employeedescribe(),不會掉進無窮遞迴。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;
    }
}

抽象類別的關鍵特性:

  • 不能被 newnew 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:封住繼承與覆寫

finalabstract 的反面,它說「到此為止,不准再改」。它有三個用法:

// 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 鎖住。這是一種有意識的設計決策:明確宣告「這裡不是擴充點」。

常見錯誤

繼承與多型踩雷的地方很集中,以下五條請貼在螢幕旁邊:

  1. 覆寫時方法簽章不一致,卻沒加 @Override:把參數型別寫錯(例如父類別是 monthlyPay()、你寫成 monthlyPay(int bonus))會變成多載(overload)而非覆寫,動態分派根本不會叫到它。永遠加 @Override 讓編譯器替你把關。
  2. 以為覆寫能放寬存取權限或縮小回傳型別到不相容:覆寫方法的存取修飾子不能比父類別更嚴格(父類別 public,子類別不能改 protected),否則編譯失敗。
  3. super(...) 沒放在建構子第一行:Java 要求父類別必須先初始化完成,super(...) 一定是建構子的第一個敘述,放錯位置直接編譯錯誤。
  4. 混淆「編譯期型別」與「執行期型別」Employee e = new FullTime(...) 之後,e 只能呼叫 Employee有定義的方法。即使 FullTime 有獨有的 getBaseSalary(),透過 e 也叫不到——編譯器只看 e 的宣告型別。要叫到,得先向下轉型(downcast)並承擔 ClassCastException 的風險。
  5. 欄位(field)沒有多型:只有方法會動態分派。如果父子類別有同名欄位,存取哪個欄位是由編譯期型別決定的(這叫欄位遮蔽 field shadowing),不會動態分派。所以請永遠透過方法存取狀態,別直接暴露 public 欄位。

深入探討(研究所視角)

到這裡你已經會用了。接下來談談「為什麼動態分派跑得起來」,以及 Java 型別系統底下的兩塊基石。

動態分派的實作:虛擬方法表(vtable)

e.monthlyPay() 怎麼在執行期找到正確版本?JVM 的典型實作是虛擬方法表(virtual method table,vtable)。每個類別在載入時,JVM 會為它建一張方法表,表中每個槽位(slot)指向該類別實際生效的方法實作。子類別的 vtable 複製父類別的版面,再把被覆寫的槽位換成自己的實作位址。

於是 e.monthlyPay() 在位元組碼層級對應到 invokevirtual 指令,執行時做的事大致是:

  1. 從物件取得它的類別(FullTime)的 vtable。
  2. monthlyPay 對應的固定槽位索引(這個索引在編譯期就確定了,因為它由 Employee 的方法版面決定)。
  3. 跳到該槽位指向的位址執行。

查表本身是 $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 放進 HashMapHashSet 時會出現「明明相等卻查不到」的詭異 bug,因為雜湊容器先用 hashCode 分桶、再用 equals 比對。這是動態分派與 Object 設計交織出的實務陷阱。

@Override 的真正意義:契約的編譯期驗證

最後回到 @Override。它在執行期完全不存在——它是純粹給編譯器看的標註(annotation),執行期行為加不加都一樣。它的價值全在編譯期

  • 它把「我意圖覆寫」這個意圖寫成程式碼,讓編譯器驗證父類別真的有可覆寫的對應方法。簽章打錯、父類別方法改名或刪除,編譯立刻紅燈。
  • 它讓讀程式碼的人一眼看出「這個方法是覆寫來的,去父類別找原型」,是一種對人類也對機器的文件。

這正是 Java 設計哲學的縮影:把意圖明確化、把錯誤盡量提早到編譯期攔截。相較於 Python 的「鴨子型別(duck typing)」——只要物件有 monthly_pay 方法就能用、對不對留到執行期才知道——Java 寧可你多打字、多宣告,換取一整類錯誤在程式上線前就被擋下。在動輒數十萬行、多人協作數年的企業級系統裡,這份「囉嗦」往往就是可維護性的來源。理解了這層差異,你才算真正理解了 Java 為什麼是現在這個樣子。

AI 共讀助教正在陪你讀:Java 繼承與多型:用一張薪資單看懂動態分派
嗨!我是這篇文章的共讀助教,只根據〈Java 繼承與多型:用一張薪資單看懂動態分派〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。