iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0

「同一手棋下第三次,就不再是巧合,是套路。抓套路不必知道對面是誰,只要知道這一手叫什麼名字。」
——《阿帕契開源審計錄》¹ 卷二·套路篇

幕間
七號翻動羊皮紙,一條條核對預言家五天前的紀錄:「你第二天說六號查殺,第四天又說他像好人,哪個作數?」
「第四天是我判斷失誤,我認錯。」預言家坦然說道。
七號在旁邊畫了記號:「你會認錯,那我更信你。」

又有人悍跳預言家了。七號沒有像前幾世那樣去猜「這個起跳的人到底是不是狼」。她翻著羊皮紙,把過去幾世看過的悍跳一字排開,然後說:「我不是在說誰是狼。我是說這個套路——第三個發言位起跳、扔一張查殺給後置位、然後盯著對方的表情微調下一句——這輪迴是第三次出現了。而且每次用這套路的人,當晚都沒被刀。」她指的是行為的形狀,不是某一張臉;她甚至不在乎這輪是誰在用它。二號在桌面下不曉得寫著什麼,被四號問到就把炭筆一收,說「隨手記的,沒什麼」,然後岔開話題。七號在羊皮紙角落記下一句:她要學的是認套路,不是認人,因為人每一世都換,套路不換。頭頂上,法官報出這是第幾夜、輪到誰發言,數字準得不像在讀某張表,倒像整座牌局的答案本來就都握在他手裡——這點怪異我沒有多想,先回到桌上這手棋。而我心裡冒出的問題是——教科書對「反覆出現的結構」給的標準答案,是一張畫著一大堆類別和箭頭的設計模式圖。現代語言真的還需要那一整套排場嗎?

設計模式當初要解決的,其實是 SOLID

工廠、策略、觀察者這些模式不是憑空長出來的。它們是在物件導向的世界裡,為了讓程式碼能滿足 SOLID 而搭出來的鷹架。SOLID 五條原則裡,跟設計模式關係最深的是這四條:

  • S(單一職責):一個模組只該有一個變動的理由。把「選演算法」跟「跑演算法」黏在一起的類別,兩邊任一改動都會波及對方。
  • O(開放封閉):對擴充開放、對修改封閉。加一種新策略時,不該去動既有的介面定義。
  • D(依賴反轉):高層模組不依賴低層的具體實作,雙方都依賴抽象。
  • I(介面隔離):與其一個什麼都要實作的巨大介面,不如拆成幾個小的,呼叫端只依賴自己真正用到的那一塊。

Day 20 我們在狼隊分工裡看過這個道理的行為版:把一個做太多事的巨大介面拆成小的。用 Go 寫出來是這樣——

// A bloated interface violates single-responsibility and interface-segregation.
type WerewolfMember interface {
    Vote(target string) error
    SendMessage(to, msg string) error
    PlanNextMove(state GameState) Action
}

// Segregated: each caller depends only on the slice it consumes.
type Voter interface{ Vote(target string) error }
type Communicator interface{ SendMessage(to, msg string) error }
type Planner interface{ PlanNextMove(state GameState) Action }

// Coordinate needs messaging only -- it never sees Vote or PlanNextMove.
func Coordinate(comms []Communicator, msg string) {
    for _, c := range comms {
        c.SendMessage("team", msg)
    }
}

拆完之後,Coordinate 不會因為 PlanNextMove 的簽名改了而被迫重新編譯——這是介面隔離加單一職責的直接效果。Go 的隱式介面讓這件事幾乎沒有成本:你不必回頭在某個型別上宣告「我實作了 Communicator」,只要方法對得上就成立,所以事後把大介面切小不需要動到任何實作。傳統設計模式追求的是同一個目標,只是用另一條路:靠類別階層、抽象基底類別、一層層的間接,把「會變的部分」隔離在「不會變的部分」後面。問題是這條路在現代語言裡常常繞遠了,而且繞出來的圖沒人想維護。

工廠:一個函式就夠了

古典工廠模式是「一個產品介面、一份型別註冊表、一個大 switch」,全部只為了讓呼叫端不必寫死具體類別的名字。在函式是一等公民的語言裡,工廠本身就是一個函式值——你把「怎麼造」直接當參數傳進去,不需要一個 Factory 類別來持有它。

// Factory + Strategy collapse into an enum and one match expression.
enum Assignor { Range, RoundRobin }

fn rebalance(pick: Assignor, consumers: &[&str], partitions: &[u32]) -> Vec<(String, Vec<u32>)> {
    match pick {
        Assignor::Range => range_assign(consumers, partitions),
        Assignor::RoundRobin => round_robin(consumers, partitions),
    }
}

