🎯 什麼情境該想到我
當你「想測的是一個又大又長的方法,但它所在的類別在測試用具裡根本實例化不出來」的時候。
⚙️ 怎麼用(步驟)
先判斷該不該用(p.261)
- 「在許多應用中,長方法都是非常難對付的角色。」如果能實例化包含這種方法的類別並將其放入測試用具,往往下一步便可以開始編寫測試了;但「有時候,為了讓一個類別能夠被獨立地實例化,需要付出相當大的努力:甚至可能對於你要進行的修改來說顯得有點得不償失了」。
- 方法規模較小、且沒有使用實例資料 → 改用 暴露靜態方法(273頁)。
- 方法規模較大,或使用了實例資料或其他方法 → 用分解出方法對象。
核心理念(p.261):「將一個長方法移至一個新類別中。後者的對象便被稱為方法對象,因為它們只含單個方法的程式碼。」通常運用之後就可以給新類別編寫測試,這會比為舊方法編寫測試容易一些;「舊方法中的局部變數可以做成新類別中的成員變數,這通常能令解依賴並改善程式碼狀況變得更容易」。
步驟(p.266,書中標明「以下步驟用於在沒有測試的情況下安全地分解出方法對象」)
- 建立一個將包含目標方法的類別。
- 為該類別建立一個建構函式,並利用簽名保持(249頁)手法來讓它具有跟目標方法完全一樣的參數列表。如果目標方法用到了原類別中的成員變數或方法的話,再往該建構函式的參數列表裡面加上一個對原類別的引用(添加為第一個參數)。
- 對於建構函式參數列表裡面的每個參數,建立一個相應的成員變數,型別分別與對應的參數型別完全相同。這一步仍可以利用簽名保持手法:將建構函式參數列表內的所有參數直接複製到成員變數宣告區段,並對格式作適當調整。在建構函式裡面對剛才建立的所有成員變數賦值或初始化。
- 在新類別中建立一個空的執行方法。通常該方法可以叫做 run,前面的例子中使用的是 draw。
- 將目標方法的方法體複製到剛才建立的執行方法中,然後編譯,依靠編譯器發現下一步所要作的修改。
- 編譯出錯資訊應當會告訴你該方法在哪兒使用了原類別的方法或成員變數。作相應改動以便令程式碼通過編譯。通常可以通過改用原類別的引用(指標)來呼叫其成員方法來達到目的。或者也有可能你需要將原類別中的相應方法置為公有;如果是成員變數,或許還得為其引入獲取方法函式,以免將其直接暴露出來。
- 新類別通過編譯之後,回到原先的目標方法,對其進行修改,讓它將工作全權委託給上面建立出來的方法對象(只需建立新類別的實例,然後呼叫其執行方法即可)。
- 如果需要,使用 介面提取(285頁)來解開對原類別的依賴。
三種變種(p.266)
- 最簡單的:原方法不使用原類別中任何實例成員 → 無需傳原類別的引用給它。
- 目標方法只使用原類別中的資料成員而不使用其方法 → 往往可以建立一個新類別,把被用到的資料成員放進去,然後傳遞該類別的對象給分解出的方法對象。
- 「本節中展示的情況其實是最糟的一種:被分解出來的方法用到了原類別上的方法。因此我們用了介面提取,並在提取出的方法對象與原類別之間建立起一定程度的抽象。」
★★ 在 Go 裡:它仍然適用,而且是「大函式無法測試」時最實用的一招
★ 原始問題:★★ 一個超大的方法,★ 它用了一堆區域變數與實例欄位
→ ★ 想測它但建不出那個物件、也無法切出小塊
→ ★ 解法:★★ 把整個方法搬到一個新型別上,★ 把它需要的東西變成那個型別的欄位
// ❌★★ 一個 300 行的方法,用了 s 的六個欄位與十個區域變數
func (s *Service) GenerateMonthlyReport(ctx context.Context, m Month) error { … }
// ✅ ★ 分解出方法對象
type monthlyReport struct {
// ★★ 原本從 s 來的東西 → 明確的欄位(★ 而且只放真正用到的)
rates RateTable
holidays []time.Time
now func() time.Time
// ★ 原本的區域變數 → 也可以是欄位(★ 若它們跨多個步驟)
rows []Row
totals map[Region]Money
}
func newMonthlyReport(s *Service) *monthlyReport { … } // ★ 一個轉接
func (r *monthlyReport) run(ctx context.Context, m Month) (Report, error) { … }
// ★ 原方法變成三行
func (s *Service) GenerateMonthlyReport(ctx context.Context, m Month) error {
rep, err := newMonthlyReport(s).run(ctx, m)
…
}★★ Go 版的關鍵優勢:★ 新型別未匯出 ⇒ ★★ 它是純粹的實作細節。
★ 你可以在同 package 的測試裡直接建立它(★ 不需要 export_test.go),
★★ 而外部完全看不到這次重構。
★★ 這一招真正的價值:它讓「大函式」變成可以逐步拆解
★ 做完之後:
① ★★ 那個方法的所有依賴都在 monthlyReport 的欄位上 ⇒ ★ 看得見、可注入
② ★★ 區域變數變成欄位 ⇒ ★ 可以把方法切成多個小方法而不必傳一堆參數
→ ★★ 這正是「提取方法」原本做不到的事(★ 因為參數會爆)
③ ★ 測試只需要建一個 monthlyReport,★★ 不需要整個 Service
★★ ② 是這一招不可替代的地方:
★ 提煉函式 在「大函式有十個互相依賴的區域變數」時會失敗
(★ 抽出來的函式需要八個參數)——★★ 而分解出方法對象先把那些變數變成欄位,
於是提取變得可行。
→ ★ 所以正確的順序是:★★ 先分解出方法對象,再在它內部做提煉函式。
★★ Go 的三個實務注意點
① ★★ 只把「真正用到的」欄位搬過去
★ 別直接塞一個 *Service 進去(★ 那等於什麼都沒隔離)
❌ type monthlyReport struct{ s *Service }
✅ type monthlyReport struct{ rates RateTable; now func() time.Time }
→ ★ 編譯器會幫你:★★ 一個一個加,直到編得過
② ★★ 區域變數變成欄位會讓它「跨方法可見」⇒ ★ 那是一個真實的代價
→ ★ 原本的區域變數只在一段內有效,★★ 現在整個型別都看得到
→ ★ 對策:★★ 拆完之後把「只在一步用到的」變數推回區域變數
③ ★ 併發安全性改變了
★ 原本方法內的區域變數是每次呼叫獨立的
→ ★★ 現在是型別的欄位 ⇒ ★ 同一個實例不可被兩個 goroutine 共用
→ ★ 對策:★★ 每次呼叫建一個新的(newMonthlyReport(s)),★ 不要重用
★★ ③ 是最容易出事的一個:★ 若有人把 monthlyReport 變成 Service 的欄位
(「省一次配置」)→ ★★ 兩個並行的報表產生會互相覆寫彼此的 totals。
★ 和相鄰手法的分工
| 差別 | |
|---|---|
| 提煉函式 | ★ 抽出一段;★★ 但區域變數多時會失敗(參數爆) |
| 特性提升 | ★ 在繼承階層裡搬動(★ Go 不成立) |
| 參數化方法 | ★ 把依賴變成參數;★★ 參數多時不適用 |
| 提煉類別 | ★ 重構手法:抽出一個「有意義的領域概念」 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
它會逼你破壞封裝:「你可能感覺不爽,因為我們為了實現該技術,將原類別中的某些原本是私有的東西給暴露(公有)出來了。」書中例子裡原本私有的 drawPoint「現在就成了公有方法,完全暴露給了外界」。(p.265)
-
結果看起來會很怪:「我們讓一個類別實現了一個新的介面,然而該介面的唯一一個使用者卻是一個由該類別建立出來的對象。」(p.265)
-
但這不是結局:「隨著時間的推移,你會對沒法將 GDIBrush 放入測試用具感到越來越不滿,於是就會開始嘗試對其進行解依賴。等你成功將其放入測試用具之後,就會開始思考其他設計方案……一旦被測試覆蓋,我們便可以做許許多多其他的事情,所以最後的程式碼結構可能會大相徑庭。」(p.265)
-
整章共通的誠實取捨:「這些技術並不能立竿見影地讓設計變得更好。實際上,如果你有良好的設計感,這裡的某些技術甚至可能會令你退縮。」(p.258)
-
與《重構》的差別:書中註明本章手法雖與《重構》部分手法重疊,但「在步驟上有所不同,我將之適當剪裁以使得它們能夠被安全地用在沒有測試的情況下」(p.258)。所以別把它當成 提煉類別 或處理 過長函式 的做法——那些是在測試保護下改善結構,這裡的目的是先讓那個長方法能被寫出測試。
-
★★ 這是「大函式有十個互相依賴的區域變數」時唯一可行的一招 —— 提煉函式 在那種情況下會失敗(抽出來的函式需要八個參數)。
-
★★ 正確順序:先分解出方法對象,再在它內部做提煉函式。
-
★★ 只把「真正用到的」欄位搬過去,不要塞一個
*Service進去 —— 那等於什麼都沒隔離。編譯器會幫你一個一個加。 -
★★ 併發安全性改變了:區域變數變成欄位後,同一個實例不可被兩個 goroutine 共用。每次呼叫建一個新的,不要為了「省一次配置」把它變成 Service 的欄位。
-
★ 區域變數變成欄位會讓它跨方法可見,這是真實的代價。拆完後把「只在一步用到的」推回區域變數。
-
★ Go 版的優勢是新型別未匯出 ⇒ 純粹的實作細節,同 package 測試可直接建立,外部看不到這次重構。