🎯 什麼情境該想到我

當你「想抽出一個介面,卻發現最適合的那個名字已經被現有的類別用掉了,而手上又沒有自動重構工具」的時候。

書中 p.281 開場就是這個困境:「介面提取(書 p.285)是個有用且用起來既方便又順手的技術,但它也有其困難之處:命名。我常常遇到這樣的情況:我想要提取出一個介面,然而卻發現我想給它取的名字已經被當前類給占用了。這時如果我的 IDE 支援重新命名類或介面提取的話,情況就會很簡單。但如果不幸不支援,那麼就只能退而求其次了。」

同頁給出解法:「如果一個類的名字恰恰適合用來作你的介面名,而且你手頭又沒有自動重構工具的話,可以使用實現提取手法來獲得所需的分離。提取一個類的實現時,只需從它派生出一個新類,並將其中的所有具體方法都塞到這個衍生類別中,也就是說把它架空。」

⚙️ 怎麼用(步驟)

書中 p.283「25.9.1 步驟」小節:

  1. 將目標類的宣告複製一份,給複製的類起一個新名字。這裡最好建立一個命名習慣——書中說作者通常使用的就是添加 “Production” 前綴以表明這是一個產品實現。
  2. 將目標類變成一個介面,這一步通過刪除所有非公有方法和資料成員來實現。
  3. 將所有剩下來的公有方法設為抽象方法。如果你的語言是 C++,則還需確保你設成抽象的那些方法都是被虛擬方法重寫的。
  4. 刪除該介面類檔案當中的不必要的 import 或 include,往往有許多都可以刪掉。可以依靠編譯器(書 p.251)來做這一步:挨個刪除 import/include,看編譯出不出錯,出錯了就添回去,否則就刪掉。
  5. 讓你的產品類實現該介面。
  6. 編譯你的產品類以確保新介面中的所有方法都被實現了。
  7. 編譯系統的其餘部分,找出那些建立原類的物件的地方,將它們修改為建立新的產品類的物件。
  8. 重編譯並測試。

C++ 額外提醒(p.282):把類架空成純粹的介面類之後,「別忘了加上純虛析構函式,並在一個實現檔案中定義它」——譯者註說明原因是衍生類別的析構函式總是要呼叫基類的析構函式,不管基類是不是抽象類。

★★ 在 Go 裡:這一招完全不需要

★ 原手法的動機(★ 書中 p.285 說「介面提取比實現提取要直接得多」):
  ★★ 在 Java/C# 裡,若「好名字」已經被那個具體類佔用了
  → ★ 你想叫 Mailer 的介面,★★ 但 Mailer 已經是類別名
  → ★ 於是:★★ 把類別改名(MailerImpl)、讓原本的名字變成介面
// ★★ Go 沒有這個問題,原因有三個:
// ① ★ 介面通常定義在「另一個 package」⇒ ★★ 名稱空間不衝突
package mailer
type SMTPMailer struct{ … }      // ★ 實作在這裡
 
package notify
type sender interface{ … }        // ★★ 名字完全自由,而且未匯出
 
// ② ★ Go 的介面命名慣例是 -er ⇒ ★★ 天然不會和型別名撞
//    ★ Sender / Notifier / Reader vs SMTPMailer / s3Store
 
// ③ ★★ 未匯出的介面(小寫)根本不佔用公開名稱空間

★★ 所以「XxxImpl」這個命名在 Go 是一個明確的反模式——
★ 它是 Java 命名衝突的產物,★★ 而 Go 沒有那個衝突。
★ 看到 type ServiceImpl struct 就知道那是從 Java 帶過來的習慣。

★★ Go 裡的正確命名:三種模式

// ★★ ① 最常見:★ 具體型別用「實作方式」命名,介面用「能力」命名
type sender interface{ Send(…) error }        // ★ 能力(消費方,未匯出)
type SMTPMailer struct{}                       // ★ 實作方式
type SESMailer  struct{}                       // ★ 另一種實作方式
 
// ★ ② package 名已經是前綴 ⇒ ★★ 型別名不必重複
package smtpmail
type Mailer struct{}      // ✅ ★ 呼叫端看到 smtpmail.Mailer
// ❌ ★ type SMTPMailer struct{} → ★★ smtpmail.SMTPMailer(結巴)
 
// ★★ ③ 只有一個實作時:★ 根本不要介面(見 [[內聯類別]])
type Mailer struct{}      // ★ 就這樣,★★ 需要測試替換時才在消費方宣告介面

