上一節我們把 交給一個型別為 的參數,編譯器欣然接受。 這背後的規則叫子型別。本節把它講清楚,再介紹 Java 重用程式碼的機制——繼承, 最後用 Liskov 替換原則回答一個關鍵問題:什麼時候「A 繼承 B」是正確的設計?

子型別:型別就是集合

若任何需要 U 的地方都可以安全地提供一個 T,則 T 是 U 的子型別(T is a U)。
在 Java 中,、、 都會建立「T 是 U 的子型別」的關係,而且這個關係是可傳遞的。

把每個型別想成「所有屬於它的物件」形成的集合,子型別就是子集合。 每一個 物件同時也是一個 ; 每一個 同時也是一個 ; 而每一個 (以及所有其他物件)都是 。

型別即集合:子型別關係就是子集合關係。Premium \(\subset\) Points \(\subset\) Account \(\subset\) Object, 所以「需要 的地方,給它任何一個子集合裡的物件都行」。

Java 的每一個類別都(直接或間接)是 的子型別,所以下面的程式合法:

Object o = "hello";                                   // String 是 Object
Object[] mixed = { 42, "text", new BankAccount(3) };   // 42 會自動包成 Integer

代價是:透過 型別的變數,你只能呼叫 自己的方法 (、、……)。想拿回原本的能力,需要轉型(第 [sec:casts] 節)。

繼承:重用另一個類別的實作

介面只共享契約;當幾個類別連實作都大同小異時,可以讓一個類別 另一個, 把欄位與方法繼承過來。本節的範例是一個帳戶家族():

的類別階層(UML 風格;空心三角箭頭 = extends)。
public abstract class BankAccount {
    protected int balance;                 // 子類別可直接存取

    public BankAccount() { this.balance = 0; }

    public void deposit(int amount) {      // 具體方法:所有子類別直接繼承
        if (amount < 0) throw new IllegalArgumentException("存款金額不可為負數:" + amount);
        this.balance = this.balance + amount;
    }
    public int getBalance() { return this.balance; }

    /** 抽象方法:每種帳戶的提款規則不同,只宣告不實作。 */
    public abstract boolean withdraw(int amount);
}
public class SavingsAccount extends BankAccount {
    public SavingsAccount() {
        super();                                  // 呼叫父類別的建構子
    }
    @Override
    public boolean withdraw(int amount) {
        if (amount < 0) throw new IllegalArgumentException("提款金額不可為負數:" + amount);
        if (this.balance < amount) return false;  // 餘額不足:拒絕,狀態不變
        this.balance = this.balance - amount;     // protected 欄位:子類別可直接存取
        return true;
    }
}

繼承的語法規則整理:

  • :B 是 A 的,A 是 B 的。 B 自動擁有 A 的所有非私有欄位與方法( 成員存在於物件裡,但 B 的程式碼看不到)。

  • 一個類別只能 extends 一個父類別(單一繼承),但可以 implements 多個介面。沒寫 的類別預設 。

  • :子類別重新定義父類別的方法(相同簽名),一定加 讓編譯器檢查。

  • :呼叫父類別版本的方法。常見寫法是「先做父類別的事,再加上自己的事」:

    @Override
    public boolean withdraw(int amount) {
        boolean ok = super.withdraw(amount);       // 父類別的真正邏輯
        this.log.add(ok ? "withdraw " + amount : "withdraw " + amount + " FAILED");
        return ok;
    }
  • 建構子不會被繼承。子類別建構子的第一行(隱含或明寫)是 ,先把「父類別那部分」建好。 若父類別沒有無參數建構子,你必須明寫 。

  • :讓子類別能存取,但無關的類別不能——比 寬、比 窄。

抽象類別 vs. 介面

是「不完整的類別」:可以有欄位、具體方法與抽象方法(只宣告不實作), 但不能被 。子類別必須實作所有抽象方法,否則自己也得標為 。

@lL0.33L0.33@ & 介面 & 抽象類別
能否有欄位(狀態) & 只能有 常數 & 可以()
能否有具體方法 & 只有 方法(§[sec:collections]) & 可以()
一個類別能繼承幾個 & 多個 & 只能 一個
適合表達 & 能力/契約(「可以存提款」) & 共同骨架(「所有帳戶都有餘額」)
何時選它 & 預設選擇;解耦最徹底 & 多個實作共享大量相同程式碼時

動態分派:靜態型別 vs. 執行期型別

PointsAccount p = new PremiumPointsAccount();   // 靜態型別 PointsAccount,執行期 Premium
p.deposit(500);                                 // 執行的是 Premium 版!
System.out.println(p.getBalance());             // 10555,不是 10505

計算過程:

  • Premium 版的 :金額乘 1.1 得 550;

  • 接著呼叫 : 加送 1%,得 \(550 + 5 = 555\);

  • 餘額 \(10000 + 555 = 10555\)。

呼叫 時,編譯器用 的(宣告型別)檢查 是否存在; 執行期則依 目前指向的物件的( 出來的那個類別) 決定執行哪一個版本的 ——一路往上找到最近的定義。

同一個呼叫的兩個階段:靜態型別決定「合法性」,執行期型別決定「行為」。

動態分派讓「一個陣列、一個迴圈、多種行為」成為可能:

