🎯 什麼情境該想到我
當你「在 C 這種過程式語言裡想解開一個小依賴,但物件導向那一套手法全都用不上」的時候。
書上說得很直白:在過程式語言中解依賴可沒有在物件導向語言中那麼多選擇——「比如你沒法使用封裝全域引用(268 頁)或是子類化並重寫方法(314 頁)。所有這些物件導向的解依賴手法都行不通。」(p.310)雖然還是可以用連接替換(296 頁)或定義補全(266 頁),但「它們對於解開一些小的依賴又顯得有點殺雞用牛刀了」(p.310)。
書中點名的適用語言:「對於支援函式指標的語言,換函式為函式指標手法是一個可選的方案。眾所周知的支援函式指標的語言就屬 C 了。」(p.310)
什麼時候比連接替換更值得選?書上舉的是一個網路應用,包資訊被保存在線上資料庫中:「我們可以使用連接替換(296 頁)來將這些函式連接到新的函式體,但連接替換有時會給建構過程帶來較大麻煩。比如我們可能會需要將問題函式從它所在的庫中分離出來以便替換它們。更重要的是,在產品程式碼中根本沒法使用連接替換所獲得的接縫來調節行為。如果你想要將程式碼納入測試同時又能提供產品實現的靈活性(比如切換你的程式碼所存取的資料庫的類型)的話,換函式為函式指標是個好主意。」(p.311)
⚙️ 怎麼用(步驟)
書中列出的步驟(p.312):
- 找到你想要替換的函式的宣告。
- 在每個找到的函式之前建立一個同名的函式指標。
- 重新命名原函式的宣告,以避免跟剛才宣告的函式指標重名。
- 在一個 C 檔案中初始化這些函式指標,將它們指向相應的函式。
- 建構,通過建構錯誤來找出原函式的函式體。將它們改為新的函式名。
書中示範的命名慣例是把原函式改名加上一個表示「產品實作」的後綴,讓原本的名字留給函式指標;初始化則放在某個 C 原始檔的環境初始化函式裡,由 main 呼叫。(p.311–312)
做完之後,書上說:有了這個函式指標,測試檔案便可以替換進來,從而達到感知或分離的目的。(p.312)
★★ 在 Go 裡:這是首選的 seam,而不是「過程式語言的替代方案」
★ 書中 p.310 把它定位為「★ 在 C 這類過程式語言裡,用不了子類化時的替代方案」
→ ★★ 而在 Go 裡它的地位完全不同:★ 因為 Go 有頭等函式,而且沒有繼承
→ ★★ 所以它是「第一選擇」,不是「退而求其次」
// ★★ Go 的 seam:把「會變的那一步」變成一個函式型別的欄位
type Service struct {
now func() time.Time // ★ 時鐘
newID func() string // ★ ID 產生器
sendMail func(ctx context.Context, to, body string) error // ★ 外部呼叫
}
func New(opts ...Option) *Service {
s := &Service{now: time.Now, newID: newULID, sendMail: realSend} // ★ 生產預設值
for _, o := range opts { o(s) }
return s
}★★ now 與 newID 這兩個是投資報酬率最高的注入:
★ 沒有它們:★★ 「月底結帳」「閏年」「跨時區」「ID 衝突」都無法寫測試
★ 有了它們:★★ 那些情境變成普通的表格測試輸入
→ ★ 成本:★ 兩個欄位 + 兩行預設值(見 [[工廠]] 的同一段)
★★ 什麼時候用函式欄位 vs 介面(★ 這是實務上最常問的一題)
| 函式欄位 | 介面 | |
|---|---|---|
| 方法數 | ★★ 一個 | ★ 多個相關操作 |
| 有狀態 | ★ 無(★ 或用 closure 捕獲) | ★★ 有(連線、快取、計數器) |
| 需要元資訊 | ❌ | ★ 需要 Name()/String() |
| ★ 可比較 | ★★ 不可比較(★ func 不能 ==、不能當 map key) | ★ 可以 |
| 樣板量 | ★★ 最少 | 多一個型別宣告 |
| ★ 除錯 | ★★ 堆疊裡是匿名函式(★ 要具名) | 型別名清楚 |
★★ 判準(同 策略模式):★ 這個 seam 只有一個行為嗎? 是 → 函式欄位。
★ 一個容易忽略的細節:★★ 函式欄位在堆疊追蹤裡是
func1、func2。
→ ★ 給它們具名:s.sendMail = sendViaSES(★ 而不是 inline 的匿名函式)。
★★ 三個 Go 特有的陷阱
// ① ★★ 零值是 nil ⇒ 呼叫會 panic
type Service struct{ now func() time.Time }
s := &Service{} // ★★ s.now 是 nil
s.now() // ★★ panic: nil pointer dereference
// → ✅ ★ 一定要在 New() 裡設預設值,★★ 或在使用點判斷 nil
// ② ★ 函式欄位讓 struct 不可比較?→ ★★ 是的
type Service struct{ now func() time.Time }
s1 == s2 // ★★ 編譯錯誤:func 不可比較
// → ★ 若那個型別需要當 map key 或用 ==,就不能有函式欄位
// ③ ★★ 併發替換是資料競爭
// ★ 測試裡 s.now = fake 而同時有 goroutine 在讀 s.now → ★★ -race 會抓到
// → ★ 只在建構時設定,★★ 不要在執行期改★ ① 是最常出現的:★★ 直接用 struct literal 建立(&Service{})
會繞過 New() 的預設值。→ ★ 對策:★★ 讓 struct 未匯出,只透過 New() 建立。
★ 和 Functional Options 的關係
// ★ 函式欄位 + Functional Options 是 Go 的標準組合
func WithClock(f func() time.Time) Option { return func(s *Service) { s.now = f } }
// → ★★ 生產環境完全不用傳;★ 測試裡只傳需要替換的那一個★ 見 工具-Go函式參數太多怎麼辦。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
⚠️ 語言限制:僅限支援函式指標的語言,書中明確點名的是 C。(p.310)物件導向語言有更好的選擇,不該退回這一招。
-
團隊接受度是前提。 書中誠實地寫出爭議:「不同的團隊對函式指標有著不同的看法。有些團隊認為函式指標極度不安全,因為指標的內容可能會被破壞,從而導致被呼叫的是一塊隨機記憶體。而有些團隊則把它當成有用的工具,當然,小心使用是前提。」(p.310)只有站在後者陣營,才適合用這一招來解開「用其他辦法很難或者無法解開的依賴」。
-
它的好處是編譯期而非連接期。 「它的好處之一便是發生在編譯期(而非連接期),因此對你的建構系統幾乎沒有影響。」(p.312)這是它相對連接替換的主要優勢。
-
Feathers 建議這只是過渡: 「然而,如果你是在 C 裡面運用這項技術的,那麼請考慮轉移到 C++,以便利用 C++ 提供的各種各樣的接縫。在本書寫作時,許多 C 編譯器都提供了編譯開關來允許你進行 C/C++ 混合編譯。利用這個性能你便可以將 C 專案逐步往 C++ 遷移,每次只需對你關心的那些檔案進行解依賴。」(p.312)換句話說,這一招好用,但它同時是「你的語言選擇限制了你」的訊號。
-
★★ 在 Go 裡這是首選的 seam,不是「退而求其次」——因為 Go 有頭等函式而且沒有繼承。
-
★★ 注入
now func() time.Time與newID func() string是投資報酬率最高的兩個:讓「月底結帳」「閏年」「跨時區」變成普通的表格測試輸入。 -
★★ 函式欄位的零值是 nil,呼叫會 panic。 直接用
&Service{}會繞過New()的預設值——讓 struct 未匯出,只透過New()建立。 -
★★ 有函式欄位的 struct 不可比較(
==編譯錯誤),不能當 map key。 -
★★ 執行期替換函式欄位是資料競爭,
-race會抓到。只在建構時設定。 -
★ 判準:這個 seam 只有一個行為嗎? 是 → 函式欄位;多個相關操作或有狀態 → 介面。
-
★ 給函式欄位具名的實作(
s.sendMail = sendViaSES),否則堆疊追蹤裡是func1。
🔗 相關工具
- 工具-打破依賴以便測試 — 這條技術屬於的大類
- 子類化並重寫方法 — 物件導向語言裡的對應選擇;C 裡用不了它才輪到本手法(書 p.310 明講)
- 封裝全域引用 — 書 p.310 點名「在過程式語言裡用不了」的另一個物件導向手法
- 連接替換 — 本手法的主要競品;書 p.311 比較兩者,指出連接替換會給建構過程添麻煩、且產品程式碼中用不了它的接縫
- 定義補全 — 書 p.310 提到的另一個 C 可用選項,但對小依賴是殺雞用牛刀
- 以獲取方法替換全域引用 — 另一種「把硬綁的引用換成可替換的間接層」手法
- 回連 修改程式碼的藝術
- 介面提取 —— 多個方法時的選擇
- 子類化並重寫方法 —— ★ 在 Go 不成立,本手法是它的替代
- 參數化建構函式 —— 把依賴推到建構時
- 策略模式 —— 函式 vs 介面的同一判準
- 工具-Go函式參數太多怎麼辦 —— Functional Options
- 工廠 —— 注入時鐘與 ID 產生器