🎯 什麼情境該想到我
當你「要把某個類納入測試,但它相依的那個具體類別在測試裡建不出來,你需要傳一個偽物件進去」的時候。
書中 p.285 給出定位與原理:「在許多語言中介面提取都是最安全的解依賴技術之一。如果某步出錯了,編譯器就會立即告訴你,所以說用介面提取的時候,極少可能引入 bug。介面提取的關鍵在於為你的類建立一個介面,該介面包含你想要在某些上下文中使用的所有方法。完成介面之後,就可以讓你的類實現它,從而通過該介面來感知或分離(比如傳遞一個偽物件給你想要測試的類)。」
p.285 說介面提取有三種方式:
- 用現成的自動重構工具(若 IDE 支援)。書中說這類工具「往往會允許你選中一個類上的某些方法然後鍵入新介面的名字」,更好的還會問你要不要順手把程式碼中某些地方改為使用新介面,「會幫你節省可觀的工作量」。
- 一步一步手工提取,也就是下面的步驟;這是書中重點描述的方式。
- 一次性從類裡面剪下/複製出多個方法,然後放到一個新的介面中。p.285:「這種做法雖然沒有前兩種做法安全,但仍然還算是相當不錯的;而且如果沒有重構工具支援並且建置耗時很長的話,該方式往往是唯一的選擇。」
⚙️ 怎麼用(步驟)
書中 p.289「步驟」小節:
- 建立一個新介面,給它起一個好名字。暫時不要往裡面添加任何方法。
- 令你提取介面的目標類實現該介面。這一步不會破壞任何東西,因為介面上還沒有任何方法。但你也可以編譯確認一下。
- 將你想要使用偽物件的地方從引用原類改為引用你新建的介面。
- 編譯系統。如果編譯器回報介面上缺少某某方法,則添加相應的方法(同時也往偽類上面添加一個空的實現),直到編譯通過。
p.288 描述這個流程實際跑起來的樣子:前幾步做完後「所有程式碼應當仍然完全能夠通過編譯,因為我們所做的只不過是引入了幾個新類,並讓一個既有類實現了一個空的介面」;接著才是真正的重構——把想要使用介面的地方改為對介面的引用,然後「編譯程式碼,並從編譯錯誤中發現許多地方呼叫了⋯⋯上的方法,我們將相應方法加到⋯⋯介面中來解決這些編譯錯誤,同時也將該方法的一個空的實現加到偽類中」。
★★ 在 Go 裡:這一招是免費的,而且完全不必碰被提取的型別
// ★ 原本的具體型別 —— ★★ 一行都不用改
package mailer
type SMTPMailer struct{ … }
func (m *SMTPMailer) Send(ctx context.Context, to, body string) error { … }
func (m *SMTPMailer) Close() error { … }
func (m *SMTPMailer) Stats() Stats { … }
// ★★ 消費方宣告它需要的介面 —— ★ 隱式滿足,不需要 "implements"
package notify
type sender interface { // ★ 未匯出、只有一個方法
Send(ctx context.Context, to, body string) error
}
func New(s sender) *Service { return &Service{s: s} }★★ 這是整本書的技術在 Go 裡「最不一樣」的一條:
★ Feathers 的步驟是「建介面 → 讓目標類實作它 → 改引用 → 靠編譯器補方法」;
★★ Go 只有第 1 步和第 3 步——第 2 步(讓類實作介面)不存在,因為它是自動的。
→ ★ 所以在 Go 裡「介面提取」的風險趨近於零,而成本只有幾行。
★★ 這件事的連鎖後果:這本書有一半的手法在 Go 不需要
★ Feathers 的許多手法之所以存在,是因為在 Java/C++ 裡:
★★ 「要讓一個依賴可替換」必須「修改那個依賴」(★ 或它的建立處)
→ ★ 於是需要:子類化並重寫、提取並重寫工廠方法、特性提升、實現提取…
★★ 而在 Go 裡:
★ 「要讓一個依賴可替換」= ★★ 在消費方宣告一個介面,★ 把欄位型別改成它
→ ★★ 被依賴的那一方一行都不用改
→ ★ 所以在 Go 裡,★★ 「介面提取」幾乎是唯一你需要的解依賴手法
★ 見 子類化並重寫方法(★ 在 Go 不成立)與 實現提取(★ 在 Go 不需要)。
★★ Go 的三條額外紀律(★ 書中沒有,因為 Java 不需要)
① ★★ 介面定義在「消費方」,不是「實作方」
❌ package mailer 裡宣告 type Mailer interface{…}
→ ★ 那是 Java 直覺;★★ 它讓 mailer package 必須維護一個它自己不需要的介面
✅ ★ package notify 裡宣告未匯出的 sender —— ★★ 見 [[工具-針對介面編程]]
② ★★ 介面要小 —— ★ 只放「這個消費者用到的方法」
★ 書中說「不必提取所有公有方法」,★★ 而 Go 把這條變成常態:
★ io.Reader 只有一個方法;★ 十個方法的介面替換性是零
③ ★★ 「接受介面、回傳具體型別」
func New(cfg Config) *SMTPMailer // ✅ ★ 回傳具體型別
func New(cfg Config) Mailer // ★★ 呼叫端拿不到 Close()/Stats()
★ ① 有一個具體的好處書中沒提到:★★ 因為介面在消費方且未匯出,
它不是公開契約 → ★ 你可以隨時增減方法,不會破壞任何人。
★★ 書中「非虛擬方法」的困難在 Go 裡換成另一個問題
★ 書中 p.289 說「唯一的困難是非虛擬方法」(★ C++ 的非虛函式、Java 的 static)
★★ Go 沒有這個問題(★ 沒有虛/非虛的區分),★ 但有一個等價的障礙:
★★ package 層的函式無法被介面涵蓋
func mailer.Send(to, body string) error // ★ 一個 package 層函式
→ ★★ 它不是任何型別的方法 ⇒ ★ 無法用介面替換
→ ★ 解法:★★ 要嘛包成一個型別的方法(見 [[暴露靜態方法]]),
★ 要嘛用函式欄位(見 [[換函式為函式指標]])
★ 書中「命名不要加 I 前綴」那一段在 Go 更是如此:
★★ Go 的慣例是介面用 -er 結尾(Reader、Sender、Notifier),
★ 而且未匯出的消費方介面用小寫(sender)——★★ 完全不需要 I 前綴。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
與《重構》的「提煉介面」不是同一件事。 兩者外形相似、動機不同:重構是在測試保護下改善結構;這裡的介面提取是為了能寫出測試而打破依賴——p.285 明說目的是「通過該介面來感知或分離(比如傳遞一個偽物件給你想要測試的類)」,是先有測試的需求才動這一刀。這個動機差異直接決定了下面那條「不必提取所有公有方法」的做法。
-
不要貪心提取所有公有方法。 p.288 用一段對話點破:讀者會問「不是吧,我們還沒有往介面以及偽類上添加 recordError 方法呢」,作者回答那個方法確實存在,「但實際情況是,我們的測試並不需要這個方法。儘管,把一個類的所有公有方法都放到介面上是個不錯的做法,但如果順著這條路走下去,就有可能要做許多不必要的工作才能將程式碼最終納入測試了。」p.289 邊註總結:「提取介面時並不一定要提取類上的所有公有方法。你可以依靠編譯器來幫你發現哪些方法需要加到介面上去。」
-
設計上真的需要完整介面就遞增式來。 p.289:「如果你覺得程式碼的設計走向的確要求介面擁有相應類上的所有公有方法的話,不妨考慮遞增式地擴充該介面。許多時候,在得到足夠的測試覆蓋之前,最好避免大規模的改動。」
-
唯一的困難是非虛擬方法。 p.289:「該手法唯一的困難之處在於當面對非虛擬方法的時候。這裡所謂的非虛擬方法在 Java 裡面可能是靜態方法,而在 C# 或 C++ 裡面則可能是非虛擬成員函式。」p.289–290 的附註說明:在允許非虛擬實例方法的語言中,如果類沒有衍生類別,可以把目標方法設為虛的照常提取;但如果類有衍生類別,把它的非虛擬函式設成虛的並放到介面中就會破壞既有程式碼的行為——C++ 中原本靜態決議到基類的呼叫,提取介面後會改為呼叫衍生類別的版本。書中並指出「在 Java 或 C# 中並沒有這個問題。Java 中所有的實例方法都是虛的。而 C# 中的情況則要安全一點,因為添加一個介面並不會影響到目前對非虛擬方法的呼叫。」
-
遇到「真的需要通過介面存取某個類上的非虛擬方法、而這個類又有衍生類別」時,p.290 給的最佳做法是添加一個新的虛擬方法,讓它委託呼叫那個非虛擬甚至靜態的方法,並確保該方法對所有衍生類別都做了正確的事。
-
命名是主要的摩擦點。 p.287「介面命名」附註反對無腦加 “I” 前綴:好處是不用動腦筋,「但也有它的壞處,那就是你最終會發現許多程式碼都不知道對付的究竟是一個介面還是一個類」;而且之後想把帶 “I” 前綴的介面做成普通類「就會引發大規模的改動。而如果不改的話,那個名字又會作為一個微小的謊言躺在程式碼中」。同頁建議:如果手頭缺少能夠自動重新命名類的工具,「最好在使用該介面的程式碼還沒有大量出現之前給它起個穩定的名字」。
-
★★ 在 Go 裡這一招是免費的,而且完全不必碰被提取的型別(隱式滿足)。Feathers 的第 2 步「讓目標類實作介面」在 Go 不存在。
-
★★ 這件事的連鎖後果:這本書有一半的手法在 Go 不需要。 那些手法存在是因為 Java/C++ 裡「要讓依賴可替換必須修改那個依賴」。
-
★★ 介面定義在消費方、未匯出、只放用到的方法。 在 mailer package 裡宣告
Mailer interface是 Java 直覺。 -
★ 未匯出的消費方介面不是公開契約 → 你可以隨時增減方法,不會破壞任何人。
-
★★ 「接受介面、回傳具體型別」。回傳介面讓呼叫端拿不到完整能力。
-
★★ Go 的等價障礙是「package 層函式無法被介面涵蓋」(它不是任何型別的方法)。要包成方法或用函式欄位。
-
★ Go 的介面命名用
-er結尾,未匯出用小寫,完全不需要I前綴。 -
十個方法的介面替換性是零——書中「不必提取所有公有方法」這條在 Go 是常態。
🔗 相關工具
- 工具-打破依賴以便測試 — 這條技術屬於的大類
- 實現提取 — 名字被占用、又沒有重構工具時的替代路線;書 p.285 說「介面提取比實現提取要直接得多」
- 引入靜態設置方法 — p.294–295 邊註提到,替換單件的另一條路就是對單件類做介面提取
- 提取並重寫工廠方法、提取並重寫獲取方法 — 換掉建立處的姊妹手法
- 回連 修改程式碼的藝術
- 實現提取 —— ★ 在 Go 不需要
- 子類化並重寫方法 —— ★ 在 Go 不成立
- 暴露靜態方法、換函式為函式指標 —— package 層函式的解法
- 工具-針對介面編程、工具-Go介面小而隱式 —— ★ 介面屬於消費者、介面要小
- 分離介面 —— 依賴方向
- 工具-打破依賴以便測試 —— 這條技術屬於的大類