🎯 什麼情境該想到我

當你「要測的程式碼到處都在用同一組全域變數或全域自由函式,而測試時換不掉它們」的時候。

⚙️ 怎麼用(步驟)

三個選項(p.268):「在測試依賴於全域變數的程式碼時本質上有三個選擇:想辦法讓它依賴的全域變數在測試期間具有另一種行為;利用連接器,連接到另一個全域變數定義;或將它封裝起來,從而可以進一步進行解耦。最後一個選擇稱作封裝全域引用。」

為什麼不用參數化方法就好(p.268):「如果 suspend_frame 呼叫了某個函式,而後者也使用了該全域變數的話,我們就必須同樣將該變數作為參數傳遞給它。」

判準(p.269):「如果若干全域變數總是被一起使用一起修改,則它們應屬同一個類別。」書中建議先檢查這些全域變數還在哪些地方被用到,如果每次都是一起用,就把它們綁在一起,建立一個「智能」的類別來持有它們。

步驟(p.272–273)

  1. 找出有待封裝的全域變數/函式。
  2. 為它們建立一個類別(你將通過該類別來引用它們)。
  3. 將全域變數/函式複製到該類別中。如果其中有些是變數,別忘了在類別的建構函式中進行適當的初始化。
  4. 將全域變數/函式的原始宣告註解掉。
  5. 宣告新類別的一個全域對象。
  6. 依靠編譯器(251頁)幫你找出所有用到了這些全域變數/函式的地方。
  7. 將所有對它們的引用加上剛建立的那個新類別的全域對象為前綴。
  8. 在想要使用偽物件的地方,利用 引入靜態設置方法(292頁)、參數化建構函式(297頁)、參數化方法 或 以獲取方法替換全域引用。

命名(p.269)

  • 「命名一個類別的時候,考慮最終會位於它裡面的方法。當然我們應當給它起一個好名字,但並不一定是完美的。別忘了,你總是可以重新命名它的。」
  • 「你想到的類名可能已經被用掉了。這時候可以考慮重新命名那些使用了該名字的實體,從而將該名字騰出來。」
  • 搬移過去時「我們故意保留了這兩個陣列原來的名字,這是為了簡化後面的步驟」(p.269);等傳遞成功之後再做一點小小的重新命名讓程式碼清晰一些(p.270)。

也適用於全域自由函式(p.271–272)

  • 「不僅是全域資料,對於 C++ 中的非成員函式(也稱自由函式),也可以做同樣的事情。」面對 C API 時「你唯一的接縫就是被呼叫函式的連接期接縫」;可以用 連接替換(296頁),但用封裝全域引用建立另一種接縫「就能得到更佳的程式碼結構」。
  • 做法:「要封裝對全域自由函式的引用,只需建立一個介面類別,然後從它派生出偽類及產品類別。產品類別中的程式碼什麼都不用做,只需直接委託/呼叫相應的全域函式即可。」(p.272)
  • 為什麼要做成虛的:也可以改用「自由函式委託給其他自由函式」或「做成新類別的靜態函式」,「但這兩種方案都不能提供良好的接縫」,那樣就不得不改用連接期接縫或預編譯期接縫(29頁);「若是使用類別+函式方案並輔以參數化類別的話,引入的接縫就既明顯又易於掌控了」(p.272)。

★★ 在 Go 裡:這一招完全適用,而且是處理全域狀態的第一步

// ★ 問題:★★ 一個可變的匯出全域變數,被二十個地方直接讀寫
package cfg
var Timeout = 30 * time.Second     // ★★ 任何人、任何時間、任何 goroutine
 
// ✅ ★ 第一步:封裝(★ 這一步不改任何行為,而編譯器會列出所有引用)
package cfg
var (
    mu      sync.RWMutex
    timeout = 30 * time.Second      // ★★ 改小寫 ⇒ ★ 外部立刻編不過
)
func Timeout() time.Duration { mu.RLock(); defer mu.RUnlock(); return timeout }
func SetTimeout(d time.Duration) error {
    if d <= 0 { return errors.New("timeout must be positive") }   // ★ 現在有地方放檢查
    mu.Lock(); defer mu.Unlock()
    timeout = d
    return nil
}

★★ Go 讓這一步特別安全:★ 把匯出的名字改成小寫 ⇒ 所有外部引用「立刻編譯錯誤」。
★ 編譯器就是你的 checklist——★★ 而這正是書中「介面提取最安全」那條論證的同一個機制。

★★ 但封裝「不是終點」:它只買到三樣東西

★ 封裝之後你得到:
① ★★ 一個放「鎖」的地方 ⇒ ★ 消除資料競爭
② ★★ 一個放「驗證」的地方 ⇒ ★ 不會被設成負數
③ ★★ 一個「攔截點」⇒ ★ 之後可以改成注入而不必再改呼叫端

★ 你「沒有」得到:
❌ ★★ 它仍然是全域可變狀態 ⇒ ★ 測試仍然互相污染、仍然不能 t.Parallel()
❌ ★ 依賴仍然隱形 ⇒ ★★ 從函式簽章看不出它用了 cfg.Timeout()

