技術筆記 GoF 設計模式與重構
程式設計核心

GoF 設計模式與重構

SOLID23 種模式Java 實作Code Smell重構

設計模式不是具體的程式碼,而是解決特定問題的思維框架——1994 年 GoF 四位作者把反覆出現的問題整理成通用解法,順帶讓開發者之間有了共同的「設計語言」。模式是重構的終點,不是起點:先寫出會痛的程式碼,才知道要往哪個模式收斂。

SOLID 五大設計原則

五條原則單看定義都很抽象,真正有用的是「違反時長什麼樣」。每條配一個具體反例與修正方向。

S單一職責 (SRP)
一個類別應該只有一個引起它變化的原因。
違反的樣子OrderService 同時算運費、寄通知信、寫稽核 log。行銷要改信件模板得動它,財務要改運費也得動它——同一個檔案有五個互不相干的變更理由。
修正方向拆成 ShippingCalculator/NotificationSender/AuditLogger,OrderService 只負責編排順序。判斷方法比背定義有用:翻 git log,看這個檔案的 commit 主題是不是分屬不同領域。
O開閉原則 (OCP)
對擴展開放,對修改封閉。
違反的樣子每支援一種新支付方式,就回到 PaymentService 那個 if-else 加一個分支——動到的是「已經測過、已經上線半年」的那段程式碼。
修正方向定義 PaymentStrategy 介面,新增支付方式=新增一個類別,既有程式碼一行都不用改。代價寫在「重構實戰」分頁:分支從編譯期可見變成執行期綁定。
L里氏替換 (LSP)
子類必須能替換其基類而不影響程式正確性。
違反的樣子Square extends Rectangle,覆寫 setWidth 時連 height 一起改。任何寫著「設寬 5、設高 4,面積應該是 20」的既有程式碼,把物件換成 Square 就錯了——而且編譯完全通過。
修正方向繼承的前提是行為相容,不是名詞上像不像。正方形是長方形在幾何上成立、在行為上不成立。改用組合,或讓兩者各自實作 Shape 介面。
I介面隔離 (ISP)
不應強迫用戶端依賴它不使用的方法。
違反的樣子一個 UserService 介面塞了 30 個方法。測試裡想 mock 它,得先實作那 30 個;只需要 findById 的類別,也被迫依賴「刪除使用者」這件事。
修正方向依使用情境切成小介面(UserFinder/UserWriter)。實作類可以同時實作多個介面,但每個呼叫端只依賴自己真正用到的那一個。
D依賴倒置 (DIP)
依賴於抽象(介面)而非具體實作。
違反的樣子OrderService 裡直接 new MySqlOrderRepository()——高階的業務規則依賴了低階的儲存細節。換資料庫要動業務程式碼,寫單元測試也得真的連上 DB。
修正方向業務層定義 OrderRepository 介面,且這個介面屬於業務層而不是資料層(這才是「倒置」的意思),實作由外部注入。Spring 的建構子注入就是這件事的機械化。

學習模式的三個建議

不要過度設計
模式是為了解決耦合問題。如果你還沒感覺到程式碼「痛」,就不要先引入模式。
觀察 JDK 與 Spring
java.io 是裝飾器、Spring AOP 是代理、JdbcTemplate 是範本方法。框架裡全是模式,讀原始碼比背定義有效。
建議的學習順序
Singleton → Strategy → Observer → Factory → Decorator。先掌握高頻模式,其他等遇到再補。

GoF 23 種模式完整索引

先用上面的分類篩選,點任一列看完整說明與 Java 範例。

精選最常見的四種模式,都附上 Spring 框架裡的實際對應——這些不是教科書練習,是你每天在用的東西。

單例模式(Singleton)— 雙重檢查鎖定

創建型 · 確保資源唯一性。

DatabasePool.java java
public class DatabasePool {
    // volatile 確保多執行緒下的記憶體可見性
    private static volatile DatabasePool instance;

    private DatabasePool() {}

    // 雙重檢查鎖定 (DCL) — 執行緒安全且效能較好
    public static DatabasePool getInstance() {
        if (instance == null) {                        // 第一次檢查(無鎖,快速)
            synchronized (DatabasePool.class) {
                if (instance == null) {                // 第二次檢查(有鎖,安全)
                    instance = new DatabasePool();
                }
            }
        }
        return instance;
    }
}

