學完本章你將能夠:
用一句話說明 SOLID 五大原則並辨識違反範例。
實作單例、工廠、建造者、策略、觀察者、裝飾者六個常用模式。
用 Lambda 現代化策略模式,並辨認 JDK 中的模式應用。
熟練使用 |record|、|sealed| 類別與 text block。
用 switch 模式匹配搭配 |sealed| 達成編譯期窮盡性檢查。
綜合以上打造一個簡潔的運算式求值器。
歡迎來到進階篇的最後一章,也是全系列的終點。設計模式是前輩們把「反覆出現的 設計問題」歸納出的通用解法,是工程師之間的共通詞彙。但學設計模式有個常見誤區: 把模式當成必須硬套的教條。本章希望傳達的是——模式是詞彙,不是規則; 理解每個模式「解決什麼問題」,遠比背下它的類別圖重要。
更棒的是,現代 Java 讓許多模式變得更輕巧:Lambda 讓策略模式幾乎不必寫類別, |record| 讓不可變物件一行搞定,|sealed| 加上 switch 模式匹配則帶來 函數式語言般的表達力。本章前半回顧經典模式,後半擁抱現代語法,最後用一個壓軸 範例把兩者融合,為你的 Java 學習之旅畫下句點。
設計原則速覽:SOLID
好的設計模式都是 SOLID 原則的具體展現。先用一句話掌握每一條:
S 單一職責:一個類別只該有一個改變的理由。
O 開放封閉:對擴充開放、對修改封閉。
L 里氏替換:子型別必須能無痛替換父型別。
I 介面隔離:不該強迫實作用不到的方法。
D 依賴反轉:依賴抽象而非具體實作。
其中開放封閉原則最為核心,值得展開。反例是:每新增一種形狀就要去改 「計算面積」的那個大 |if-else|(修改既有程式,容易改壞)。正解是讓 每種形狀實作共同介面,新增形狀時只需新增一個類別,完全不動既有程式碼:
/** * 開放封閉:面積計算依賴 Shape 抽象。 * 新增形狀只要新增類別,不必修改既有的計算邏輯。 */ public class OpenClosed
interface Shape double area(); // 抽象:每種形狀自己算面積
record Circle(double r) implements Shape public double area() return Math.PI * r * r; record Square(double side) implements Shape public double area() return side * side;
public static void main(String[] args) Shape[] shapes = new Circle(2), new Square(3) ; for (Shape s : shapes) // 這段程式碼永遠不必因新增形狀而修改 System.out.printf(" s.getClass().getSimpleName(), s.area());
Circle 面積:12.57 Square 面積:9.00
設計模式傳統上分三大類:建立型(如單例、工廠、建造者,關注「如何產生 物件」)、結構型(如裝飾者,關注「如何組合物件」)、行為型 (如策略、觀察者,關注「物件間如何協作」)。學習心法是:先認出「我遇到的是 哪一類問題」,再挑對應的模式,切勿為用模式而用模式。
單例模式
單例(Singleton)確保一個類別全程只有一個實體。它看似簡單,卻是最能考驗並行 功力的模式。我們逐步演進各種寫法:
/** * 彙整單例的常見寫法,各有取捨。 */ public class Singletons
/** 餓漢式:類別載入即建立,天生執行緒安全,但無法延遲 */ static class Eager private static final Eager INSTANCE = new Eager(); private Eager() public static Eager get() return INSTANCE;
/** 雙重檢查鎖:延遲建立又高效,volatile 不可省 */ static class DoubleChecked // volatile 防止指令重排導致「看到未初始化完成的物件」 private static volatile DoubleChecked instance; private DoubleChecked() public static DoubleChecked get() if (instance == null) // 第一次檢查(免鎖) synchronized (DoubleChecked.class) if (instance == null) // 第二次檢查(持鎖) instance = new DoubleChecked(); return instance;
/** 靜態內部類別:兼顧延遲與執行緒安全,寫法最優雅 */ static class Holder private Holder() private static class Lazy static final Holder INSTANCE = new Holder(); public static Holder get() return Lazy.INSTANCE;
public static void main(String[] args) System.out.println("餓漢式同一實體?" + (Eager.get() == Eager.get())); System.out.println("雙重檢查同一實體?" + (DoubleChecked.get() == DoubleChecked.get())); System.out.println("靜態內部類別同一實體?" + (Holder.get() == Holder.get()));
餓漢式同一實體?true 雙重檢查同一實體?true 靜態內部類別同一實體?true
| 寫法 | 延遲建立 | 備註 |
|---|---|---|
| 餓漢式 | 否 | 簡單、天生安全 |
| 懶漢式加 |synchronized| | 是 | 每次取用都鎖,較慢 |
| 雙重檢查鎖 | 是 | 需 |volatile| |
| 靜態內部類別 | 是 | 優雅、推薦 |
| 列舉(|enum|) | 否 | 最安全,防反射與序列化破壞 |
雙重檢查鎖為什麼一定要 |volatile|?因為 |instance = new ...| 並非 原子操作,它含「配置記憶體、呼叫建構子、把參考指向物件」三步,而 JVM 可能 重排這些步驟。若沒有 |volatile|,某執行緒可能看到「參考已非 null 但物件 尚未初始化完成」的半成品。|volatile| 禁止這種重排,呼應第 3 章的可見性。 《Effective Java》則推薦用 |enum| 實作單例,因為它連反射與序列化攻擊都能 天然抵禦。
工廠模式
工廠模式把「建立物件」的邏輯集中管理。最簡單的「簡單工廠」用一個方法依參數回傳 不同實作;但它每加一種產品就得改工廠內的 |switch|,違反開放封閉。 工廠方法模式更進一步:把「決定建立哪種產品」下放給子類別,新增產品時 只要新增一個工廠子類別。
/** * 工廠方法:抽象工廠定義「生產」的規格,子工廠決定生產什麼。 * 新增運輸方式只要新增產品類別與工廠子類別,不動既有程式。 */ public class FactoryMethod
interface Transport String deliver(); static class Truck implements Transport public String deliver() return "以卡車走陸運"; static class Ship implements Transport public String deliver() return "以貨船走海運";
/** 抽象工廠:把建立動作延遲到子類別 */ abstract static class Logistics abstract Transport createTransport(); // 工廠方法 String plan() return "配送計畫:" + createTransport().deliver(); static class RoadLogistics extends Logistics Transport createTransport() return new Truck(); static class SeaLogistics extends Logistics Transport createTransport() return new Ship();
public static void main(String[] args) System.out.println(new RoadLogistics().plan()); System.out.println(new SeaLogistics().plan());
配送計畫:以卡車走陸運 配送計畫:以貨船走海運
建造者模式
當一個物件有很多參數(尤其含許多選填項),建構子會膨脹成難以閱讀的「伸縮建構子」 (telescoping constructor):一長串同型別參數,呼叫時根本分不清誰是誰。建造者 模式用流暢的鏈式 API 逐項設定,最後 |build| 出不可變物件。
/** * 建造者模式:以鏈式 API 清楚地組裝物件,產出不可變的 HttpRequest。 * 對照標準庫的 HttpRequest.newBuilder()、StringBuilder。 */ public class BuilderDemo
/** 不可變的目標物件,只能透過 Builder 建立 */ static final class HttpRequest
private final String url; // 必填 private final String method; // 選填,有預設 private final int timeout; // 選填,有預設
private HttpRequest(Builder b) this.url = b.url; this.method = b.method; this.timeout = b.timeout; public String toString() return method + " " + url + " (逾時 " + timeout + "s)";
static class Builder
private final String url; // 必填放建構子 private String method = "GET"; // 預設值 private int timeout = 30;
Builder(String url) this.url = url; Builder method(String m) this.method = m; return this; Builder timeout(int t) this.timeout = t; return this; HttpRequest build() return new HttpRequest(this);
public static void main(String[] args) // 只設定需要的項目,可讀性極佳 HttpRequest req = new HttpRequest.Builder("https://api.example.com") .method("POST") .timeout(60) .build(); System.out.println(req);
POST https://api.example.com (逾時 60s)
策略模式
策略模式把「可替換的演算法」封裝成獨立物件。傳統寫法會陷入 |if-else| 地獄:每加一種計費方式就多一個分支。策略模式把每種演算法變成一個策略物件, 注入使用即可。而在現代 Java,由於策略介面通常是函數式介面,可以直接用 Lambda (呼應第 1 章),連類別都省了。
import java.util.function.DoubleUnaryOperator;
/** * 策略模式:折扣演算法可替換。 * 傳統要為每種策略寫一個類別;用 Lambda 則一行搞定。 */ public class StrategyDemo
/** 結帳時注入折扣策略 */ static double checkout(double price, DoubleUnaryOperator discount) return discount.applyAsDouble(price); // 套用策略
public static void main(String[] args) double price = 1000; // 每個策略就是一個 Lambda,取代一整個策略類別 System.out.println("原價:" + checkout(price, p -> p)); System.out.println("九折:" + checkout(price, p -> p * 0.9)); System.out.println("折百:" + checkout(price, p -> p - 100));
原價:1000.0 九折:900.0 折百:900.0
觀察者模式
觀察者模式建立「一對多」的通知關係:一個主題(Subject)狀態改變時,自動通知 所有註冊的觀察者(Observer)。它是事件驅動系統、GUI 與訊息系統的骨架。
import java.util.ArrayList; import java.util.List; import java.util.function.Consumer;
/** * 觀察者模式:新聞社發布消息,所有訂閱者自動收到。 * 觀察者用 Consumer 表達,註冊即訂閱。 */ public class ObserverDemo
/** 主題:維護訂閱者清單並負責廣播 */ static class NewsAgency private final List<Consumer<String>> subscribers = new ArrayList<>(); void subscribe(Consumer<String> s) subscribers.add(s); void publish(String news) // 狀態改變時通知每一個觀察者 subscribers.forEach(s -> s.accept(news));
public static void main(String[] args)
NewsAgency agency = new NewsAgency(); // 兩個觀察者訂閱 agency.subscribe(n -> System.out.println("讀者A收到:" + n)); agency.subscribe(n -> System.out.println("讀者B收到:" + n));
agency.publish("Java 21 發布虛擬執行緒"); // 一發布,全員收到
讀者A收到:Java 21 發布虛擬執行緒 讀者B收到:Java 21 發布虛擬執行緒
裝飾者模式
裝飾者模式讓我們動態地為物件疊加功能,而不必製造大量子類別。經典範例 是飲料加料:在基礎飲料外「包」一層層配料,每層都增添描述與價格。
/** * 裝飾者模式:以「包裹」的方式層層疊加功能。 * 每個裝飾者持有被裝飾者,並在其行為上加料。 */ public class DecoratorDemo
interface Beverage String desc(); int cost(); /** 基礎飲料 */ static class Coffee implements Beverage public String desc() return "咖啡"; public int cost() return 50; /** 裝飾者基底:持有一個被裝飾的飲料 */ abstract static class AddOn implements Beverage protected final Beverage base; AddOn(Beverage base) this.base = base; static class Milk extends AddOn Milk(Beverage b) super(b); public String desc() return base.desc() + "+牛奶"; public int cost() return base.cost() + 10; static class Sugar extends AddOn Sugar(Beverage b) super(b); public String desc() return base.desc() + "+糖"; public int cost() return base.cost() + 5;
public static void main(String[] args) // 一層層包裝:咖啡 → 加牛奶 → 加糖 Beverage drink = new Sugar(new Milk(new Coffee())); System.out.println(drink.desc() + " = " + drink.cost() + " 元");
咖啡+牛奶+糖 = 65 元
你其實早在中級篇就用過裝飾者了!寫法 |new BufferedReader(new InputStreamReader(in))| 正是層層裝飾: |InputStreamReader| 為位元組串流加上「解碼成字元」的功能, |BufferedReader| 再加上「緩衝與讀整行」的功能。Java I/O 整個體系就是 裝飾者模式的大型實踐。認出這一點,你會對標準庫的設計豁然開朗。
|Iterator| 是迭代器模式、|Runnable| 是命令模式、|Comparator| 是策略模式、|Observable| 與事件監聽是觀察者模式——模式無所不在,下表 整理 JDK 中的常見對照:
| JDK 元素 | 對應模式 |
|---|---|
| |Iterator| | 迭代器(Iterator) |
| |Runnable| | 命令(Command) |
| |Comparator| | 策略(Strategy) |
| |InputStream| 家族 | 裝飾者(Decorator) |
| |Collections.unmodifiableList| | 代理/裝飾者 |
現代 Java 特性
record 與 text block
|record| 是 Java 16 正式加入的不可變資料載體:一行宣告就自動 產生建構子、|equals|、|hashCode|、|toString| 與各欄位的存取 方法。它還支援緊湊建構子來做驗證。text block(三個雙引號)則讓多行 字串不再需要滿是 || 與跳脫。
/** * record 自動產生樣板方法;緊湊建構子集中做驗證。 */ public class RecordDemo
/** 一行定義不可變的座標,緊湊建構子確保非負 */ record Point(int x, int y) Point // 緊湊建構子(無參數列) if (x < 0 || y < 0) throw new IllegalArgumentException("座標不可為負");
public static void main(String[] args)
Point p = new Point(3, 4); System.out.println("toString 自動產生:" + p); System.out.println("存取方法:" + p.x() + ", " + p.y());
// 值相等即 equals 相等(自動實作) System.out.println("值相等?" + p.equals(new Point(3, 4)));
// text block:多行字串免跳脫 String json = """ "x": 3, "y": 4 """; System.out.println(json);
toString 自動產生:Point[x=3, y=4] 存取方法:3, 4 值相等?true "x": 3, "y": 4
|record| 不是萬用的。它適合表達「純資料」的不可變值物件(DTO、座標、 金額)。若你需要可變狀態、需要繼承其他類別、或物件的身分(identity) 比它的值更重要(如 JPA 實體),就不該用 |record|。此外,|record| 的欄位是淺層不可變——若欄位是可變集合,仍可能被外部修改,必要時要在緊湊建構子 內做防禦性複製。
sealed 類別與 switch 模式匹配
|sealed|(密封)類別或介面用 |permits| 明確限定「誰可以繼承或 實作我」,讓繼承體系封閉而可控。它與 switch 模式匹配是天作之合:因為子型別是 有限且已知的,編譯器能檢查 switch 是否窮盡所有情況,少寫一個就編譯 報錯,從此不必再寫防禦性的 |default|。
壓軸綜合範例:運算式求值器
讓我們用 |sealed| + |record| + switch 模式匹配,實作一個算術運算式 求值器。運算式只有三種:數字、加法、乘法。傳統做法要嘛在父類別塞一個 |eval| 抽象方法(邏輯分散在各子類別),要嘛寫冗長的訪問者模式 (Visitor)。現代 Java 讓我們把邏輯集中在一個一目瞭然的 switch 裡:
/** * 用 sealed 介面 + record + switch 模式匹配求值算術運算式。 * sealed 保證子型別有限,switch 可省略 default 且窮盡性由編譯器把關。 */ public class ExprEval
/** 密封介面:運算式只可能是這三種 */ sealed interface Expr permits Num, Add, Mul record Num(double value) implements Expr record Add(Expr left, Expr right) implements Expr record Mul(Expr left, Expr right) implements Expr
/** 求值:對每種運算式解構後遞迴計算 */ static double eval(Expr e) return switch (e) // record 解構:直接把欄位綁定成區域變數 case Num(double v) -> v; case Add(Expr l, Expr r) -> eval(l) + eval(r); case Mul(Expr l, Expr r) -> eval(l) * eval(r); // 不需要 default:sealed 已保證窮盡所有情況 ;
public static void main(String[] args) // 建構運算式:(2 + 3) * 4 Expr expr = new Mul(new Add(new Num(2), new Num(3)), new Num(4)); System.out.println("(2 + 3) * 4 = " + eval(expr));
(2 + 3) * 4 = 20.0
短短一個 switch,就取代了傳統訪問者模式的一整套介面與方法。更關鍵的是安全性: 若日後為 |Expr| 新增一種 |Sub|(減法),只要忘了在 switch 補上對應 分支,編譯器就會立刻報錯。這種「編譯期窮盡性檢查」是過去 Java 難以 企及的表達力,也是現代 Java 邁向函數式與資料導向設計的里程碑。
全系列結語:後續的學習地圖
恭喜你走到這裡!從初級篇的第一個 HelloWorld,到中級篇的物件導向與集合,再到 進階篇的並行、JVM 與設計模式,你已經具備資深 Java 工程師的核心知識。但學習 永無止境,以下是接下來值得投入的方向:
框架:|Spring| 與 Spring Boot 是 Java 後端的事實標準, 你在第 5 章親手實作的依賴注入正是它的地基。
測試:|JUnit| 5 與 |Mockito| 是專業開發的必備 技能,第 5 章的迷你測試框架已讓你看穿其原理。
建置工具:|Maven| 與 |Gradle| 負責依賴管理與 自動化建置,是團隊協作的基礎設施。
資料庫進階:ORM(如 JPA/Hibernate)、SQL 調校、索引設計, 延續第 7 章的 JDBC 基礎。
分散式系統:微服務、訊息佇列(Kafka)、容器化(Docker/ Kubernetes),是大型系統的必經之路。
最後一個建議:持續閱讀優秀的原始碼、持續動手寫專案。設計模式、並行 技巧、JVM 原理,都要在真實專案的千錘百鍊中才會內化成直覺。把這三冊當成隨時 可查的地圖,但真正的成長發生在你敲下的每一行程式碼裡。祝你在 Java 的世界走得 又遠又穩!
本章小結
SOLID 是設計的指導原則,其中開放封閉原則(對擴充開放、對修改封閉) 最為核心。
單例有餓漢、懶漢、雙重檢查鎖、靜態內部類別、|enum| 等寫法; 雙重檢查鎖必須用 |volatile|,|enum| 最安全。
工廠方法把「建立哪種產品」下放子類別,實現開放封閉;建造者用鏈式 API 組裝不可變物件。
策略模式封裝可替換演算法,現代 Java 可直接用 Lambda 取代策略類別。
觀察者建立一對多通知;裝飾者動態疊加功能,Java I/O 即其大型實踐。
|record| 是不可變資料載體,自動產生樣板方法並支援緊湊建構子驗證。
|sealed| 限定繼承體系,搭配 switch 模式匹配可達成編譯期窮盡性 檢查,免寫 |default|。
|sealed| + |record| + switch 模式匹配能簡潔地取代訪問者 模式,展現現代 Java 的表達力。
本章練習
觀念題:雙重檢查鎖為什麼一定要用 |volatile|?拿掉它可能發生什麼 問題? (提示:|new| 的三個步驟與指令重排。)
觀念題:什麼情況下不該用 |record|?請至少舉出兩種。 (提示:可變狀態、需要繼承、身分重於值。)
實作題:用 |enum| 實作單例,並說明它為何能抵禦反射與序列化的 破壞。 (提示:|enum| 的實體由 JVM 保證唯一。)
實作題:用建造者模式為一個 |Pizza|(大小必填,配料多個選填) 設計流暢 API,並產出不可變物件。 (提示:配料可用可變參數或多次呼叫累加。)
實作題:用策略模式(Lambda 版)實作一個排序器,能依「金額」或「時間」 排序訂單,策略由呼叫端注入。 (提示:策略型別用 |Comparator<Order>|。)
實作題:擴充運算式求值器,新增 |Sub|(減法)與 |Div| (除法),觀察若忘記在 switch 補上分支,編譯器如何報錯。 (提示:先在 |permits| 加入新型別,再看 switch 的窮盡性檢查。)