★★ 所以正確的路徑是三步,而封裝只是第一步(見 登錄器):

第 1 步 ★★ 封裝(★ 這一頁)—— ★ 編譯器幫你找出所有引用
第 2 步 ★ 把 cfg.Timeout() 的呼叫「往外推」到函式參數
第 3 步 ★★ 最後它只出現在 main(),變成一個注入的依賴

★★ Go 特有的三個細節

// ① ★★ 回傳 slice/map 等於沒有封裝
func Tags() []string { return tags }              // ★★ 呼叫端可以 Tags()[0] = "x"
func Tags() []string { return slices.Clone(tags) } // ✅ ★ 或回傳 iter.Seq
// → ★ 見 [[封裝集合]]
 
// ② ★★ 「唯讀的全域」其實常常是可寫的
var Defaults = map[string]string{"a": "1"}        // ★★ 別的 package 可以 delete
// → ✅ ★ 包成函式:func Default(k string) string
 
// ③ ★ sentinel error 與常數不需要封裝
var ErrNotFound = errors.New("not found")         // ✅ ★ Go 的標準慣例
const MaxRetries = 3                               // ✅
// → ★★ 判準:★ 它在啟動後還會被寫入嗎?不會 → 不必封裝(見 [[全域資料]])

★★ 一個測試上的具體技巧:讓全域狀態「可還原」

// ★ 在完全移除全域狀態之前,★★ 至少讓測試能安全地改它
func SetTimeoutForTest(t *testing.T, d time.Duration) {
    t.Helper()
    old := Timeout()
    if err := SetTimeout(d); err != nil { t.Fatal(err) }
    t.Cleanup(func() { _ = SetTimeout(old) })      // ★★ 自動還原
}

★★ t.Cleanup 是這裡的關鍵:★ 它比 defer 好,因為
即使測試用了 subtests 或提早 t.Fatal,還原仍然會執行。
⚠️ 但這仍然不能 t.Parallel() ——★ 見 t.Setenv 的官方說明:
★★ 「因為它影響整個行程,不能用在 parallel 測試」,全域狀態一律如此。

🧪 我實際套用的紀錄

  • (待填)

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

  • 中途程式碼會變醜,這是預期內的:「完成這些之後,你會發現程式碼更加醜陋了,但重要的是它能正確編譯執行,所以說前面的步驟是保持行為的。」(p.270)

  • 只是第一步,不是終點:「從引用一個簡單的全域變數到引用一個類別成員只是第一步。之後你還需要考慮是否應當使用引入靜態設置方法或參數化建構函式,又或者參數化方法。」(p.270)「看起來改進不大,是不是?但實際上這是非常有價值的第一步。」(p.271)

  • 刻意不做「乾淨」的版本:書中自問為什麼把兩個全域變數放進新類別還設成公有——「凡事不宜操之過急。更何況手頭還沒有測試,而且我們要花盡量少的工作先將測試安置到位再說,這時候最好能不碰就不去碰程式碼中的邏輯。我們應當盡量避免觸動這些邏輯,並嘗試往程式碼中引入接縫,以便通過這些接縫來植入測試用的程式碼或資料。」(p.270)

  • 搬移的順序:「在使用封裝全域引用手法時,從資料或小型方法開始著手。稍大一點的方法可以等測試到位之後再移至新類別中。」(p.270)

  • 有些全域變數搬不進既有類別:書中例子中,把兩個陣列做成 AGGController 的成員變數行不通,因為還有其他類別也要用到它們(p.268)。

  • 與《重構》的差別:這條和 封裝變數、全域資料 名字相近但目的不同——書中註明本章手法「在步驟上有所不同,我將之適當剪裁以使得它們能夠被安全地用在沒有測試的情況下」(p.258);這裡封裝是為了造出一個接縫好把測試安置進去,不是為了改善設計。

  • ★★ Go 讓這一步特別安全:把匯出名字改小寫 ⇒ 所有外部引用立刻編譯錯誤。 編譯器就是你的 checklist。

  • ★★ 封裝不是終點,它只買到三樣東西(放鎖的地方、放驗證的地方、一個攔截點)。它仍然是全域可變狀態:測試仍會互相污染、仍不能 t.Parallel()。

  • ★★ 正確路徑是三步:封裝 → 把呼叫往外推到參數 → 最後只出現在 main()。

  • ★★ 回傳 slice/map 等於沒有封裝(呼叫端可以改)。要回傳副本或 iter.Seq。

  • ★★ 「唯讀的全域 map」其實是可寫的(別的 package 可以 delete)。包成函式。

  • ★ sentinel error 與常數不需要封裝。 判準:它在啟動後還會被寫入嗎?

  • ★ 測試用的 setter 要用 t.Cleanup 還原(比 defer 好:subtests 與提早 t.Fatal 仍會執行)。

  • 有全域狀態替換的測試一律不能 t.Parallel(),這是所有行程層級狀態的共同限制。

🔗 相關工具