🎯 什麼情境該想到我
當你「一份資料在很多地方被修改,改著改著在某個沒預期的角落引發錯誤」的時候——你很難再確定某個時刻它到底是什麼狀態。
⚙️ 怎麼用(步驟 / 公式)
這味道是什麼徵兆:對資料的修改常導致難以預料的後果與難查的 bug——你在一處改了值,卻在另一個看似無關的地方爆炸。函數式程式設計的觀念是資料永不改變、更新即產生新副本;現實中不必極端,但要盡量控制「可變」的範圍與入口。
通常用哪些重構手法對治:
- 封裝變數(Encapsulate Variable):讓所有更新都經過少數幾個函式,方便監控與演進。
- 將查詢與修改分離(Separate Query from Modifier):確保「取值」的函式沒有副作用,能安心呼叫。
- 以查詢取代暫時變數(Replace Temp with Query):減少會被反覆賦值的中間變數。
- 搭配移除設值函式(Remove Setting Method)、拆分變數(Split Variable)(一個變數別身兼多職)。
★★ Go 的關鍵優勢:struct 是值,天生有複製語義
type Point struct{ X, Y int }
a := Point{1, 2}
b := a
b.X = 99 // ★ a.X 還是 1 —— Go 的 struct 賦值與傳參都是複製★ 這是 Go 相對 Java/Python 最大的差異:在那些語言裡
b = a是別名,
改b就改了a。Go 預設是安全的,所以這個味道在 Go 裡集中在下面幾個例外。
★★ 四個例外:Go 裡真正的可變性風險
// ① ★ 只要有一個 slice / map / 指標欄位,複製就變成「淺複製」
type Order struct { ID string; Items []Item }
o2 := o1
o2.Items[0].Qty = 99 // ★ o1.Items[0].Qty 也變了 —— 底層陣列共用
// ② ★ slices.Clone 也是淺的(實測 Go 1.26.4,2026-09-25)
type Course struct{ Tags []string }
cl := slices.Clone(courses)
cl[0].Tags[0] = "leaked" // ★ 原本的 Tags 也變了
// ③ ★★ cap 洩漏:子切片會覆寫相鄰元素
sub := courses[:2] // len=2 cap=3
sub = append(sub, x) // ★ 直接覆寫 courses[2]!
safe := courses[:2:2] // ✅ 三索引限制 cap,append 會另外配置
// ④ ★ map 永遠是參考型別
func f(m map[string]int) { m["x"] = 1 } // ★ 呼叫端看得到改動;沒有「傳值」的 map→ 完整實測見 封裝集合、工具-Go切片的共享與陷阱。
★ 怎麼具體認出來
| 訊號 | 說明 |
|---|---|
| ★ 同一個變數被賦值多次,且中間被讀取 | 讀者必須在腦中模擬執行順序 |
| ★ 函式參數被修改 | func f(o *Order) { o.Status = … } —— 呼叫端不知道自己的資料被動了 |
| ★ 回傳的 slice/map 被呼叫端修改 | 見 封裝集合 |
go test -race 報 DATA RACE | 最硬的證據 |
| setter 遍地 | 任何人、任何時間都能改任何欄位 |
go test -race ./... # ★ 併發可變性唯一可靠的偵測工具
# golangci-lint:reassign(套件變數被重新賦值)、containedctx、makezero、prealloc★ 對治的順序
區域變數被重複賦值 ──▶ 拆成多個變數,或 [[提煉變數]] / [[以查詢取代暫時變數]]
欄位被到處寫 ──▶ [[封裝變數]](有地方放鎖與驗證)
setter 遍地 ──▶ 移除 setter,改成「回傳新值」的方法
計算結果被存成欄位 ──▶ [[以查詢取代暫時變數]]:改成算出來的
查詢與修改混在一起 ──▶ [[將查詢與修改分離]]
// ★ Go 的不可變寫法:值接收者 + 回傳新值
type Money struct{ cents int64 }
func (m Money) Add(o Money) Money { return Money{m.cents + o.cents} } // ★ 不改 m
// ⚠️ 代價:每次操作一次複製。小 struct 無感;大 struct 要 profile
// ★ 真正的不可變:小寫欄位 + 建構函式 + 只有值接收者的方法
type DateRange struct{ start, end time.Time } // ★ 外部無法構造非零值,也無法修改★★ 什麼時候這其實不是味道
| 情況 | 說明 |
|---|---|
| ★ 累加器 | total += x、sb.WriteString(…)、append(result, x) —— 可變是它的本質 |
★ strings.Builder/bytes.Buffer | 就是為了避免不可變字串的 O(n²) 拼接而存在的 |
| ★ 熱路徑上的就地修改 | sort.Slice 就地排序、copy(dst, src);不可變版本要多配置一次 |
| ★ 迴圈變數 | for i := 0; i < n; i++ |
| 有明確所有者的可變狀態 | 一個 struct 的私有欄位,只由它自己的方法修改,且有鎖 → 那是封裝,不是味道 |
★★ 判準不是「有沒有變」,是「誰能改它,以及改了之後誰會意外」。
有單一所有者、改動都經過方法、併發有鎖 → 沒問題。
任何人都能從任何地方改 → 這才是味道。
⚠️ Go 沒有 const 物件——三個現實
// ① const 只能用於基本型別
const x = 3 // ✅
const p = Point{1, 2} // ★ 編譯錯誤 —— Go 沒有常數 struct
// ② ★ 「不可變」只能靠「不提供修改方法 + 小寫欄位」達成,而且只在 package 外有效
// 同 package 內仍然改得動 —— Go 的邊界是 package
// ③ ★ 含 sync.Mutex 的型別不能用值接收者
func (c Counter) Value() int { … } // ★ go vet: "passes lock by value"
// 實測:c.go:13:9: Value passes lock by value: Counter contains sync.Mutex
// ⚠️ 而 go test 的 vet 子集不含 copylocks —— CI 要單獨跑 go vet ./...TypeScript
readonly items: readonly Item[]; // ★ 編譯期唯讀,零成本,比 Go 漂亮
// ⚠️ 但純編譯期:as Item[] 或從 JS 呼叫就繞過了
Object.freeze(obj) // ★ 執行期,但是淺的🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
完全不可變在效能或現實上未必可行;目標是「縮小並看管」可變範圍,不是消滅所有變動。
-
一個變數若在不同階段承載不同意義,先用拆分變數釐清,別急著封裝。
-
★ 累加器、
strings.Builder、就地排序、迴圈變數,可變是它們的本質,不是味道。 -
★★ 判準是「誰能改它」。有單一所有者、改動經過方法、併發有鎖 → 那是封裝。
-
★ Go 的 struct 複製是淺的:一個 slice/map/指標欄位就破功。
slices.Clone也是淺的。 -
★
s[:2]沒有限制 cap,append 會覆寫相鄰元素。要用三索引s[:2:2]。 -
★ Go 沒有常數 struct、沒有不可變集合。「不可變」只能靠小寫欄位 + 不提供修改方法,且只在 package 外有效。
-
★ 含
sync.Mutex的型別方法要用指標接收者。go vet抓得到,但go test的 vet 子集不含 copylocks。 -
不可變寫法的代價是複製。小 struct 無感;熱路徑上的大 struct 要 profile。
-
go test -race是併發可變性唯一可靠的偵測工具,但只在有測試覆蓋時有效。
🔗 相關工具
- 全域資料 Global Data(可變 × 全域 是最危險的組合)
- 神秘命名 Mysterious Name(身兼多職的變數往往也名字不清)
- 重構
- 封裝變數、封裝集合 —— 主要對治手法
- 將查詢與修改分離 —— 副作用與查詢分開
- 以查詢取代暫時變數 —— 存下來的計算結果改成算出來
- 全域資料 —— 最糟的可變資料
- 值物件 —— 不可變的目標形態
- 工具-Go切片的共享與陷阱 —— slice 共享底層陣列的完整規則