🎯 什麼情境該想到我
當你「一段程式碼其實在依序處理兩件不同的事情」(例如先把輸入解析成某種結構、再拿它去計算結果)的時候——把它拆成前後兩個清楚的階段,各自處理一件事、各有自己的中間資料結構,就能分別理解與修改。
⚙️ 怎麼用(步驟 / 公式)
意圖:把一段依序做兩件不同事情的程式碼,拆成前後兩個獨立階段,中間用一個資料結構銜接。
動作要點(小步安全進行):
- 把第二階段的程式碼用提煉函式 Extract Function抽成獨立函式。
- 編譯、測試。
- 引入一個「中間資料結構」,作為抽出函式的參數傳入。
- 檢查第二階段用到的每個參數:若它其實屬於第一階段的產物,就把它搬進中間資料結構。
- 對第一階段做提煉函式 Extract Function,讓它回傳這個中間資料結構。
- 於是形成「第一階段產出中間資料 → 第二階段消費中間資料」的清楚兩段式流程。
★ before / after(Go)
before——一個函式同時在「解析輸入」和「用解析結果計算」:
func PriceOrder(spec string) (float64, error) {
parts := strings.Split(spec, "-") // ← 階段一:解字串
product := parts[0]
qty, _ := strconv.Atoi(parts[1])
base := catalog[product].Price * float64(qty) // ← 階段二:算錢
discount := math.Max(0, float64(qty-500)) * catalog[product].Price * 0.05
return base - discount, nil
}問題:想測「數量 600 的折扣算得對不對」,你被迫先組一個字串。
after——中間放一個資料結構,把兩個階段切開:
// ★ 中間資料結構:階段一的輸出 = 階段二的輸入
type OrderData struct {
Product string
Qty int
}
func parseOrder(spec string) (OrderData, error) { … } // 只管解析
func priceOrder(d OrderData, cat Catalog) (float64, error) { … } // ★ 純函式,好測
func PriceOrder(spec string, cat Catalog) (float64, error) {
d, err := parseOrder(spec)
if err != nil { return 0, fmt.Errorf("parse order: %w", err) }
return priceOrder(d, cat)
}★ 訊號:怎麼認出「這裡有兩個階段」
- 函式的前半和後半用的資料幾乎不重疊——前半只碰
spec,後半只碰product/qty。 - 想測後半,卻被迫餵前半的輸入格式(最可靠的訊號)。
- 前半在做格式的事(字串、JSON、HTTP、CLI flag),後半在做領域的事。
★★ 這就是 functional core, imperative shell
拆分階段做到極致,就是把程式分成兩層:
| 內核(core) | 外殼(shell) | |
|---|---|---|
| 做什麼 | 決策、計算、規則 | I/O、DB、HTTP、時鐘、隨機 |
| 性質 | 純函式:同樣輸入→同樣輸出 | 有副作用 |
| 怎麼測 | ★ 直接給值、比對值,不需要任何 mock | 少量整合測試 |
// ✅ shell 負責取資料與寫結果,core 只負責決定
func (s *Service) Settle(ctx context.Context, id string) error {
inv, err := s.repo.Load(ctx, id) // shell:I/O
if err != nil { return err }
plan := computeSettlement(inv, s.now()) // ★ core:純函式,時間也是傳進來的
return s.repo.Apply(ctx, plan) // shell:I/O
}★ 把 time.Now() 變成傳入的參數,是這個手法最高槓桿的一次應用——
它讓「月底結帳」「閏年」「跨時區」這些情境變成可以直接寫測試的普通輸入。
和相鄰概念的界線
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
若兩件事本來就緊密交織、拆開反而要傳一堆狀態來回,硬拆會更亂。
-
只有一個目的、沒有明顯前後兩件事的程式碼,不需要拆。
-
★ 只有兩個階段真的獨立時才拆。如果階段二還要回頭問階段一的原始輸入,中間結構就會長出
RawInput string這種欄位——那表示切點錯了。 -
中間資料結構不要變成 純資料類別。它應該帶著自己的驗證與衍生查詢(
d.Valid()、d.Total())。 -
多一層對映的成本是真的:兩個階段各自的型別都要維護,欄位增減要改兩處。階段很薄的時候不划算。
-
不要拆成三、四個階段就停不下來。每個階段之間都要一個型別;五階段 = 四個中間型別 = 讀者要追四次對映。
-
shell 也需要測試。純核心測得再漂亮,
repo.Load傳錯參數一樣是線上事故——外殼靠少量整合測試守住。
🔗 相關工具
- 提煉函式 Extract Function(拆分階段主要靠一連串提煉函式完成)
- 引入參數物件 Introduce Parameter Object(中間資料結構常以參數物件的形式承載)
- 重構
- 管道與過濾器 —— 同一想法在系統層級的版本
- 提煉函式 —— 較輕的版本,不產生中間結構
- 純資料類別 —— 中間結構的退化風險
- 領域模型 —— 純核心通常就是領域模型該待的地方
- 工具-打破依賴以便測試 —— 把 I/O 推到外殼的手法