★ ③ 是最重要的一條:★★ Go 裡「一個介面一個實作」是 冗員元素
(見 工具-Go介面小而隱式 的介面污染)。

★★ 這一頁在 Go 真正對應的一個動作

★ 書中這一招的技術核心是「在不改變呼叫端的前提下,把一個具體型別換成介面」。
★★ Go 裡有一個等價且常用的動作:★ 把一個具體型別「參數化」

// ★ 之前
func Process(m *smtpmail.Mailer, o Order) error

// ★ 之後 —— ★★ 呼叫端完全不用改(★ *smtpmail.Mailer 自動滿足 sender)
type sender interface{ Send(ctx context.Context, to, body string) error }
func Process(m sender, o Order) error

★★ 這個改動的風險是零:★ 編譯器會驗證所有呼叫端傳進來的型別都滿足介面,
★★ 而不滿足的話是編譯錯誤,不是執行期錯誤——
★ 這正是書中說「介面提取是最安全的解依賴技術之一」的理由,★★ 而 Go 讓它更安全。

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 英文名照原書印。 中譯本 p.281 印作「實現提取(Extract Implemeter)」,Implemeter 疑為 Implementer 的排版脫字;此處照原書寫,搜尋時兩種拼法都試一下。

  • 做完之後依賴其實還沒改善。 p.283 講得非常誠實:「好,到目前為止我們的重構只是將程式碼中建立某具體類的物件的地方改為建立另一個具體類的物件,這對系統中的依賴情形並沒有任何改善。」真正的收穫是後續機會——「然而,由於有了介面類⋯⋯的存在,現在我們便可以考察所有那些建立⋯⋯物件的地方,看看是否可以適當運用工廠方法來進一步減少依賴了。」

  • 有基類或衍生類別時會變得很麻煩。 p.284:「如果目標類沒有任何基類或衍生類別,則實現提取用起來還是相對簡單的;否則就需要聰明一點了。」同頁說明限制:「實現提取需要一個能被轉換為介面的具體類。並且在完成提取之後你會得到一個介面和一個具體類。」書中示範的做法是把新的產品類硬塞進既有的繼承體系中間。

  • 塞進繼承體系就該考慮換招。 p.285 直接勸退:「如果你發現自己將一個類像上面這樣嵌入了繼承體系,那麼我建議你真的需要考慮一下是否應當改用介面提取,並給你的介面選擇其他名字了。介面提取比實現提取要直接得多。」

  • 不要用 “I” 前綴當偷懶解法。 p.281:「通常我不贊成往當前類的名字前草草加上一個 ‘I’ 前綴就了事的做法,除非這種做法已經是你的程式碼基裡面的命名慣例。」理由是一個程式碼基裡差不多一半名字帶 “I” 前綴、另一半不帶,用到型別名字時有一半機率會用錯。同頁邊註強調:「命名是設計的關鍵部分。好的名字有助於人們理解系統⋯⋯反之,糟糕的名字則會影響理解,並給你身後的程式設計師帶來無盡煩惱。」

  • p.287 的「介面命名」附註把兩者的取捨講明:把資料和方法全轉移到新的衍生類別、把原類架空成介面,好處是「你無需將程式碼中所有引用⋯⋯的地方修改成引用新的介面」,缺點是「將所有資料成員和方法統統轉移到另一個類中需要不少步驟」;作者說「只要風險足夠小,我還是會時不時用這招的。這其實就是所謂的實現提取」。

  • ★★ 在 Go 裡這一招完全不需要:介面通常在另一個 package、命名慣例是 -er、未匯出介面不佔公開名稱空間——名稱衝突不存在。

  • ★★ XxxImpl 在 Go 是明確的反模式,它是 Java 命名衝突的產物。看到 ServiceImpl 就知道那是 Java 習慣。

  • ★★ package 名已經是前綴:smtpmail.Mailer 而非 smtpmail.SMTPMailer。

  • ★★ 只有一個實作時根本不要介面(冗員元素 的介面污染)。需要測試替換時才在消費方宣告。

  • ★ Go 裡等價的動作是「把參數型別從具體型別換成介面」,而它的風險是零——不滿足是編譯錯誤。

  • ★ 具體型別用「實作方式」命名(SMTPMailer)、介面用「能力」命名(sender)。

🔗 相關工具