學完本章你將能夠:
以 private 欄位搭配 getter 與 setter 實作完整的封裝,並在 setter 中驗證資料。
分辨四種存取修飾詞的可見範圍,說明欄位為什麼應該宣告為 private。
用 extends 建立繼承關係,判斷 is-a 與 has-a,在繼承與組合之間做正確選擇。
掌握 super 的兩種用途:呼叫父類別建構子與呼叫父類別方法。
正確覆寫方法、理解 @Override 標註的保護作用,並完整說出物件的初始化順序。
依照契約正確覆寫 Object 類別的 toString、equals 與 hashCode。
在初級篇裡,你已經會定義類別、建立物件、撰寫建構子與方法。本章要把物件導向真正的威力釋放出來:封裝(encapsulation)讓每個物件為自己的資料把關,使「不合法的狀態」根本不可能出現;繼承(inheritance)讓新類別站在既有類別的肩膀上,重複利用並擴充其行為。這兩個概念是物件導向三大支柱的前兩根,第三根「多型」將在下一章登場。
本章的範例圍繞兩條軸線:銀行帳戶(示範封裝與資料驗證)以及員工與經理(示範繼承、覆寫與 super)。請務必親手輸入並執行每一個範例——封裝與繼承的許多細節,唯有看到編譯器報錯、看到輸出順序,才會真正內化。
封裝:把資料關進門裡
完整的封裝實踐
封裝的公式很簡單:欄位一律 private,存取一律走公開方法。真正的重點在於:setter 與其他修改狀態的方法,必須在資料進門之前完成驗證。以下是一個完整的示範:
/** * 銀行帳戶:封裝的完整示範。 * 欄位全部 private,外界只能透過公開方法存取, * 每一次修改都經過驗證,守住「餘額永不為負」這條不變條件。 */ public class BankAccount
private String owner; // 戶名:private,外界無法直接竄改 private int balance; // 餘額(元):不變條件是 balance >= 0
/** 建構子:物件誕生的第一刻就必須是合法狀態 */ public BankAccount(String owner, int initialBalance) setOwner(owner); // 重複利用 setter 裡的驗證邏輯 deposit(initialBalance); // 初始金額視為第一筆存款來驗證
/** getter:唯讀出口,外界只能「看」不能「改」 */ public String getOwner() return owner;
/** setter:資料進門前先驗證,不合法就拒收 */ public void setOwner(String owner) // 驗證失敗就拋出例外,讓錯誤在第一時間現形(詳見第 3 章) if (owner == null || owner.isBlank()) throw new IllegalArgumentException("戶名不可為空"); this.owner = owner; // this.owner 是欄位,owner 是參數
/** 注意:刻意不提供 setBalance!餘額只能透過存提款改變 */ public int getBalance() return balance;
/** 存款:金額必須為非負數 */ public void deposit(int amount) if (amount < 0) throw new IllegalArgumentException("存款金額不可為負"); balance += amount; // 通過驗證才允許修改欄位
/** 提款:金額必須為正,且不可超過餘額 */ public void withdraw(int amount) if (amount <= 0) throw new IllegalArgumentException("提款金額必須為正"); if (amount > balance) // 守住不變條件的最後防線 throw new IllegalArgumentException("餘額不足"); balance -= amount;
注意幾個設計決策。第一,|balance| 沒有對應的 setter:不是每個欄位都需要成對的 getter 與 setter,「餘額」在語意上只能透過存款與提款改變,直接開放 |setBalance| 反而是在破壞封裝。第二,建構子重複利用 |setOwner| 與 |deposit| 的驗證邏輯,確保「從物件誕生的第一刻起」所有規則就已生效。用一個測試程式觀察封裝的效果:
/** 封裝的效果:非法操作進不了門,物件狀態永遠合法 */ public class BankAccountDemo
public static void main(String[] args)
BankAccount account = new BankAccount("小明", 1000);
account.deposit(500); // 合法:存入 500 元 account.withdraw(300); // 合法:提出 300 元 System.out.println(account.getOwner() + "的餘額:" + account.getBalance() + " 元");
// account.balance = -999; // 編譯錯誤:balance 是 private
try account.withdraw(9999); // 非法:超過餘額 catch (IllegalArgumentException e) // 物件拒絕進入非法狀態,餘額毫髮無傷 System.out.println("提款失敗:" + e.getMessage()); System.out.println("最終餘額:" + account.getBalance() + " 元");
小明的餘額:1200 元 提款失敗:餘額不足 最終餘額:1200 元
被註解掉的那一行 |account.balance = -999;| 若取消註解,編譯器會直接報錯:|balance| 在 |BankAccountDemo| 中不可見。這正是我們要的效果——錯誤越早被抓到越好,編譯期就擋下,勝過執行期才爆炸。
不變條件:封裝真正守護的東西
「餘額永遠大於等於零」這類在物件整個生命週期中恆真的規則,稱為不變條件(invariant)。封裝的本質不是「把欄位藏起來」這個動作,而是藉由收斂所有修改入口,讓不變條件「無法被破壞」。如果欄位是 public,任何一行程式都可能把它改成非法值,要找出誰改壞的,得翻遍整個專案;欄位是 private 時,嫌疑犯只剩下類別內的少數幾個方法。這就是為什麼欄位應該一律 private——不是教條,而是把除錯範圍從「全世界」縮小到「一個檔案」。
四種存取修飾詞
Java 提供四個層級的存取控制。沒有寫任何修飾詞時稱為「預設存取」(也叫套件私有,package-private):
| 修飾詞 | 同類別 | 同套件 | 不同套件的子類別 | 任何地方 |
|---|---|---|---|---|
| |private| | 可 | 不可 | 不可 | 不可 |
| (預設) | 可 | 可 | 不可 | 不可 |
| |protected| | 可 | 可 | 可 | 不可 |
| |public| | 可 | 可 | 可 | 可 |
實務上的選擇原則:欄位用 |private|;提供給外界的行為用 |public|;打算讓子類別使用或覆寫、但不想對外公開的方法用 |protected|;預設存取則適合「只在同套件內部協作」的輔助類別與方法。原則是從最嚴格的開始給,日後放寬容易,收緊卻可能牽動大量既有程式。
繼承:站在既有類別的肩膀上
extends 與 is-a 關係
繼承用 |extends| 關鍵字宣告。子類別(subclass)自動獲得父類別(superclass)的非 private 成員,並可以新增自己的欄位與方法、覆寫父類別的方法。判斷該不該繼承,最好的試金石是 is-a 句型:「經理是一種員工」說得通,所以 |Manager extends Employee| 合理;「汽車是一種引擎」說不通,所以汽車不該繼承引擎。
/** * 員工:繼承體系的父類別。 * 把所有員工共同的狀態與行為集中在這裡,讓子類別重複利用。 */ public class Employee
private String name; // 姓名:private,連子類別都不能直接存取 private int salary; // 月薪(元)
/** 建構子:初始化所有員工共同的欄位 */ public Employee(String name, int salary) this.name = name; this.salary = salary;
public String getName() return name;
public int getSalary() return salary;
/** 年薪:預設演算法是月薪乘以 12 */ public int annualPay() return salary * 12;
/** 自我介紹:設計上預期會被子類別覆寫 */ public String describe() return "員工" + name + ",月薪 " + salary + " 元";
/** * 經理:繼承 Employee,額外持有「管理加給」。 * Manager is-a Employee:任何需要員工的地方都能接受經理。 */ public class Manager extends Employee
private int bonus; // 每月管理加給:Manager 特有的欄位
/** 建構子:先請父類別初始化共同欄位,再處理自己的欄位 */ public Manager(String name, int salary, int bonus) super(name, salary); // 必須是第一行:先蓋地基,再蓋樓 this.bonus = bonus;
/** 覆寫年薪:在父類別的計算結果上追加管理加給 */ @Override public int annualPay() // super.annualPay():重複利用父類別的公式,避免複製貼上 return super.annualPay() + bonus * 12;
/** 覆寫自我介紹:沿用父類別的描述再補充自己的資訊 */ @Override public String describe() return super.describe() + ",加給 " + bonus + " 元";
/** 子類別自動擁有父類別的公開成員 */ public class InheritanceDemo
public static void main(String[] args)
Employee emp = new Employee("小華", 40000); Manager mgr = new Manager("小美", 60000, 15000);
// getName 定義在 Employee:Manager 一行都沒寫就直接能用 System.out.println(emp.describe()); System.out.println(mgr.describe()); System.out.println(mgr.getName() + "的年薪:" + mgr.annualPay() + " 元");
員工小華,月薪 40000 元 員工小美,月薪 60000 元,加給 15000 元 小美的年薪:900000 元
追蹤一下第三行輸出:|mgr.annualPay()| 執行的是 |Manager| 覆寫的版本,它先呼叫 |super.annualPay()| 取得 \(60000 \times 12 = 720000\),再加上 \(15000 \times 12 = 180000\),總計 900000 元。輸出與推導完全一致。
子類別到底繼承了什麼
一個常見的誤解是「private 欄位不會被繼承」。精確的說法是:子類別的物件確實含有父類別的 private 欄位(|Manager| 物件的記憶體中確實存著 |name| 與 |salary|),否則 |getName()| 不可能運作;但子類別的程式碼不能直接存取這些欄位,必須透過父類別提供的非 private 方法。也就是說:private 欄位是「有繼承、沒門路」,這正是封裝跨越繼承關係依然有效的證明。另外兩件不被繼承的東西是建構子(只能用 |super| 呼叫,不能被當成自己的建構子)與 static 成員的「覆寫資格」(static 方法只有遮蔽、沒有覆寫)。
繼承還是組合
繼承不是重複利用程式碼的唯一手段。另一條路是組合(composition):把既有類別的物件當成欄位持有,將工作「委派」給它,兩者的關係是 has-a(擁有):
/** 引擎:一個獨立的元件類別 */ class Engine /** 發動引擎 */ public void start() System.out.println("引擎發動");
/** 汽車「擁有」引擎(has-a):用組合,不用繼承 */ public class Car
// 把引擎當成欄位持有:Car 的能力建立在 Engine 之上 private final Engine engine = new Engine();
/** 開車:把「發動」這件事委派給引擎物件 */ public void drive() engine.start(); System.out.println("汽車出發");
繼承與組合的選擇準則:先問 is-a 還是 has-a。「經理是一種員工」用繼承;「汽車擁有引擎」用組合。若兩者都說得通,優先選擇組合:繼承會把父類別的實作細節暴露給子類別(耦合較緊),父類別一改,所有子類別都可能被波及;組合只依賴對方的公開介面,關係鬆、容易替換。業界流傳的口訣是「多用組合,少用繼承」——繼承保留給真正穩定的 is-a 階層。
super 關鍵字
|super| 有兩種用途。第一種是呼叫父類別建構子:|super(引數)| 必須寫在子類別建構子的第一行,因為子類別的欄位初始化必須建立在「父類別部分已經初始化完成」的地基上。若你沒有寫,編譯器會自動插入無參數的 |super()|;一旦父類別沒有無參數建構子(例如上面的 |Employee| 與 |BankAccount|),你就必須親手呼叫,否則編譯失敗。第二種用途是呼叫父類別方法:|super.方法名()| 可以在覆寫版本中重複利用父類別的邏輯,|Manager.describe()| 就是典型用法。
/** 定存帳戶:示範 super 與「繼承欄位但不能直接存取」 */ public class SavingsAccount extends BankAccount
private final int ratePercent; // 年利率(整數百分比)
public SavingsAccount(String owner, int balance, int ratePercent) // 父類別沒有無參數建構子,這裡必須明確呼叫 super(...), // 而且它必須是建構子的第一行敘述 super(owner, balance); this.ratePercent = ratePercent;
/** 一年後的預估餘額(本金加單利) */ public int projectedBalance() // 不能直接寫 balance:它是父類別的 private 欄位, // 子類別「擁有」它但「碰不到」,必須走公開的 getter return getBalance() + getBalance() * ratePercent / 100;
public static void main(String[] args) SavingsAccount savings = new SavingsAccount("小美", 10000, 2); System.out.println("戶名:" + savings.getOwner()); System.out.println("一年後預估:" + savings.projectedBalance() + " 元");
戶名:小美 一年後預估:10200 元
方法覆寫與 @Override
覆寫(override)是子類別以完全相同的方法簽章(方法名稱與參數列)重新實作父類別的方法。合法覆寫的規則如下:方法名稱與參數列必須一致;回傳型別必須相同(或是父類別回傳型別的子型別,稱為協變回傳);存取層級只能放寬不能收窄(父類別是 |public|,子類別不能改成 |protected|);不能覆寫 |private|、|static| 與 |final| 方法。注意覆寫(override)與多載(overload)是兩回事:多載是「同名不同參數列」的多個方法並存,發生在同一個類別內也可以。
規則不少,但你不需要背——|@Override| 標註會替你檢查。看看沒有它會發生什麼事:
/** 動物父類別 */ class Animal /** 發出叫聲:設計上預期被子類別覆寫 */ public void makeSound() System.out.println("(某種動物的聲音)");
/** 貓:本想覆寫 makeSound,卻把方法名拼錯了 */ class Cat extends Animal /** 少了大寫 S!這不是覆寫,而是「新增」了一個方法 */ public void makesound() System.out.println("喵!");
/** 觀察拼錯方法名的後果 */ public class OverrideTrap public static void main(String[] args) Cat cat = new Cat(); cat.makeSound(); // 執行繼承來的父類別版本:覆寫根本沒發生 cat.makesound(); // 拼錯字的那個「新方法」還健在
(某種動物的聲音) 喵!
程式照常編譯、照常執行,只是行為完全不是你要的——這種「安靜的錯誤」最難抓。加上 |@Override| 之後,編譯器會驗證「這個方法真的覆寫了父類別的某個方法」,拼錯字立刻現形:
class Cat extends Animal @Override // 明確宣告:我就是要覆寫 public void makesound() // 編譯器立刻抓到拼字錯誤 System.out.println("喵!");
Cat.java:2: error: method does not override or implement a method from a supertype @Override ^ 1 error
拼錯方法名稱(或寫錯參數型別)時,Java 不會報錯,而是默默把它當成新方法或多載,父類別版本原封不動地被繼承下來,程式的行為悄悄跑偏。防禦方法只有一個:每一個覆寫方法都加上 @Override,沒有例外。這行標註零成本,卻能把一類極難除錯的問題轉成一眼可見的編譯錯誤。
建構子鏈與初始化順序
建立一個子類別物件時,初始化像接力賽一樣層層向上又層層落下。完整順序是:(1)父類別靜態初始化區塊(類別第一次載入時,僅一次);(2)子類別靜態初始化區塊(僅一次);(3)父類別實體初始化區塊與欄位初始器;(4)父類別建構子本體;(5)子類別實體初始化區塊與欄位初始器;(6)子類別建構子本體。用程式驗證:
/** 父類別:每個初始化階段都印一行,讓執行順序現形 */ class Parent
// 靜態初始化區塊:類別第一次被載入時執行,而且只執行一次 static System.out.println("1. Parent 靜態初始化區塊");
// 實體初始化區塊:每次 new 都會在建構子本體之前執行 System.out.println("3. Parent 實體初始化區塊");
/** 父類別建構子 */ Parent() System.out.println("4. Parent 建構子");
/** 子類別 */ class Child extends Parent
static System.out.println("2. Child 靜態初始化區塊");
System.out.println("5. Child 實體初始化區塊");
/** 子類別建構子:第一行隱含 super() */ Child() // 編譯器自動插入 super():先完成父類別的初始化 System.out.println("6. Child 建構子");
/** 驗證完整的初始化順序 */ public class InitOrder public static void main(String[] args) System.out.println("=== 第一次 new Child() ==="); new Child(); System.out.println("=== 第二次 new Child() ==="); new Child(); // 靜態區塊不會再執行
=== 第一次 new Child() === 1. Parent 靜態初始化區塊 2. Child 靜態初始化區塊 3. Parent 實體初始化區塊 4. Parent 建構子 5. Child 實體初始化區塊 6. Child 建構子 === 第二次 new Child() === 3. Parent 實體初始化區塊 4. Parent 建構子 5. Child 實體初始化區塊 6. Child 建構子
觀察兩個關鍵:靜態區塊屬於「類別」,只在類別載入時執行一次,第二次 |new| 就不再出現;實體層級的初始化一定是「父類別先完成、子類別才開始」,這條建構子鏈(constructor chaining)一路追溯到所有類別的始祖——|Object|。
人人都是 Object 的後代
沒有寫 |extends| 的類別,一律隱含繼承 |java.lang.Object|。因此 |Object| 定義的方法人人都有,其中三個最值得正確覆寫:|toString|(物件的文字描述)、|equals|(邏輯相等的定義)與 |hashCode|(雜湊碼,供雜湊集合使用)。
toString、equals 與 hashCode 的正確覆寫
預設的 |toString| 回傳「類別名加雜湊碼」(例如 |Book@1b6d3586|),對人類毫無意義;預設的 |equals| 等同於 |==|,只認「同一個物件」。以下是三者一起正確覆寫的標準範本:
import java.util.Objects;
/** * 書籍:示範 toString、equals 與 hashCode 的正確覆寫。 * 以 ISBN 作為「邏輯身分」:ISBN 相同就視為同一本書。 */ public class Book
private final String isbn; // 國際標準書號:身分依據 private final String title; // 書名:只是描述性資料
public Book(String isbn, String title) this.isbn = isbn; this.title = title;
/** 覆寫 toString:println 會自動呼叫,回傳有意義的描述 */ @Override public String toString() return "Bookisbn=" + isbn + ", title=" + title + "";
/** * 覆寫 equals:定義「邏輯相等」。 * 標準流程:同一物件快速通過;null 或型別不符直接否定; * 最後才比較身分欄位。 */ @Override public boolean equals(Object obj) if (this == obj) // 1. 自己跟自己一定相等 return true; if (obj == null || getClass() != obj.getClass()) return false; // 2. null 與不同類別都不相等 Book other = (Book) obj; // 3. 檢查過了,安全向下轉型 return isbn.equals(other.isbn); // 4. 只以 ISBN 判斷身分
/** 覆寫 hashCode:equals 相等的物件必須有相同的雜湊碼 */ @Override public int hashCode() return Objects.hash(isbn); // 與 equals 使用同一組欄位
這份範本蘊含 |equals| 契約的幾個要求:自反性(|x.equals(x)| 恆為 |true|,由第 1 步保證)、對稱性(|x.equals(y)| 與 |y.equals(x)| 結果一致,用 |getClass()| 比較型別可確保這點;若改用 |instanceof|,則允許子類別物件參與比較,但必須小心子類別再加欄位時對稱性會破裂)、null 安全(|x.equals(null)| 必須回傳 |false| 而非拋例外,由第 2 步保證)。參數型別必須是 |Object|——寫成 |equals(Book obj)| 就變成多載,集合類別呼叫的仍是預設版本,這也是務必加 |@Override| 的原因之一。
/** 驗證 toString 與 equals、hashCode 契約 */ public class BookDemo
public static void main(String[] args)
Book a = new Book("978-0134685991", "Effective Java"); Book b = new Book("978-0134685991", "Effective Java");
// println 自動呼叫 toString() System.out.println(a);
// == 比較「參考」:a 與 b 是兩個不同的物件 System.out.println("a == b:" + (a == b));
// equals 比較「邏輯身分」:ISBN 相同即視為同一本書 System.out.println("a.equals(b):" + a.equals(b));
// 契約檢查:equals 相等,hashCode 也必須相等 System.out.println("雜湊碼相同:" + (a.hashCode() == b.hashCode()));
Bookisbn=978-0134685991, title=Effective Java a == b:false a.equals(b):true 雜湊碼相同:true
|hashCode| 與 |equals| 之間有一條鐵的契約:equals 判定相等的兩個物件,hashCode 必須回傳相同的值。只覆寫 |equals| 而不覆寫 |hashCode|,兩個「邏輯上相等」的物件會落在雜湊集合的不同格子裡——|HashSet| 去不了重、|HashMap| 用等值的鍵查不到資料,而且這種錯誤時隱時現,極難除錯(第 4 章會親眼見到後果)。口訣:兩者永遠成對覆寫,且使用同一組欄位。
final:封印繼承與覆寫
|final| 用在類別上表示「禁止繼承」,用在方法上表示「允許繼承類別,但這個方法不准覆寫」。標準函式庫中 |String| 與 |Integer| 都是 final 類別,確保它們的不可變性質不會被子類別破壞:
/** final 類別:禁止被繼承,確保行為不會被子類別竄改 */ public final class AuthToken
private final String value; // final 欄位:一旦設定即不可變
public AuthToken(String value) this.value = value;
public String getValue() return value;
/** 允許繼承,但封印關鍵方法 */ class PaymentBase /** final 方法:稽核邏輯攸關安全,不准子類別改寫 */ public final void audit() System.out.println("寫入稽核紀錄");
設計繼承體系是一種承諾:父類別的每個可覆寫方法,都是留給未來子類別的擴充點。若一個類別當初不是為了被繼承而設計,宣告成 |final| 反而是負責任的做法——這也是「多用組合、少用繼承」精神的延伸。
本章小結
封裝的公式:欄位一律 |private|,修改一律經過會驗證的公開方法;其目的在守護不變條件,把除錯範圍從全專案縮小到單一類別。
不是每個欄位都需要 setter;像餘額這類狀態,應該只透過語意明確的操作(存款、提款)改變。
四種存取修飾詞由嚴到寬:|private|、預設(同套件)、|protected|、|public|;原則是從最嚴格的開始給。
繼承表達 is-a 關係;組合表達 has-a 關係;兩者都說得通時優先用組合,降低耦合。
子類別物件含有父類別的 private 欄位,但不能直接存取,必須透過父類別的公開方法。
|super(...)| 呼叫父類別建構子且必須是第一行;|super.方法()| 可在覆寫版本中重複利用父類別邏輯。
每個覆寫方法都要加 |@Override|,把拼錯名稱、寫錯參數的隱形錯誤轉成編譯錯誤。
初始化順序:父靜態、子靜態(各僅一次)、父實體區塊、父建構子、子實體區塊、子建構子。
|toString| 讓輸出可讀;|equals| 定義邏輯相等且參數必須是 |Object|;|equals| 與 |hashCode| 永遠成對覆寫並使用同一組欄位。
|final| 類別禁止繼承、|final| 方法禁止覆寫;不為繼承而設計的類別,宣告 final 是負責任的做法。
本章練習
觀念題:為什麼「把欄位宣告成 public,但大家約定好不要亂改」不是可行的封裝策略?請從不變條件與除錯成本兩個角度說明。(提示:回想 |balance| 若被任意一行程式改成負數,要如何找出兇手。)
實作題:設計 |Temperature| 類別,內含攝氏溫度欄位,setter 拒絕低於絕對零度(攝氏負 273.15 度)的值;另提供 |toFahrenheit()| 方法。撰寫測試程式驗證合法與非法輸入。(提示:非法輸入時拋出 |IllegalArgumentException|。)
實作題:建立 |Vehicle| 父類別(欄位:廠牌、時速上限)與 |ElectricCar| 子類別(新增欄位:電池容量),子類別建構子必須用 |super| 初始化共同欄位,並覆寫 |describe()| 方法沿用父類別描述再補充電池資訊。(提示:模仿本章 |Employee| 與 |Manager| 的寫法。)
觀念題:下列類別的 |main| 會印出什麼?先在紙上推導再執行驗證:父類別與子類別各有一個靜態區塊、一個實體區塊與一個建構子,且 |main| 中連續 |new| 了兩次子類別。(提示:靜態區塊只執行一次。)
實作題:為 |Student| 類別(欄位:學號、姓名)以學號為身分依據覆寫 |equals| 與 |hashCode|,並寫程式驗證:兩個學號相同、姓名不同的物件 |equals| 為 |true| 且雜湊碼相同。(提示:使用 |Objects.hash| 與 |getClass()| 檢查。)
挑戰題:把 |BankAccount| 改用組合的方式重新設計一個 |JointAccount|(聯名帳戶,內含兩個 |BankAccount|),體會「不透過繼承也能重複利用」的感覺。(提示:提款時先檢查兩個帳戶的餘額總和。)