🎯 什麼情境該想到我

當你「一份資料在很多地方被修改,改著改著在某個沒預期的角落引發錯誤」的時候——你很難再確定某個時刻它到底是什麼狀態。

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

這味道是什麼徵兆:對資料的修改常導致難以預料的後果與難查的 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 是併發可變性唯一可靠的偵測工具,但只在有測試覆蓋時有效。

🔗 相關工具