// Spring 中更推薦:直接用 @Component,框架保證 Bean 唯一

策略模式(Strategy)— Spring Bean Map 注入

行為型 · 消除 if-else,動態切換演算法。

PaymentService.java java
// 1. 定義策略介面
interface PaymentStrategy {
    void pay(int amount);
    String getType();
}

// 2. 具體策略(Spring Bean)
@Service("CreditCard")
class CreditCardPay implements PaymentStrategy {
    public void pay(int amount) { System.out.println("信用卡支付: " + amount); }
    public String getType() { return "CreditCard"; }
}

@Service("LinePay")
class LinePayStrategy implements PaymentStrategy {
    public void pay(int amount) { System.out.println("LinePay 支付: " + amount); }
    public String getType() { return "LinePay"; }
}

// 3. 上下文:Spring 自動將所有 PaymentStrategy 注入 Map
@Service
class PaymentService {
    @Autowired
    private Map<String, PaymentStrategy> strategies; // key = BeanName

    public void process(String type, int amount) {
        strategies.get(type).pay(amount); // 動態選擇,不需要 if-else!
    }
}

觀察者模式(Observer)— Spring 事件驅動

行為型 · 解耦業務邏輯與後續通知。

OrderService.java java
// 1. 定義事件
class OrderCreatedEvent extends ApplicationEvent {
    private final Order order;
    public OrderCreatedEvent(Object source, Order order) {
        super(source); this.order = order;
    }
    public Order getOrder() { return order; }
}

// 2. 發布者(不需知道有誰在監聽)
@Service
class OrderService {
    @Autowired ApplicationEventPublisher publisher;

    public void createOrder(Order order) {
        // 核心業務邏輯...
        publisher.publishEvent(new OrderCreatedEvent(this, order)); // 發布
    }
}

// 3. 訂閱者(可以有多個,互相獨立)
@Component class EmailListener {
    @EventListener
    public void sendConfirmEmail(OrderCreatedEvent e) { /* 發確認信 */ }
}

@Component class InventoryListener {
    @EventListener
    public void deductStock(OrderCreatedEvent e) { /* 扣庫存 */ }
}

範本方法(Template Method)— 資料匯出框架

行為型 · 複用流程骨架,子類只決定細節。

DataExporter.java java
// 抽象類別定義固定流程骨架
abstract class DataExporter {

    // 範本方法(final 防止子類改變骨架順序)
    public final void export(String filename) {
        List<Object> data = fetchData();      // ← 子類實作
        String formatted  = format(data);     // ← 子類實作
        writeToFile(formatted, filename);     // 固定步驟
        System.out.println("匯出完成: " + filename);
    }

    protected abstract List<Object> fetchData();
    protected abstract String format(List<Object> data);

    private void writeToFile(String data, String f) {
        System.out.println("寫入檔案 " + f);
    }
}

// CSV 子類只需實作差異部分
class CsvExporter extends DataExporter {
    protected List<Object> fetchData() { return List.of("Alice", "Bob"); }
    protected String format(List<Object> d) {
        return String.join(",", d.stream().map(Object::toString).toList());
    }
}

// Spring 中的 JdbcTemplate 就是典型的範本方法應用

這一節原本是舊筆記「全域底層架構修煉」的第七章(那頁的七個主題已全部拆成獨立內容,原頁不再保留),2026-08-04 併進本頁——因為它談的三件事(識別 Code Smell、用模式消滅條件判斷、防禦性編程)跟模式清單是同一個主題的兩端:模式是重構走到最後收斂出來的形狀,不是設計第一天就該挑的選單。內容依照本站章節的寫法補上推導過程、真實失敗場景與「什麼時候不要用」。

談 Code Smell 與模式之前,先把要解決的問題看清楚:程式碼只寫一次,卻會被讀幾十次、改幾十次。重構的目標從來不是美觀,而是讓「多加一個需求」的成本不要隨專案年齡往上翹。

腐化不是一次發生的

