值與實體:為什麼需要「可變的物件」

先想想數字 5。你把它加 1,並不是「改變了 5」,而是得到一個全新的值 6。 5 本身不可變(immutable),除了它的值之外沒有任何「身分」。 Week 5 學過的 也是這樣:兩個欄位相同的 record 就是「同一個值」,可以放心用 比較。

再想想你的銀行帳戶。你存入 100 元,並不會得到一個新帳戶, 而是同一個帳戶的餘額被更新了。如果好幾個人共用一個帳戶,他們必須看到同一份資料 ——每次操作都複製一個新帳戶出來,是不可能正確的。

值沒有身分,只有內容:、、。相等就是內容相等。
實體有身分,且狀態會隨時間改變:銀行帳戶、聊天室、發電機。 兩個實體即使此刻內容一樣,也是不同的東西。
Java 用 表達值,用可變的 class 表達實體。 一個 只能表達實體在某一瞬間的快照,不能表達實體本身。

類別的解剖:欄位、建構子、方法與

一個類別(class)是「可變物件的藍圖」。下面這個 是本節的主角, 先看全貌,再逐一拆解:

public class BankAccount implements Account {

    // 私有欄位:只有這個類別裡的程式碼看得到
    private int balance;                        // 資料不變量:balance >= 0

    /** 建構子:確保物件「出生」時就處於合法狀態。 */
    public BankAccount(int initialDeposit) {
        if (initialDeposit < 0) {
            throw new IllegalArgumentException("開戶金額不可為負數:" + initialDeposit);
        }
        this.balance = initialDeposit;
    }

    public void deposit(int amount) {
        if (amount < 0) {                       // 違反前置條件 → 拒絕
            throw new IllegalArgumentException("存款金額不可為負數:" + amount);
        }
        this.balance = this.balance + amount;   // 不變量仍成立
    }

    public void withdraw(int amount) {
        if (amount < 0) {
            throw new IllegalArgumentException("提款金額不可為負數:" + amount);
        }
        if (amount > this.balance) {            // 物件目前的「狀態」不允許此操作
            throw new IllegalStateException("餘額不足:餘額 " + this.balance + ",欲提領 " + amount);
        }
        this.balance = this.balance - amount;   // 因為 amount <= balance,結果仍 >= 0
    }

    public int getBalance() {                   // Getter:讀得到、寫不到
        return this.balance;
    }
}
  • :——物件的狀態。每個 物件都有自己的一份。

  • :名字與類別相同、沒有回傳型別(連 都沒有)的特殊方法, 在 時執行。它的任務是讓物件一出生就滿足所有不變量。

  • :定義在類別內的函式,可以直接使用該物件的欄位。 讀作「請 這個物件執行 deposit」。

  • :在方法內代表「目前正在被操作的那個物件」。 就是「我的 balance」。 當沒有同名的參數或區域變數時, 可以省略,但寫出來更清楚。

、、—— 字串與陣列都是物件。 是呼叫方法(有括號), 是讀取欄位(沒有括號,而且不能賦值)。 現在你只是從「使用別人的類別」升級成「設計自己的類別」。

存取修飾子:誰看得到?

欄位、方法、甚至整個類別都可以加上:

@llL0.5@ 修飾子 & 名稱 & 誰可以存取
& 公開 & 任何地方的任何程式碼。
& 私有 & 只有同一個類別裡的程式碼。
(不寫) & 套件私有(package-private) & 同一個套件內如同 ,套件外如同 。
& 受保護 & 同套件,加上所有子類別(第 [sec:subtyping] 節)。

存取修飾子講的是程式碼寫在哪裡(語法範圍),而不是執行時是哪個物件。 所以在 類別裡,一個帳戶物件的方法可以讀寫另一個帳戶物件的 :

public void transferTo(BankAccount other, int amount) {
    this.withdraw(amount);
    other.balance = other.balance + amount;   // 合法:同一個類別內
}

這在寫 、 這類「同型別互動」的方法時非常方便。

封裝的定義與兩個理由

把欄位標為 以隱藏內部狀態,並提供公開方法作為唯一的互動管道。 這樣做有兩個理由:

  1. 完整性(integrity):我們能維持—— 「在每一次方法呼叫之前與之後都必須成立的性質」。因為外界寫不到 , 根本無法編譯;想改餘額只能經過 /, 而那裡有我們的檢查邏輯。於是「餘額永遠 \(\ge 0\)」成為保證,而非期望。

  2. 抽象(abstraction):把介面(外界看得到的公開方法)與實作(私有欄位與方法內容)分開。 使用者只需認識介面,認知負擔大幅降低;而我們日後想把餘額改用 、改成以「分」為單位、 甚至改存到資料庫,只要 的行為不變,呼叫者完全不會察覺。