BankAccount[] accounts = {
    new SavingsAccount(), new LoggingSavingsAccount(), new AccountWithWithdrawalFees()
};
for (BankAccount a : accounts) {
    a.deposit(100);
    boolean ok = a.withdraw(100);   // 靜態型別 BankAccount,執行的是各子類別的 withdraw
    System.out.println("  " + a.getClass().getSimpleName() + ":存 100 領 100 → " + ok + ",餘額 " + a.getBalance());
}
SavingsAccount:存 100 領 100 → true,餘額 0
  LoggingSavingsAccount:存 100 領 100 → true,餘額 0
  AccountWithWithdrawalFees:存 100 領 100 → false,餘額 100

與

  • 類別():不能再被繼承。 方法:不能被覆寫。 欄位:只能在建構子中賦值一次(、)。 宣告 是在說「我沒有為被繼承而設計,請不要繼承我」——Java 的 就是 。

  • 成員屬於類別本身而非個別物件,所有物件共用一份。用類別名稱存取:

    public static final int FEE = 5;               // 常數:static final,全大寫命名
    int total = amount + AccountWithWithdrawalFees.FEE;

    Week 1–5 寫的所有「純函式」()都是 static 方法:它們不需要任何物件的狀態。

  • 判斷原則:需要讀寫某個物件的欄位 → 實例方法;只依賴參數 → static 方法。 在 static 方法裡沒有 ,所以不能直接用實例欄位——這是 裡常見的編譯錯誤來源。

Liskov 替換原則:什麼時候繼承是「對的」?

編譯器只檢查簽名——只要方法名稱與型別對得上,任何 都能編譯。 但「合法」不等於「正確」。看 裡這個以 的契約為前提寫的函式:

/** 依 SavingsAccount 的契約:提領剛好等於餘額的金額一定成功,之後餘額為 0。 */
static void withdrawEverything(SavingsAccount acct) {
    int all = acct.getBalance();
    boolean ok = acct.withdraw(all);
    assert ok : "契約保證:提領等於餘額的金額必定成功";
    assert acct.getBalance() == 0 : "契約保證:領光之後餘額為 0";
}

再看這個「合法」的子類別:

public class OverdraftSavingsAccount extends SavingsAccount {
    @Override
    public boolean withdraw(int amount) {
        this.balance = this.balance - amount;   // 不檢查餘額
        return true;                            // 永遠宣稱成功
    }
}
=== 5. LSP:違規的子型別會讓正確的程式碼出錯 ===
  提領 80 → 成功? true,餘額 = 0
  再提 1000 → 餘額 = -1000(負數!不變量被打破)
  ★ 編譯器沒有抱怨,但以 SavingsAccount 契約為前提的程式碼全都可能出錯。

Barbara Liskov(1987):若對每個型別 S 的物件 \(o_1\),都存在型別 T 的物件 \(o_2\), 使得任何以 T 定義的程式 P 在把 \(o_2\) 換成 \(o_1\) 後行為不變,則 S 是 T 的子型別。
白話版:子型別的物件必須能在任何地方替換父型別的物件,而不讓依賴父型別契約的程式出錯。 滿足這個條件的子型別稱為——它不只是簽名相容,連行為都相容。

要成為行為子型別,覆寫的方法必須遵守四條規則:

行為子型別的四條規則。

「每次提款收 5 元手續費」的帳戶,該繼承誰? 繼承 能重用最多程式碼,但 的契約說「提領 \(\le\) 餘額的金額一定成功」, 手續費會讓「剛好領光」失敗——違反規則 2。 所以它改成繼承契約較寬鬆的 (只承諾「嘗試提款並回報結果」),犧牲一點重用換取行為正確。
繼承是為了表達「is-a」的行為關係,不是為了少打幾行字。 若只是想借用實作,讓新類別持有一個舊類別的物件(組合,composition)通常比繼承更安全。

數學上是;但若 有 /, 就會違反 LSP:呼叫者 預期面積 15,正方形卻會得到 9 或 25。問題出在可變性—— 若兩者都是不可變的 record, 當 的子型別就沒有任何問題。 LSP 的很多違規都源自「父類別允許的狀態變化,子類別無法同時滿足」。

練習

已知 開戶 10000 點、 加 ; 呼叫 。 下面程式印出什麼?

Account x = new PremiumPointsAccount();
x.deposit(1000);
PointsAccount y = new PointsAccount();
y.deposit(1000);
System.out.println(x.getBalance() + " " + y.getBalance());

:\(1000 \times 110 / 100 = 1100\), 加 \(1100 + 11 = 1111\),得 11111。 :\(1000 + 10 = 1010\),得 11010。輸出 。 注意 的靜態型別是 ,但 執行的仍是 Premium 版本。

介面規定 後 回傳 ,且 對空堆疊拋出 。 以下子類別哪些違反 LSP?為什麼?

  1. :每次 push/pop 額外印出一行訊息,其他行為相同。

  2. :最多放 10 個,第 11 次 拋出例外。

  3. :空堆疊 回傳 而不拋例外。

(a)  合規:契約全部保留,只多了副作用。 (b)  前置條件變強(父類別的 沒有「數量上限」的要求)——規則 1。 (c)  後置條件變弱(父類別承諾會拋例外,子類別不拋)——規則 2;依賴「空堆疊會拋例外」寫的程式會失控。