第 1 個月:calculateShipping() 12 行,一個 if-else 兩個分支
第 4 個月:加了離島加價        → 巢狀 if 第二層
第 7 個月:加了促銷免運        → 第三層,方法變成 80 行
第 11 個月:要改「離島 + 促銷」的組合
            → 得先讀懂全部 80 行才敢動一行
第 13 個月:沒人敢動了,新需求改成複製一份 calculateShippingV2()
第 18 個月:兩份的行為已經不一致,但沒有人說得出差在哪
真正的成本出現在第 11 步:理解成本。每多一層巢狀,要正確改動就得多背一組前置條件。而第 13 步那次複製貼上,替這段程式碼建立了一個「改這裡要記得改那裡」的隱性契約——這個契約沒有寫在任何地方,也沒有任何工具會提醒你。

重構的定義裡有一句常被跳過的話

Martin Fowler 的定義是:在不改變外部可觀察行為的前提下,改善內部結構。「不改變行為」不是附註,是整件事的前提——一旦順手改了行為,出事時你就無法判斷是重構弄壞的,還是新需求本來就有 bug。

💡 可以直接套用的規則:重構的 commit 與行為變更的 commit 分開。混在一起的 diff,reviewer 只能整包相信你。

什麼時候不要重構

  • 沒有測試、也沒有時間補測試——那不叫重構,叫改寫。理由見下方「安全網」那張卡。
  • 這個模組三個月後就要下線——改動成本的複利,對快死掉的程式碼不成立。
  • 為了套模式而重構——還沒被痛過,就不會知道接縫該切在哪裡。
  • 上線前一天——重構的好處是長期的,風險卻是立即的。

Code Smell 常被當成「寫得爛的地方」,但它原本的意思精確得多:一個表面徵狀,暗示底下可能有結構問題。像味道一樣——不一定是病,但值得走過去看一眼。

最值得先學的一組:發散式修改 vs 霰彈式修改

這兩個是同一個問題的兩面,方向剛好相反,合起來就是單一職責原則的操作型定義:

發散式修改 Divergent Change
  一個類別,因為「很多不同的理由」而要改
  → OrderService 這季改了 5 次:運費規則、通知模板、
    稽核欄位、發票格式、優惠計算
  → 職責放太多 ⇒ 要「拆開」

霰彈式修改 Shotgun Surgery
  一個理由,卻要改「很多個類別」
  → 幣別小數位從 2 位改成 4 位,動了 11 個檔案
  → 同一個職責被撒得太散 ⇒ 要「聚攏」
💡 判斷方法比記名詞有用:去翻 git log。同一個檔案的 commit 訊息分屬完全不同的主題,是發散式修改;某類 commit 總是同時動到固定的一組檔案,是霰彈式修改。

幾個常被誤認成「風格問題」的 smell

  • 基本型別偏執:金額用 double、幣別用 String。徵狀是「同一段校驗邏輯到處複製」——因為型別本身不保證任何事。修法是包一個 Money 值物件,把校驗收進建構子,之後全系統只需要驗一次。
  • 資料泥團:String city, String district, String zip, String addr 這四個參數總是一起出現、一起傳、一起改。它們其實已經是一個概念(Address),只是還沒有被命名。
  • 訊息鏈:order.getUser().getProfile().getCompany().getName()。每一個 . 都是一次「我知道你內部長什麼樣」的假設,中間任何一層改結構,你就得跟著改。
  • 用註解解釋「這段在幹嘛」:註解比程式碼還長,通常代表命名與結構沒有表達出意圖。把那段抽成方法、用註解的內容當方法名,註解就不需要了。
不要把 smell 清單當成檢查表逐條消滅。它的價值在於「讓你停下來看一眼」;看完覺得現況合理就走人——這是完全正當的結論。

「用設計模式消滅 if-else」大概是最常被誤解的一句話。if-else 本身沒有錯,它是最直白、最好讀的控制流。真正的問題是同一組分支散落在好幾個地方——加一個新分支要改 N 個檔案,也就是上一張卡的霰彈式修改。

先問一個問題,再決定要不要動它

這組分支只出現在這一個方法裡嗎? 是 → 留著,把它讀順就好。 否 → 往下走。

四個層次,成本逐級上升

L1 衛語句(Guard Clause)
    「不成立就早點 return」提到最前面,消滅巢狀深度
    成本:幾乎為零,任何情況都值得做

