🎯 什麼情境該想到我

當你「想在測試裡把某個成員換成假的,但那個成員是具體型別、而繼承這條路又不好走」的時候。

書上的定位:「本章提到的許多解依賴技術都依賴於物件導向的核心機制,如介面以及實現繼承。而有些新的語言特性則提供了另外的選擇。例如,如果你的語言支援泛型以及型別別名,則可以使用叫做模板重定義的手法來解依賴。」(p.320)

書中的例子是 C++ 的 AsyncReceptionPort,它有個具體的 socket 成員:「如果我們想修改 Run() 函式中的邏輯,就會發現要想在測試工具中執行該方法,就必須通過一個 socket 發送東西。在 C++ 中我們可以通過把 AsyncReceptionPort 做成一個類別模板來完全避免這個問題。」(p.321)改完之後,測試檔案裡就能用一個 FakeSocket 來實例化該類別模板(p.321)。

typedef 是這招最漂亮的地方: 「該技術最漂亮之處就在於我們可以通過一個 typedef 來避免在程式碼庫中到處修改對該類別的使用。如果沒有 typedef 的話,就需要將每處對 AsyncReceptionPort 的使用換成 AsyncReceptionPort<CSocket>。這就意味著大量無聊的工作,但難倒是不難,我們可以依靠編譯器(251 頁)來確保修改了每一處地方。在支援泛型但不支援型別別名機制(如 typedef)的語言中,你只能依靠編譯器。」(p.322)

⚙️ 怎麼用(步驟)

書中特別聲明:「以下是在 C++ 中運用模板重定義手法的步驟。在其他支援泛型的語言中步驟或許有所不同,但原則一樣。」(p.322)

  1. 在待測試類別中找出你想要替換的特性。
  2. 將該類別做成一個類別模板,根據你想要替換的變數對它進行參數化,將方法體轉移到標頭檔中。
  3. 給該類別模板另起一個名字。可以將原類別名後面加上「Impl」。
  4. 在類別模板定義之後加上一行 typedef,把「Impl 版本以具體型別實例化後的結果」重新命名回原類別名(書中寫成 typedef XXXImpl<T> XXX; 的形式,其中 XXX 是原類別名,T 為參數化型別的具體型別)。
  5. 在測試檔案中包含該類別模板的定義,用新的測試用型別來實例化。

★★ 在 Go 裡:這一招完全不存在(Go 的泛型不能做這件事)

// ★ C++ 的做法:★★ 用 template 參數替換整個型別
template<typename TimeProvider>
class Scheduler {
    TimeProvider clock;    // ★ 測試時傳一個 FakeClock,編譯期就替換掉
};
// ★★ Go 的泛型做不到等價的事,原因有兩個:
// ① ★ Go 的泛型是「型別參數受介面約束」,★★ 而不是 C++ 的 duck-typed 展開
type Scheduler[C Clock] struct{ clock C }   // ★ C 必須滿足 Clock 介面
// → ★★ 所以你還是需要那個介面 ⇒ ★ 那不如直接用介面欄位,泛型沒有帶來任何好處
 
// ② ★★ Go 的泛型不能宣告在方法上
func (s *Scheduler) Run[T any]()   // ★★ 編譯錯誤

★★ 結論:★ 在 Go 裡「用泛型做測試替換」幾乎沒有理由。
★ 因為介面已經給了你同樣的替換能力,而且更簡單、更可讀。

★ Go 的等價做法:介面欄位(而且它有一個 C++ 沒有的優勢)

type Clock interface{ Now() time.Time }
 
type Scheduler struct{ clock Clock }
 
// ★ 或更輕:★★ 函式欄位(★ 單一方法的介面在 Go 常常退化成 func)
type Scheduler struct{ now func() time.Time }

★★ Go 的優勢:★ 不需要為了測試改變型別的「編譯期身分」。

★ C++ 的 template 重定義:★★ Scheduler<RealClock> 和 Scheduler<FakeClock>
  是兩個不同的型別 ⇒ ★ 它們不能放進同一個 slice、不能互相賦值
★ Go 的介面欄位:★★ 都是同一個 *Scheduler ⇒ ★ 沒有這個問題

★ 什麼時候 Go 的泛型真的有用(★ 和這一頁無關,但值得對照)

✅ ★ 型別不同但邏輯相同:slices.Sort、maps.Keys
✅ ★ 避免 any + 型別斷言的容器
❌ ★★ 測試替換 → 用介面
❌ ★★ 「行為不同」的抽象 → 用介面(見 [[工具-Go介面小而隱式]])

★ 判準:★★ 問「這些型別的行為一樣嗎?」 一樣(只是型別不同)→ 泛型;不一樣 → 介面。

🧪 我實際套用的紀錄

  • (待填)

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

  • ⚠️ 語言限制:需要語言同時支援泛型與型別別名。 書中示範用的是 C++(p.320–323)。只有泛型、沒有型別別名(typedef 這類)的語言仍可用,但你就得靠編譯器一處一處改過去(p.322)。

  • ⚠️ 主要缺點:實作被逼進標頭檔,依賴變多、重編譯變慢。 書中的側欄講得很直接(p.322):

    「C++ 中的模板重定義手法有一個主要的缺點,即當你參數化一個類別之後,它的實作程式碼就必須轉移到標頭檔中來。這會增加系統中的依賴。每次修改類別模板的程式碼之後,使用該類別的程式碼都必須重編譯。」

    (簡體版譯者在此加註:C++ 編譯器普遍不支援模板的分離編譯。)

  • Feathers 自己不把它當首選。 「一般來說我仍然傾向於採用基於繼承的手法在 C++ 中解依賴。然而如果想要解開的依賴本就處於模板程式碼中的話,該手法就可以用了。」(p.322)書中舉的正面例子是 CollaborationManager 這種本來就是模板的類別——「考慮到在這裡模板的使用方式使得介面提取比較困難,我們可以換種方式來參數化 CollaborationManager,問題就迎刃而解了。」(p.322)

  • 拿它來替換「方法定義」是下策。 「在 C++ 中你甚至可以利用該技術來替換方法的定義,只不過這麼做就有點不夠優雅了。C++ 的語言規則要求你必須提供一個模板參數,所以可以選擇一個成員變數並將其型別泛化為模板參數,或引入一個新的成員變數以便能夠基於某個型別來參數化你的類別——但我非到萬不得已是不會採取這種做法的。我會先非常謹慎地考察是否能使用基於繼承的技術。」(p.322)
    (簡體版譯者在此加註,認為作者這段敘述似乎有問題:實際上在 C++ 中一個類別模板的模板參數並不一定要在該類別模板裡面使用到。)

  • 它會改變型別的名字。 步驟 3、4 要你把原類別改名加 Impl 再用 typedef 把原名補回去——原名還在,但它現在是別名而不是類別本身。

  • ★★ 這一招在 Go 完全不存在。 Go 的泛型是「型別參數受介面約束」,不是 C++ 的 duck-typed 展開——所以你還是需要那個介面,泛型沒有帶來任何好處。

  • ★★ Go 的泛型不能宣告在方法上(func (s *S) Run[T any]() 編譯錯誤)。

  • ★★ 在 Go 裡「用泛型做測試替換」幾乎沒有理由,介面已經給了同樣的能力而且更簡單。

  • ★ Go 的介面欄位有一個 C++ 沒有的優勢:不會讓型別的編譯期身分改變(Scheduler<Real> 與 Scheduler<Fake> 在 C++ 是兩個型別)。

  • ★ 判準:這些型別的行為一樣嗎? 一樣 → 泛型;不一樣 → 介面。

🔗 相關工具