封裝就像一顆膠囊:狀態在裡面,外界只能敲門(呼叫公開方法),不能直接伸手進去。

用例外守住不變量

當方法被要求做不可能的事——存負數、提領超過餘額——正確的反應是立刻失敗, 而不是默默做出錯誤結果。Java 的做法是。 內建的兩種例外幾乎涵蓋所有情況:

@lL0.62@ 例外 & 何時使用
& 參數不合法:呼叫者違反了前置條件(例如 )。
& 參數沒問題,但物件目前的狀態不允許這個操作(例如餘額 120 時 )。

呼叫端若能處理,就用 / 接住:

try {
    acct.withdraw(1000);                                 // 餘額不足
} catch (IllegalStateException e) {
    System.out.println("攔到 IllegalStateException:" + e.getMessage());
}
assert acct.getBalance() == 120 : "失敗的操作不應改變狀態";

初學者常為了讓錯誤訊息消失而寫 。 這等於告訴程式「所有錯誤——包含你沒想到的邏輯 bug、、系統故障——通通忽略」。 請只接住你知道怎麼處理的例外;不知道怎麼處理,就讓它往上傳。

Week 2 學的 用於檢查「我自己的程式邏輯絕不該違反」的事(例如迴圈不變量、後置條件), 而且預設不執行,是開發期的自我檢查。 例外則用於「呼叫者或外界可能會做錯」的事,永遠生效,是公開介面的一部分。 的 應該丟例外而不是 , 因為關掉 之後負數存款就會悄悄成功。

Getter 與 Setter

Getter 讓外界「讀」一個私有欄位(),但不能「寫」。
Setter 讓外界「設定」某個屬性(例如 ), 它可以先檢查再接受,也可以拒絕(不改狀態或丟例外);成功後對應的 getter 應回傳剛設定的值。

注意 刻意不提供 : 「任意設定餘額」不是一個合理的帳戶操作。 也不是 setter—— 它改變的是餘額,但你設定的是「增加多少」而不是「餘額變成多少」。 不要反射性地為每個欄位都寫一對 getter/setter; 先問:這個型別合理的操作是什麼?公開的方法應該對應操作,而不是對應欄位。

套件與

程式變大以後,「所有類別都在同一個資料夾」會撞名(世界上有多少人寫過 ?)。 把相關的類別收成一組,並形成階層:

package au.edu.anu.comp1110.bank;     // 檔案第一行:宣告這個檔案屬於哪個套件

import java.util.Arrays;              // 引入單一類別
import java.util.*;                   // 引入整個套件(不建議:容易撞名)
  • 套件名稱全部小寫,資料夾結構必須與套件名稱一致,例如:
    。

  • 是「套件.類別」,例如 。 只是讓你可以省略前綴。

  • 對外發布的程式庫慣例用「反轉的網域名稱」開頭:Google 的是 ,ANU 的會是 。

  • 沒寫 的檔案屬於預設套件(default package)。它只適合小型練習: 明確套件裡的程式碼無法存取預設套件裡的東西。本教材的範例為了方便都用預設套件。

  • 執行有套件的類別要用完整名稱,且要站在套件根目錄的上一層:
    。

練習

設計一個 類別:欄位 (目前油量)與 (油箱容量), 資料不變量 \(0 \le \code{fuel} \le \code{capacity}\)。 方法: 每小時耗油 5 單位,油不夠時拋出 ; 加油,會溢出或 為負時拋出 ; 是 getter。寫一個 用 測試每一條規則。

下面的類別有一個嚴重的封裝漏洞,它在哪裡?外界可以怎麼利用它打破不變量?

public class Roster {
    private String[] names = new String[10];   // 不變量:沒有 null 之後還有名字
    private int count = 0;
    public void add(String n) { names[count] = n; count = count + 1; }
    public String[] getNames() { return names; }
}

把內部陣列的參照直接交出去。呼叫者寫 或 就能繞過 改動內部狀態,不變量形同虛設。 解法是回傳防禦性複製(defensive copy):。 第 [sec:defensive] 節會再深入討論。