🎯 什麼情境該想到我
當你「想測的方法沒用到任何實例變數或其他方法,但它所在的類別在測試用具裡實例化不出來」的時候。
⚙️ 怎麼用(步驟)
原理(p.273):「對付那些沒法在測試用具中實例化的類別是件麻煩事……假設你有一個方法,該方法不使用實例變數或其他方法,就可以將它設成靜態的。而一旦它成了靜態的,你便無需實例化其類別就可以將它置於測試之下了。」
為什麼不乾脆把方法搬到別的類別去(p.273):書中的例子裡,把 validate 整個移到 packet 類別上「倒的確是個不錯的主意,但就目前來說這麼做的風險還是大了點,比如首先我們就沒法實施簽名保持(249頁)。所以如果你沒有方法轉移的自動化支援的話,通常最好還是先把測試安置到位再說。……一旦測試到位,就可以放心做所需的改動,並大膽改進程式碼了」。
步驟(p.275)
- 編寫一個測試,存取你打算設為公有靜態的那個方法。
- 將目標方法的方法體提取到一個靜態方法中。記住實施簽名保持(249頁)。給這個方法起一個新的名字,看一看它的參數名,或許會有所啟發。例如一個名叫 validate 的方法接受一個 packet 參數,那麼就可以提取出一個叫做 validatePacket 的靜態方法。
- 編譯。
- 如果收到關於存取實例變數或方法的編譯錯誤,看一下那些被存取到的變數或方法,看它們能否也能被設為靜態的。如果可以,就將它們也一併設為靜態的並通過編譯。
書中的補充(p.274)
- 在某些語言中還可以更簡單——直接把原來的方法設為靜態的即可;如果該方法被其他類別用到了,那些地方仍然可以工作,也就是說可以通過實例來呼叫靜態方法。「不過在有些語言中這麼做會招來編譯警告。而如果沒有編譯警告當然是最好不過的了。」
- 「靜態方法不會存取類別的任何私有資料,它只是一個實用方法。如果把它設成公有的,就可以編寫測試了。之後如果你想要將該方法轉移到另一個類別中去,這些測試就會是你的強大後盾。」
- 判斷訊號:如果你看到某個方法沒有使用任何實例資料,把它設成靜態的是個好主意,這樣可以讓它變得醒目,直到你弄清它應該屬於哪個類別。
★★ 在 Go 裡:這個問題的形態不同,但它確實存在
★ 書中的問題:★★ Java 的方法綁在類別上,★ 而那個類別在測試裡建不出來
→ ★ 解法:★★ 把不依賴實例狀態的邏輯改成 static,★ 這樣不必建實例就能測
★★ Go 沒有「類別」,★ 但有等價的問題:
★ 一個方法掛在一個「難建立的 struct」上,而它其實不需要那個 struct 的狀態
// ★ 問題:★★ computeFee 掛在 Service 上,而 Service 需要 db、smtp、redis…
type Service struct{ db *sql.DB; mail *smtp.Client; rdb *redis.Client; rates RateTable }
func (s *Service) computeFee(o Order) Money {
return o.Subtotal().Mul(s.rates.For(o.Region)) // ★ 只用到 s.rates
}
// → ★★ 想測 computeFee 就得建一個 Service,★ 而那要三個外部依賴
// ✅ ★ Go 的「暴露靜態方法」= ★★ 改成 package 層函式,把需要的資料當參數
func computeFee(o Order, rates RateTable) Money {
return o.Subtotal().Mul(rates.For(o.Region))
}
func (s *Service) computeFee(o Order) Money { return computeFee(o, s.rates) } // ★ 保留轉發★★ 這一招在 Go 是「零風險」的:★ 純粹的機械性搬移,
★★ 而且編譯器會告訴你漏了哪個參數。
★ 保留原方法當轉發 ⇒ 所有呼叫端一行都不用改。
★★ 一個可操作的診斷:數「方法用到了接收者的幾個欄位」
★ 對每個方法問:★★ 它用到 s 的哪些欄位?
★ 零個 → ★★ 它應該是 package 層函式(★ 或搬到別的型別,見 [[依戀情結]])
★ 一個 → ★ 把那一個當參數,改成函式(★ 這一頁的手法)
★ 多個且相關 → ★ 留在方法上(★ 那是高內聚)
★ 多個但不相關 → ★★ 那個型別可能是 [[過大類別]]
★★ 「零個」的情況在 Go 裡有一個明確的訊號:
★ go vet 不會報,但 ★★ revive 的 unused-receiver 規則會
——★ 而那正是「這應該是一個函式」的提示。
★★ Go 裡這個手法的第二種形態:把 method 變成 func 型別
// ★ 有時你需要的不是「package 層函式」,★★ 而是「可替換的那一步」
type Service struct{ computeFee func(Order) Money } // ★ 見 [[換函式為函式指標]]★ 判準:★★ 你要「能單獨測它」還是「能替換它」?
單獨測 → ★ package 層函式(這一頁)。
要替換 → ★★ 函式欄位。
★★ 一個 Go 特有的反向風險:package 層函式會變成 utils
★ 把方法變成 package 層函式做得太多 ⇒ ★★ 出現一堆無主的自由函式
→ ★ 然後有人把它們集中到 utils/ ⇒ ★★ 而那是 [[內幕交易]] 的溫床
→ ★ 對策:★★ 函式要放在「它操作的型別所在的 package」
★ computeFee(o Order, rates RateTable) → ★ 放 package pricing 或 package order
★★ 絕不放 package utils
★ 見 模組 的「不要有 utils」與 依戀情結 的「函式該放哪」。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
只適用於「不用實例變數也不用其他方法」的方法:這是書中給的前提條件(p.273);如果方法規模較大或用到實例資料,改用 分解出方法對象(p.261)。
-
它會讓原本私有/實例的東西變成公有靜態:書中承認「很可能當初 RSCWorkflow 的建立者根本沒有想到會有這麼一天,它的 validate 方法被做成靜態的,更不用說公有了」,並回應:「封裝對於類別來說固然是件好事,但類別的靜態部分其實並不屬於該類別。實際上,在某些語言中,它隸屬於另一個類別,有時候也叫做元類。」(p.273–274)
-
怕別人濫用這個入口:「如果你擔心之後又會有人來使用這個靜態方法從而帶來依賴問題,可以考慮使用非公有的存取限制。比如在 Java 和 C# 中有套件內可見性或內部可見性……或把它做成受保護的並通過一個測試基類來存取它。在 C++ 中也可以做類似的事情:可以把你的靜態方法設為受保護的,或引入一個命名空間。」(p.274)
-
簽名保持是安全關鍵:「在沒有測試的情況下解依賴時,盡可能對方法進行簽名保持。對整個方法進行剪下/複製可以降低引入錯誤的可能性。」(p.273)
-
與《重構》的差別:這條和 搬移函式 看起來像同一件事,但書中明說本章手法「在步驟上有所不同,我將之適當剪裁以使得它們能夠被安全地用在沒有測試的情況下」(p.258);設成靜態只是為了先寫得出測試,真正的搬家要等測試到位之後(p.273–274)。
-
★★ 在 Go 裡這一招是零風險的機械性搬移:改成 package 層函式、保留原方法當轉發 → 呼叫端一行都不用改,而編譯器會告訴你漏了哪個參數。
-
★★ 可操作的診斷:數「方法用到接收者的幾個欄位」。 零個 → 該是函式;多個但不相關 → 那個型別是 過大類別。
-
★
revive的unused-receiver規則會標出「零個」的情況,那正是「這應該是一個函式」的提示。 -
★★ 反向風險:做太多會產生一堆無主的自由函式,然後有人把它們集中到
utils/。 函式要放在「它操作的型別所在的 package」。 -
★ 判準:你要「能單獨測它」(→ package 層函式)還是「能替換它」(→ 函式欄位)?
-
保留原方法當轉發,這讓改動的影響範圍是零。