🎯 什麼情境該想到我

當你「想換掉某個成員變數上的物件,但它是在建構函式裡被建立的,而且你用的語言(例如 C++)不讓你用工廠方法那一招」的時候。

書中 p.278 開頭就把定位講清楚:「提取並重寫工廠方法(書 p.276)在分離對於類型的依賴方面是個強大的手法,但它並不是萬能的。其最大的問題就是不適用於 C++。在 C++ 裡面你無法在基類建構函式中呼叫衍生類別的虛擬函式。但所幸還是有解決辦法的,如果你只在建構函式中建立新物件,並且並不用這個新物件做其他任何事情的話,本節的手法是適用的。」

同頁講原理:「該手法的關鍵在於為你想要替換的成員變數引入一個獲取方法,以便可以通過該獲取方法來換入偽物件。引入了獲取方法之後,將該類中所有使用該物件的地方改為通過獲取方法來獲取。這麼一來,你就可以在衍生類別中通過子類化並重寫該獲取方法來換入測試用的物件了。」

⚙️ 怎麼用(步驟)

書中 p.281「步驟」小節:

  1. 找出需要為其引入獲取方法的物件。
  2. 將建立該物件所需的所有邏輯都提取到一個獲取方法中。
  3. 將所有對該物件的使用都替換為通過該獲取方法來獲取,並在所有建構函式中將該物件的引用初始化為 null(C++ 中則是指標初始化為 0)。
  4. 在獲取方法裡面加入「首次呼叫時建立」功能,這樣當成員引用為 null 時該獲取方法就會負責建立新物件。
  5. 子類化該類,重寫這個獲取方法並在其中提供你自己的測試用物件。

p.279 用一句話總結前幾步:「我們所做的第一件事情就是引入一個遲求值的獲取方法,該方法會在第一次被呼叫的時候建立⋯⋯物件。然後我們將該類裡面所有用到該物件的地方都改為通過呼叫這個獲取方法來獲得它。」

p.280 邊註解釋什麼叫遲求值:「一個遲求值的獲取方法在呼叫方看來與其他獲取方法並沒什麼兩樣。但有一點關鍵的區別,就是遲求值的獲取方法在第一次被呼叫的時候才去建立被返回的物件。」同段並指出「遲求值的獲取方法也用在單件模式中」。

★★ 在 Go 裡:這一招不成立,因為 Go 沒有虛擬分派

// ★ Go 的嵌入看起來像繼承,★★ 但方法呼叫不會分派到外層型別
type Base struct{}
func (b Base) Hook() string  { return "base" }
func (b Base) Run()  string  { return "run: " + b.Hook() }   // ★★ b 永遠是 Base
 
type Testing struct{ Base }
func (t Testing) Hook() string { return "fake" }
 
Testing{}.Run()   // ★★ "run: base" —— ★ 不是 "run: fake"

★★ 所以「在測試子類別裡覆寫方法」這整套手法在 Go 完全無效——
★ 而且它是靜默無效:★★ 程式碼編譯得過、測試跑得過,
只是你以為被架空的那段其實還在真的執行。
→ ★ 這是從 Java/C# 轉 Go 最容易踩的一個坑(見 工具-Go嵌入機制)。

★★ Go 的等價做法:把「可變的那一步」變成一個欄位

// ★ Go 的 seam 是「函式值」或「介面」,而不是「可覆寫的方法」
type Forwarder struct {
    // ★★ 原本要被覆寫的那一步,變成一個可注入的欄位
    buildMessage func(Mail) (Message, error)
    now          func() time.Time
    send         func(context.Context, Message) error
}
 
func New(opts ...Option) *Forwarder {
    f := &Forwarder{
        buildMessage: realBuildMessage,     // ★ 生產環境的預設值
        now:          time.Now,
        send:         realSend,
    }
    for _, o := range opts { o(f) }
    return f
}
// ★ 測試裡替換那一步 —— ★★ 不需要繼承、不需要 mock 框架
f := New(WithBuildMessage(func(Mail) (Message, error) {
    return Message{ID: "fixed"}, nil
}))

