🎯 什麼情境該想到我

當你「有一份任何角落都能存取、也都能修改的全域資料,出問題時完全查不出是誰改的」的時候——包括全域變數、類別的靜態欄位、單例(singleton)裡的可變狀態。

⚙️ 怎麼用(步驟 / 公式)

這味道是什麼徵兆:全域資料的問題在於「從任何地方都能被改,卻沒有任何機制能看出是哪段碼改的」。它像看不見的暗流,讓 bug 極難重現與定位,且會在整份程式碼裡製造隱形耦合。作者稱它為「最刺鼻的味道之一」。

通常用哪些重構手法對治:

  • 封裝變數(Encapsulate Variable):把全域資料藏到一個函式(或一小組函式)後面,所有存取都得經過它。這樣你就有了單一的攔截點,能控管、記錄、限制修改,日後也更容易搬移或改結構。

封裝後,進一步可縮小可見範圍、把「誰能改」限制到最少。

★ Go 裡的五種全域資料,危險程度不同

// ① ★★ 最危險:可變的匯出變數
package cfg
var Timeout = 30 * time.Second        // ★ 任何 package、任何 goroutine 都能改
//    → 沒有鎖 = 資料競爭;有鎖也還是「誰改了它」無法追蹤
 
// ② 可變的套件私有變數
var cache = map[string]*User{}        // ★ 至少邊界是 package,但併發問題一樣
 
// ③ ★ init() 建立的全域單例
var db *sql.DB
func init() { db, _ = sql.Open(…) }   // ★ 測試無法替換;init 的執行順序跨檔案不保證直觀
 
// ④ 不可變的全域(✅ 通常沒問題)
var ErrNotFound = errors.New("not found")     // ★ sentinel error 是慣例
const MaxRetries = 3
 
// ⑤ ★ 隱性全域:sync.Once 包起來的延遲初始化
var once sync.Once                    // ★ 好一點,但仍然是全域狀態
# golangci-lint 相關 linter(2026-09-24 查證的官方清單)
#   gochecknoglobals  —— 檢查沒有全域變數
#   gochecknoinits    —— 檢查沒有 init 函式
#   reassign          —— ★ 檢查套件變數沒有被重新賦值(抓別人改你的全域)

★★ 為什麼會痛:四個具體情境

// ① ★ 資料競爭 —— go test -race 會抓到,但只在有測試覆蓋時
go func() { cfg.Timeout = time.Second }()
go func() { use(cfg.Timeout) }()          // ★ DATA RACE
 
// ② ★★ 測試互相污染
func TestA(t *testing.T) { cfg.Timeout = time.Second; … }   // ★ 沒還原
func TestB(t *testing.T) { … }                               // ★ 拿到 TestA 留下的值
//    更糟:go test 預設同套件內循序執行,但加了 t.Parallel() 就會爆
 
// ③ ★ 無法平行測試
//    全域狀態讓 t.Parallel() 變成不可用 —— 測試變慢,而且慢得無法改善
 
// ④ ★ 「誰改了它」無法回答
//    出事時你有一個錯的值,和整個 codebase 的可能來源

★ 對治的順序(不要一步到位)

第 1 步 ─▶ [[封裝變數]]:改小寫 + 存取方法。★ 這一步的價值是「有地方放鎖與驗證」
第 2 步 ─▶ 加鎖或改成不可變(設定只在啟動時讀一次)
第 3 步 ─▶ ★ 變成明確的依賴:從全域變成 struct 欄位,由 main 注入
// ★ 第 3 步的樣子 —— 這是真正的解法
type Service struct {
    cfg    Config          // ★ 不再是全域;每個測試可以給不同的值
    db     *sql.DB
    logger *slog.Logger
    now    func() time.Time    // ★ 連時間都注入 —— 測試可以控制「現在」
}
 
func New(cfg Config, db *sql.DB, logger *slog.Logger) *Service { … }

★ Go 社群的共識:依賴用參數或 struct 欄位傳,不要用全域。
這不是 DI 框架的意思——在 main() 裡手動組裝就是 Go 的 DI。

★★ 什麼時候全域是對的

情況例子
★ 不可變的 sentinel errorvar ErrNotFound = errors.New(…) —— Go 的標準慣例,不要改
常數const MaxRetries = 3
★ 編譯期就決定的查表/正則var emailRe = regexp.MustCompile(…) —— 建立昂貴、之後唯讀
★ 標準庫自己就這樣做的事http.DefaultClient、slog.Default() —— ⚠️ 但函式庫不該依賴它們,要讓呼叫端傳入
★ driver 註冊表sql.Register、image.RegisterFormat —— 這是 Go 的既定模式(配 import _)
flag / 環境變數解析結果啟動時寫一次、之後唯讀

★★ 判準:它在啟動後還會被寫入嗎?
不會(唯讀)→ 全域通常沒問題。
會 → 幾乎一定要處理,不管它看起來多方便。

⚠️ 兩個容易忽略的細節

// ① ★ 「唯讀」的全域 map 其實可寫
var defaults = map[string]string{"a": "1"}    // ★ 別的 package 可以 delete、可以改值
//    → map 沒有不可變版本。要真的唯讀就包成函式:func Default(k string) string
 
// ② ★ 全域 slice 的底層陣列會被共用
var Tags = []string{"a", "b"}
func Get() []string { return Tags }           // ★ 呼叫端可以 Get()[0] = "x"
//    → 見 [[封裝集合]]

🧪 我實際套用的紀錄

  • (待填)

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

  • 全域的「常數」(不可變)沒有這個問題,反而是好東西——味道來自「可變」。

  • 封裝能大幅降低風險,但無法完全消除全域帶來的耦合;能縮小作用域就縮小。

  • ★ 不可變的 sentinel error(var ErrX = errors.New(…))是 Go 的標準慣例,不是味道。

  • ★ 判準是「啟動後還會被寫入嗎」。唯讀的全域通常沒問題;可寫的幾乎一定要處理。

  • ★ 「唯讀」的全域 map/slice 其實可寫。Go 沒有不可變集合;要真的唯讀必須包成函式。

  • ★ 函式庫不該依賴 http.DefaultClient/slog.Default()。應用程式可以,函式庫要讓呼叫端傳入。

  • ★ 全域狀態讓 t.Parallel() 不可用,測試會永久地慢。

  • 加鎖只解決競爭,不解決「誰改了它」。真正的解法是變成注入的依賴。

  • 不要引入 DI 框架。Go 的 DI 就是在 main() 裡手動組裝。

  • init() 的跨檔案執行順序不直觀,而且測試無法介入。能用 New() 就不要用 init()。

🔗 相關工具