L2 表驅動(Map 查表)
    分支只是「輸入 → 一個值」的對應,就不該寫成流程控制
    Map<String, BigDecimal> RATE = Map.of("TW", ..., "JP", ...);
    成本:低。但分支帶的是「行為」而不只是「值」時就不夠用

L3 策略模式(+ Spring Bean Map 注入)
    分支決定的是「用哪一整套演算法」,而且被多處共用
    寫法見本頁「程式碼範例」分頁的 PaymentStrategy
    成本:中,見下方「真正的代價」

L4 狀態模式
    分支決定的是「下一個狀態是什麼」,狀態之間還有轉移規則
    訂單:待付款 → 已付款 → 出貨中 → 完成/取消
    成本:高(類別數=狀態數),但比一堆散落的
         if (status == X && status != Y) 便宜得多

真實失敗場景:三個分支拆成五個檔案

徵狀:新人接手兩週後說「我看不懂運費到底是怎麼算出來的」

診斷:原本 3 個分支的 if-else 被拆成
      ShippingStrategy 介面 + 3 個實作 + 1 個 Factory + 1 個 enum
      讀懂一條流程要開 5 個檔案,
      而這 3 個分支從頭到尾只被一個方法用到

惡化:後來「本島與外島共用同一段折扣」,兩個策略類開始互相繼承
      → 改一個地區的規則會影響另一個地區,比原本的 if-else 更危險

修法:合回一個方法,用衛語句整平,共用的折扣抽成一個私有方法

策略模式真正的代價

  • 編譯期的分支變成執行期的綁定:原本 IDE 的 Find Usages 能一眼看完所有分支,改成 strategies.get(type) 之後就查不到了——靜態分析、重構工具、死碼偵測全部一起失效。
  • 錯字要到執行才炸:Bean 名稱打錯、忘記加 @Service,編譯完全通過,上線才發現。這正是後端底層 ch06談的 IoC 取捨:容器幫你組裝的代價,是組裝錯誤被延後到啟動或呼叫時才暴露。
  • 沒有 else 了:strategies.get(未知的type) 回傳 null,接著就是 NPE。原本的 else 至少會丟一個講得清楚的例外。
💡 折衷做法:用 enum 承載策略(每個 enum 常數實作抽象方法)。分支仍然集中在一個檔案裡看得完,同時保有多型;而且 switch 對 enum 還能讓編譯器檢查「有沒有漏掉哪個分支」——這是策略模式做不到的。

到處加 if (x != null) 之所以永遠加不完,是因為它治的是徵狀。根因是 null 這個值同時承載了三種完全不同的意思:

userRepository.findByEmail(email) 回傳 null,可能代表:
  ① 查得到這個人,但他沒有填 email    (資料的正常狀態)
  ② 查無此人                          (合法、但呼叫端要處理的結果)
  ③ 查詢本身出錯,例外被吞掉了        (bug)

呼叫端拿到 null 之後,無法區分這三件事,只好一律檢查;
於是檢查散落到每一個呼叫點 —— 漏掉任何一個就是 NPE。

四道防線,由外而內

  • ① 邊界定契約:回傳集合時永遠回 Collections.emptyList(),不要回 null(呼叫端可以直接 for,少一層檢查);公開方法的參數用 Objects.requireNonNull(orderId, "orderId 不可為 null") 快速失敗——讓它在「傳錯的那一刻」炸,而不是三層之後在某個不相干的地方炸。
  • ② 回傳型別用 Optional:Optional<User> findByEmail(...) 是在型別上宣告「可能查不到」,呼叫端不處理就編不過。用 map/flatMap/orElse 串起來;.get() 只是把 NPE 換成 NoSuchElementException,不算處理。
  • ③ Null Object 模式:與其回 null 讓每個呼叫點自己判斷,不如回一個「什麼都不做」的實作——GuestUser(權限永遠是空集合)、NoOpNotifier(send 是空實作)。呼叫端的分支直接消失。
  • ④ 讓工具擋:@NonNull/@Nullable 加上 IDE 與靜態檢查,把「這裡可能是 null」從口耳相傳變成編譯期就看得到的資訊。

真實失敗場景:堆疊裡看不到的那一行