這段 Rust 把工廠和策略一起收掉了。Assignor 是一個封閉的 enum,match 的窮盡性檢查會在編譯期逼你處理每一個變體——哪天加了 Assignor::Sticky,所有沒更新的 match 都會編譯失敗,直接把你帶到該改的地方。這裡的開放封閉是靠 enum 加 pattern matching 達成的,不是靠繼承:新增變體是「改一個地方、編譯器幫你找齊其他地方」。代價是 enum 封閉,第三方無法在你的 crate 外面塞一個新變體進來——如果你要的正是那種開放式擴充,Rust 這邊就得回到 Box<dyn Trait>,也就是把 trait 當介面用。這個取捨要自己拿捏:封閉 enum 換來窮盡性檢查,開放 trait 換來外部可擴充。

其他三個語言的工廠也一樣輕。Go 直接傳一個 func() Animal 或一個 type Assignor func(...),呼叫端要換實作就換一個函式值,沒有註冊表;Java 25 用 Supplier<T> 加方法參照,register("dog", Dog::new) 一行就是一筆工廠登記,不必寫 DogFactory implements AnimalFactory;Python 更乾脆,類別本身就是可呼叫物件,一個 {"dog": Dog, "cat": Cat} 字典就是完整的工廠。共通點是:當「建構」可以被當成一個值來傳遞和儲存,那個專門用來持有建構邏輯的類別就沒有存在的理由了。

策略與觀察者:把行為當參數傳

策略模式的本質是「把一組可互換的演算法交給呼叫端選用」。當函式能當值傳,這就是傳一個函式,沒有 Strategy 介面、沒有 setStrategy()。觀察者模式的本質是「狀態變了自動通知訂閱者」,在 Python 裡就是維護一串 callable:

# Observer without an Observer class -- just a list of callables.
subscribers: list = []

def on_vote(handler):
    subscribers.append(handler)

def cast_vote(voter: str, target: str) -> None:
    for handler in subscribers:
        handler(voter, target)

這段 Python 沒有 Subject、沒有 Observer 抽象類別、沒有 attach()/detach() 的樣板,只有一個清單和一個迴圈。但觀察者模式在不同語言的命運不一樣,值得比較:Go 幾乎不用觀察者,因為 channel 已經內建了「一方送、多方收、彼此不直接耦合」的語意,select 還能等多個來源;Rust 類似,tokio::sync::broadcast 或 watch 通道加上 enum 訊息就覆蓋了大部分場景;Java 反而把觀察者留下來並標準化了——java.util.concurrent.Flow 把 Publisher/Subscriber/Subscription 做進標準函式庫,還加上背壓(subscriber 告訴 publisher「我一次只吃得下幾個」)。差別在於 Java 的型別系統沒有 sum type、沒有輕量 channel,於是「訂閱關係」這件事還是需要一組明確的介面來承載。模式沒有死,它搬進了語言——只是每個語言收留它的方式不同。

至於用 pattern matching 換掉類別階層,得失也很具體。你失去的是「開放式多型」:一個 sealed 型別或封閉 enum 不允許外部新增子型別,函式庫使用者不能在你沒預期的地方插一個新 case。你得到的是:所有分支集中在一處、一眼看得完,編譯器保證沒有漏掉的情況,而且不必為了三種行為生出三個檔案、三個類別、一張 UML。當「會變的種類」是有限且由你掌控的(分區策略、AST 節點、狀態機的狀態),這個交易幾乎總是划算。

https://ithelp.ithome.com.tw/upload/images/20260924/20183684RocUTWy2xE.png

認出套路之後

七號指認的是「套路」而不是「人」,這讓她的判斷第一次可以跨輪迴累積:下一世同一手棋一出現,她一句話就點破,不必從頭分析一遍。對我們狼隊,這比她抓對一隻狼更麻煩——她開始把行為抽象成有名字的東西,而有名字的東西可以被討論、被傳授、被別人接手驗證。SOLID 那四條原則,在現代語言裡也不再需要一張膨脹的類別圖去滿足:一等函式扛下工廠與策略,封閉 enum 加 pattern matching 扛下開放封閉,隱式介面與 trait 扛下依賴反轉與介面隔離。當年要靠設計模式才守得住的邊界,直接寫進了型別系統。模式不是消失了,是被語言吸收了。

讀完這篇,你該做的是找一段自己用「介面加好幾個實作類別」寫出來的策略或工廠,試著改成傳一個函式值或一個 enum,跑既有測試確認行為沒變,再回頭數數少了幾個檔案、幾個類別。對照閱讀用的官方文件與經典 GoF 條目,都收在下方的參考資料。

參考資料與延伸閱讀


¹ 註:本書名為情境設定之虛構文獻,非真實歷史或開源紀錄。


上一篇
Day 20|開口前的自我檢查
系列文
狼人自爆的心路歷程:一個「AI人」的30天自學修煉 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言