🎯 什麼情境該想到我

當你「發現要測的類別在自己的建構函式裡 new 出了那個難搞的物件,測試根本插不進手」的時候。

⚙️ 怎麼用(步驟)

書的核心主張:如果你用建構函式建立了一個物件,那麼通常解除對該物件依賴的最佳辦法就是將它的建立過程外部化——即在該類別外建立該物件,然後讓該類別的客戶程式碼把這個物件傳給該類別的建構函式(p.297–298)。

書中特別點出大家的心理障礙:人們之所以並不常常想到該技術,是因為他們覺得這種做法等於強迫客戶程式碼傳遞額外參數給該類別的建構函式。然而別忘了,你還可以另外再寫一個方便的建構函式,它具有跟原來的建構函式相同的簽名(p.298)。這麼一來你就既保證了測試程式碼能夠換入必要的測試用物件,又絲毫不干擾產品程式碼(p.298)。

書中列的步驟(p.300–301):

  1. 找出你想要參數化的建構函式,並將它複製一份。
  2. 給其中的一份複製增加一個參數,該參數用來傳入你想要替換的物件。將該建構函式體中的相應的物件建立語句刪掉,改為使用新增的那個參數(如果需要賦值的話,就將那個參數賦給相應的實例變數)。
  3. 如果你的語言支援委託建構函式,那麼刪掉另一份建構函式的函式體,代以對剛才那個建構函式的呼叫,別忘了呼叫的時候要 new 一個相應物件出來。如果你的語言不支援委託建構函式,則可能需要將建構函式中的共同成分提取到一個比如叫 common_init 的方法中。
    (書中譯註:委託建構函式即「從一個建構函式中呼叫另一個建構函式」的能力。)

書中把這個過程一步一步拆給你看:先把原建構函式複製一份 → 給其中一個加上要替換的那個型別的參數 → 把這個參數賦給相應的實例變數、刪掉原來的 new 運算式 → 再回到另一個建構函式,刪除其函式體,代以對剛才那個建構函式的呼叫(p.299–300)。

★★ 在 Go 裡:這不是「手法」,這就是慣例

// ❌★ 從 Java 帶過來的習慣
func NewService() *Service {
    return &Service{
        db:   mustOpenDB(os.Getenv("DSN")),      // ★★ 在建構函式裡建立依賴
        mail: smtp.NewClient(os.Getenv("SMTP")),
        now:  time.Now,
    }
}
 
// ✅★★ Go 的慣例:依賴一律從外面傳進來
func New(db *sql.DB, mail Mailer, now func() time.Time) *Service {
    return &Service{db: db, mail: mail, now: now}
}

★★ 所以這一頁在 Go 的價值是「診斷」而非「手法」:
★ grep -n "os.Getenv\|sql.Open\|http.Client{" 在 New* 函式裡有結果
→ ★★ 那些都是該變成參數的依賴。

★★ 參數變多了怎麼辦:三種做法與判準

// ★ ① 直接多參數(★ ≤4 個、都必填)
func New(db *sql.DB, mail Mailer, log *slog.Logger) *Service
 
// ★★ ② params struct(★ 多個必填、且會一起出現)
type Deps struct{ DB *sql.DB; Mail Mailer; Log *slog.Logger }
func New(d Deps) *Service
// ⚠️ ★★ 零值歧義:★ 忘記填 Log → nil → ★ 第一次記 log 時 panic
//    → ✅ ★ 在 New 裡檢查:if d.Log == nil { d.Log = slog.Default() }
 
// ★★ ③ Functional Options(★ 大多是「有預設值的選項」)
func New(db *sql.DB, opts ...Option) *Service
// ★ 必填的(db)當一般參數,★★ 可選的(時鐘、超時、logger)當 option

★★ 判準(見 工具-Go函式參數太多怎麼辦):★ 這個依賴「有合理的預設值」嗎?
沒有(db、mailer)→ ★ 一般參數(★★ 忘記傳 = 編譯錯誤,這是你要的)。
有(時鐘、logger、超時)→ ★ option。

★★ 最重要的一條:★ 必填依賴不要放 option。
★ New(db) 忘記 WithMailer() → ★★ 執行期 nil panic,
而 New(db, mail) 忘記傳 → 編譯錯誤。