徵狀:Map<String, Boolean> featureFlags;
      if (featureFlags.get("newCheckout")) { ... }   ← NPE
      堆疊只指到這一行,但這一行「看起來沒有任何 null 存取」

診斷:key 不存在時 get() 回傳 null(型別是 Boolean 物件),
      而 if 需要的是 boolean 基本型別
      → 編譯器偷偷插了一個 .booleanValue()
      NPE 來自那個原始碼上看不見的方法呼叫(自動拆箱)

修法:featureFlags.getOrDefault("newCheckout", false)
      更好的做法是別讓設定值走 Map<String, Boolean>,
      改成有型別的設定物件(@ConfigurationProperties)

同類陷阱:flag ? 1 : someInteger
          三元運算子兩邊型別不同時,編譯器會把兩邊都拆箱,
          someInteger 為 null 就炸 —— 同樣看不見

Optional 的三個「不要」

  • 不要當欄位:Optional 沒有實作 Serializable,而且每個實例都多包一層物件。欄位需要的是「這個欄位允不允許 null」的明確契約,不是再包一層。
  • 不要當方法參數:呼叫端照樣可以傳 null 進來,你等於要檢查兩件事。要表達可選參數,用多載或 Builder。
  • 不要放進熱路徑的迴圈:每次 map 都會配置新物件。一秒幾萬次的路徑上這是實際成本(見後端底層 ch04的物件配置與 GC)。

重構的定義是「不改變外部可觀察行為」。這句話立刻帶出一個問題:你要怎麼證明沒有改變?靠仔細看是不夠的——會出事的地方,通常正是你沒想到要看的地方。

特徵測試:先釘住「現在」,先不管「應該」

面對一段沒有測試、也沒人說得清規格的老程式碼,正確的第一步不是讀懂它,而是把它現在的行為原封不動地錄下來:

① 挑幾組真實輸入(含邊界值與看起來很怪的 case)丟進去
② 把「實際跑出來的結果」直接寫成斷言 —— 即使那個結果看起來像 bug
③ 這些測試現在全綠。它們不驗證「應該是什麼」,只釘住「現在是什麼」
④ 開始重構。任何一條變紅 = 你動到行為了
⑤ 真的要修那個 bug,另外開一個 commit,並且明確地改掉那條斷言
第 ② 步最違反直覺:明知是 bug 也照樣釘住。因為此刻的目標是隔離變因——如果重構和修 bug 同時進行,測試變紅時你分不出是哪一邊造成的。

真實失敗場景:那個開了三週的重寫分支

徵狀:refactor/order-module 開了三週,合不回去

診斷:期間 main 前進了 200 多個 commit,其中 30 幾個動到同一批檔案
      每次 rebase 都要重新解一輪相同的衝突,
      而且沒有任何測試能證明「這次解對了」

結果:分支放棄,三週的工作丟掉

修法:改用絞殺榕(Strangler Fig)——新舊實作並存,
      用一個開關逐條路徑切過去,每切一條就合併一次,
      任何一刻都可以退回舊實作

「小步」的具體定義

  • 每一步結束時都能編譯、能跑測試(不是「整包寫完再跑」)。
  • 每一步都小到出事時可以直接 git checkout 丟掉重來,而不需要 debug。
  • 能用 IDE 的自動重構(Rename、Extract Method、Inline)就不要手動改——它們是機械保證正確的,人手改容易漏掉一處。

把這一頁四個分頁串起來:GoF 的 23 種模式不是設計時拿來挑一個套用的目錄,而是重構重複走到最後,自然收斂出來的形狀。Fowler 的說法是「往模式的方向重構」(refactoring towards patterns)——先有痛,才有模式。

三個常見的反向操作

  • 先挑模式再想問題:新專案第一天就規劃好抽象工廠與策略介面。問題是需求還沒撞過你,接縫幾乎一定切錯位置;之後每個新需求都會「剛好卡在抽象的縫上」。
  • DRY 的誤用:兩段程式碼碰巧長得一樣就合併,等於宣告「這兩個需求以後永遠會一起變」。真的分岔時,會先長出一個帶 boolean 參數的共用方法,然後是第二個、第三個。DRY 講的是知識不重複,不是字面不重複。
  • 把間接當成解耦:每加一層介面,讀者追一條流程就要多跳一次。抽象的價值是「隱藏會變的細節」;如果底下只有一個實作、而且你說不出第二個會是什麼,那層介面就只是多一個檔案。