★★ 三個具體優勢:

① ★★ 編譯期就知道「哪些步驟是可替換的」—— ★ 它們是欄位,看得見
② ★★ 不需要為了測試放寬封裝(★ Feathers 那招要把 private 改成 protected)
③ ★ 生產路徑與測試路徑是同一段程式碼,★★ 只有那一個欄位不同

⚠️ 代價:★ 函式欄位比方法多一次間接、★★ 而且會讓 struct 變大。
★ 所以只對「測試真的需要替換的那幾步」這樣做,不要全部欄位化。

★★ Go 的對應:不要有「惰性建立的 getter」

// ★ 原始問題:getter 在第一次呼叫時建立一個重物件
func (s *Service) client() *http.Client {
    if s.c == nil { s.c = buildRealClient() }    // ★★ 測試時建不出來/不想建
    return s.c
}
 
// ✅ ★ Go:在建構時就注入,不要惰性建立
type Service struct{ c *http.Client }
func New(c *http.Client) *Service { return &Service{c: c} }

★★ Go 裡這個形態除了難測,還有兩個具體問題:

① ★★ 併發不安全 —— ★ 兩個 goroutine 同時呼叫 client() 會建兩個
   → ★ 要用 sync.Once,★★ 而 sync.Once 會把錯誤永久快取(見 [[延遲載入]])
② ★ 錯誤無處可回 —— ★★ getter 的簽章是 () *http.Client,建立失敗只能 panic

→ ★ 所以「惰性 getter」在 Go 裡本身就是一個該避免的模式,
★★ 而不只是「一個需要被解依賴的障礙」。

★ 真的需要惰性時:sync.OnceValue(Go 1.21+)

type Service struct {
    getClient func() (*http.Client, error)
}
func New() *Service {
    return &Service{getClient: sync.OnceValues(buildRealClient)}   // ★ 併發安全
}
// ★ 測試裡替換
s := &Service{getClient: func() (*http.Client, error) { return fakeClient, nil }}

⚠️ sync.OnceValues 會把錯誤也快取:★★ 暫時性的網路錯誤會讓那個值永遠拿不到。
★ 需要重試就不能用它(見 延遲載入)。

🧪 我實際套用的紀錄

  • (待填)

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

  • 未初始化就被存取的風險。 p.280:「提取並重寫獲取方法手法的缺點之一便是問題成員變數可能在未被初始化之前就被不小心存取到。所以最好確保類裡面的所有用到該成員變數的地方都是通過我們的獲取方法來存取的。」

  • 物件生命週期要格外小心。 p.280:「使用提取並重寫獲取方法手法時,你須得對物件的生命週期格外小心,尤其是對於像 C++ 這樣的沒有內建垃圾收集的語言。確保你釋放測試用物件的方式與產品程式碼釋放產品用物件的方式是一致的。」

  • 作者自己不常用。 p.280:「其實我並不常用這一手法。如果我發現問題物件上只有唯一一個方法的話,使用提取並重寫呼叫(書 p.275)會容易得多。但如果同一物件上有多個問題方法的話,提取並重寫獲取方法就是更好的選擇了。想想看,只需提取出一個獲取方法然後重寫它,問題就都解決了。」

  • 適用前提是窄的:只在建構函式中建立新物件、且不拿這個新物件做其他任何事情(p.278)。

  • ★★ 這一招在 Go 不成立(無虛擬分派)。

  • ★★ 「惰性建立的 getter」在 Go 裡本身就該避免:併發不安全(兩個 goroutine 建兩個)、錯誤無處可回(只能 panic)。

  • ★ 正確做法是建構時注入,不要惰性建立。

  • ★ 真的需要惰性用 sync.OnceValue(s)(Go 1.21+),但它會把錯誤永久快取 —— 暫時性網路錯誤會讓那個值永遠拿不到。

🔗 相關工具