Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

繼承與多型

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 繼承與多型:用一張薪資單看懂動態分派〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。