可以直接拿來用的三個判準

① 痛過才用     —— 說得出「上次改這裡花了多久、動了幾個檔案」再談模式
② 數得出第二個 —— 講不出第二種情況,就先別抽介面
③ 讀者成本     —— 新人追一條流程要開幾個檔案?
                  從 1 變成 5,換到的是什麼?
💡 建議把本頁其他分頁的模式清單當成「重構之後長什麼樣」的參考圖鑑來讀,而不是設計時的選單。判斷「該不該抽」永遠比「抽成哪一種」重要。

對照速查

Code Smell 對照表:徵狀 → 根因 → 手法
Code Smell你會觀察到什麼根因常用手法/模式
長方法 Long Method「要改這一行,得先讀完前面 60 行」一個方法裡混了多個抽象層級Extract Method、衛語句
上帝類 God Class檔案 1200 行、建構子 20 個依賴職責邊界從來沒被畫出來Extract Class(依 SRP 拆)
發散式修改同一個檔案的 commit 主題天差地遠一個類別有多個變化的理由拆開:Extract Class
霰彈式修改一個小需求要動 11 個檔案同一個職責被撒得太散聚攏:Move Method/Inline Class
重複出現的 switch同一組 case 在 3 個地方各寫一次分支跟著資料走,而不是跟著行為走多型/策略模式/enum 帶行為
基本型別偏執同一段校驗邏輯到處複製型別不承載任何規則值物件(Money、Email、Address)
訊息鏈a.getB().getC().getD()對別人內部結構的一連串假設Hide Delegate、得墨忒耳定律
註解在解釋「這段在幹嘛」註解比程式碼還長命名與結構沒有表達出意圖Extract Method + 用註解內容當方法名
消滅條件判斷的四個層次:換到什麼、付出什麼
層次什麼時候用換到的好處付出的代價
L1 衛語句任何巢狀超過兩層的方法巢狀變平,主線邏輯浮出來幾乎沒有——所以總是先做這個
L2 表驅動 Map分支只是「輸入 → 一個值」新增分支=加一筆資料,不動程式碼分支帶行為時就不夠用;查不到要自己處理預設
L3 策略模式分支是「一整套演算法」且多處共用新增分支不動既有程式碼(OCP)Find Usages 失效、錯字延後到執行期、檔案數上升
L4 狀態模式分支決定「下一個狀態」且有轉移規則非法轉移能在單一位置擋掉類別數=狀態數;小型狀態機不划算
null 的四道防線
手法擋在哪一層擋掉什麼注意事項
回空集合不回 null方法回傳邊界呼叫端每個 for 迴圈前的檢查「空集合」與「查詢失敗」仍要用別的方式區分
Objects.requireNonNull方法入口錯誤傳播——在傳錯的當下就炸訊息要寫清楚是哪一個參數
Optional 當回傳型別型別系統呼叫端「忘記處理查無資料」不要當欄位或參數;.get() 不算處理
Null Object 模式物件設計呼叫端到處都是的 if 分支它會安靜地什麼都不做,要有辦法觀察得到

GoF 把 23 種模式歸成三大類。行為型佔了將近一半(11 種),這個比例本身就在說明:物件怎麼分工、怎麼互相通知,才是設計上最難的部分。

23 種模式的分佈

創建型 5
結構型 7
行為型 11
創建型(Creational) 5 種
Singleton、Factory Method、Abstract Factory、Builder、Prototype
結構型(Structural) 7 種
Adapter、Bridge、Composite、Decorator、Facade、Flyweight、Proxy
行為型(Behavioral) 11 種
Strategy、Observer、Command、State、Chain、Iterator、Mediator、Memento、Template、Visitor、Interpreter

面試與框架對應

高頻考點:Strategy、Observer、Factory Method、Decorator、Proxy 是 Java 後端面試最常出現的五種。
模式與 Spring 的對應:@Bean(Factory)·@Aspect(Proxy)·@EventListener(Observer)·JdbcTemplate(Template Method)·FilterChain(Chain of Responsibility)