★★ 三個常被漏掉的「該參數化」的東西

// ① ★★ 時鐘(★ 最高價值)
now func() time.Time
// → ★ 「月底結帳」「閏年」「跨時區」變成普通的測試輸入(見 [[換函式為函式指標]])
 
// ② ★★ ID/隨機來源
newID func() string
// → ★ 讓「建立出來的物件」可以用 golden file 比對
// → ★★ 而且它讓重試安全(★ 同一個 ID ⇒ 撞唯一鍵 ⇒ 你知道已經成功了)
 
// ③ ★ context 不是建構函式的參數
func New(ctx context.Context, …)      // ★★ 通常是錯的
// → ★ ctx 是「一次呼叫」的生命週期,★★ 而不是「一個物件」的
// → ★ 例外:★ 建構時就要啟動背景 goroutine 的型別(★ 那時 ctx 控制它的關閉)

★★ ③ 是 Go 特有的一個常見錯誤:★ 把 ctx 存進 struct
→ ★★ 那個 ctx 在第一次請求結束後就被取消了,而之後所有呼叫都會立刻失敗。
(★ containedctx linter 會抓這個。)

★ 書中「原本的建構函式要不要留」在 Go 的答案

★ 書中做法:★ 保留原本的無參數建構函式,讓它呼叫新的參數化版本
★★ Go 的做法:★ 通常不需要保留 —— ★★ 因為編譯器會列出所有呼叫端,
   而「改完所有呼叫端」是一次機械性的動作
→ ★ 例外:★★ 匯出的建構函式(別的 module 在用)⇒ ★ 保留舊的並掛 `// Deprecated:`

🧪 我實際套用的紀錄

  • (待填)

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

  • 會擴散型別依賴:書自己列了缺點——當我們往一個建構函式上添加一個新參數時,可能會導致進一步對該參數的型別的依賴;該類別的使用者可能會在產品程式碼中使用這個新的建構函式,從而增加系統內的依賴。「不過一般來說這個問題不要緊」(p.300)。

  • 作者對它評價很高:「參數化建構函式是個非常簡單的重構手法,我喜歡用它。」(p.300)——這在本章的解依賴手法中算是少見的正面評價。

  • 預設實參是捷徑,但 C++ 有代價:在支援預設參數的語言中,只需簡單地給現有建構函式的目標參數加一個預設實參即可。但在 C++ 中這麼做有一個缺點——該類別所在的標頭檔必須包含那個參數型別的標頭檔;如果不是因為這個預設實參,只需前向宣告就夠了。「也正是因為這個原因,一般我並不使用預設實參」(p.300)。

  • 與《重構》的分界:《重構》裡改動參數列是為了讓介面更表達意圖;這裡動建構函式的唯一動機是把物件的建立權從類別內部搶到外部,好在測試裡換進測試用物件。書中留下同簽名的方便建構函式,正是為了讓產品程式碼完全不受影響——這是解依賴的思路,不是設計改良的思路。

  • ★★ 在 Go 裡這是慣例而非手法。 這一頁的價值是診斷:New* 函式裡出現 os.Getenv/sql.Open/http.Client{} → 那些該變成參數。

  • ★★ 必填依賴不要放 Functional Option。 忘記傳 option → 執行期 nil panic;忘記傳參數 → 編譯錯誤。

  • ★★ 不要把 ctx 存進 struct(New(ctx, …) 通常是錯的)。那個 ctx 在第一次請求後就被取消,之後所有呼叫立刻失敗。containedctx linter 會抓。

  • ★★ 三個最該參數化的:時鐘(now func() time.Time)、ID 產生器、logger。 前兩個讓時間與 ID 相關的情境變成普通測試輸入。

  • ★ params struct 有零值歧義:忘記填 Log → nil → 第一次記 log 時 panic。要在 New 裡補預設值。

  • ★ Go 通常不需要保留舊的建構函式(編譯器會列出所有呼叫端);例外是匯出的 API,要掛 // Deprecated:。

  • 判準:這個依賴有合理的預設值嗎? 沒有 → 一般參數;有 → option。

🔗 相關工具