🎯 什麼情境該想到我

當你「用一個裸露的記錄(record)或雜湊/字典到處直接讀寫欄位」的時候——把它換成類別。類別能隱藏內部結構、控管欄位存取,也讓「存起來的值」和「算出來的值」對使用者一視同仁,不必在意差別。

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

意圖:把可變的裸資料記錄換成類別,以便控管欄位存取、隱藏內部結構。

動作要點(小步安全進行):

  1. 對持有這筆記錄的變數做封裝變數 Encapsulate Variable,讓存取集中到取值函式。
  2. 建立一個類別包住這筆記錄,讓取值函式回傳這個類別的實例;在類別裡提供對應的取值/設值方法。
  3. 逐一把「直接讀寫記錄欄位」的地方改成呼叫類別的存取方法,每步測試。
  4. 全部改完後,去掉對外直接暴露原始記錄的路徑,讓內部結構真正被隱藏。
  5. 之後可自由改變內部欄位表示,甚至把某些欄位改成「即時計算」而外界無感。

★ before / after(Go)

// before:到處直接讀寫 map ——★ key 打錯是執行期才發現,而且值是 any
org := map[string]any{"name": "Acme", "country": "TW"}
fmt.Println(org["nmae"])          // ★ 印出 <nil>,完全不報錯
 
// after:換成有型別的記錄
type Organization struct {
    Name    string
    Country string
}
org := Organization{Name: "Acme", Country: "TW"}
fmt.Println(org.Nmae)             // ★ 編譯錯誤

★ 這個手法在 Go 裡的真正對象是 map[string]any

Fowler 的原版在講「用類別取代 record」。Go 本來就有 struct,所以真正的壞味道是這些:

壞味道為什麼痛
map[string]any 當 DTO★ key 打錯不報錯、值要 type assert、欄位是什麼只能靠讀程式碼猜
json.RawMessage 一路傳到底沒人知道裡面有什麼,每個消費者各自解一次
巨大的 interface{} 回傳值呼叫端一連串 switch v := x.(type)
// ★ 從 map 過渡到 struct 的安全路徑:先包一層,讓呼叫端逐步遷移
type Organization struct{ raw map[string]any }
 
func (o Organization) Name() string {
    s, _ := o.raw["name"].(string)     // ★ 失敗就是零值,先維持舊行為
    return s
}

包完之後所有存取都經過方法 → 這時才把 raw 換成真欄位,呼叫端一行都不用改。

★ 可變 vs 不可變:Go 的兩個關鍵細節

type Point struct{ X, Y int }
 
// ① struct 是值 —— 賦值與傳參都是複製
a := Point{1, 2}
b := a
b.X = 99                     // ★ a.X 還是 1。Go 的 struct 天然有「複製語義」
 
// ② ★ 但只要有一個 slice/map/指標欄位,複製就變成「淺複製」
type Order struct {
    ID    string
    Items []Item              // ★ 底層陣列是共用的
}
o2 := o1
o2.Items[0].Qty = 99          // ★ o1.Items[0].Qty 也變成 99 了

→ struct 要真的不可變,必須連內部的 slice/map 一起處理(見 封裝集合)。

Go 慣例:什麼時候該用方法而不是公開欄位

直接公開欄位★ 用方法
純資料傳遞(DTO、config、JSON 對映)有不變式要維護(End 不得早於 Start)
零值就是合理的值是衍生的(Total() 由 items 算出)
同套件內未來可能改實作(欄位改成算出來的)

★ Go 社群不推崇「所有欄位都私有」。time.Time、http.Request 都有公開欄位。
判準是「這個欄位能不能被任意設成任何值而不破壞型別的意義」——能,就公開。

TypeScript

TS 的對應動作是「把 Record<string, unknown> 換成 interface」,另外多一個 Go 沒有的選項:

// ★ readonly 是編譯期的 —— 執行期照樣改得動,別靠它做安全邊界
interface Organization { readonly name: string; readonly country: string }
type DeepReadonly<T> = { readonly [K in keyof T]: DeepReadonly<T[K]> };

🧪 我實際套用的紀錄

  • (待填)

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

  • 若這筆記錄是不可變的、且只是單純資料傳遞(DTO),封裝的好處有限。

  • 使用範圍很小、很短命的記錄不必大費周章。

  • ★ struct 有 slice / map / 指標欄位時,複製是淺的——「值語義」的直覺會騙你(見上)。

  • 不要為了封裝把純 DTO 也包成一堆方法。JSON 對映用的 struct 就該是公開欄位;多包一層 getter 沒有任何收益。

  • 從 map[string]any 一次換到 struct 風險很高。先包方法、讓呼叫端遷移、最後才換底層——這才是「小步」。

  • encoding/json 只看得到匯出欄位。把欄位改成小寫做封裝 → 序列化悄悄漏掉那些欄位,而且不報錯。改完一定要跑一次 round-trip 測試。

  • 反射/ORM/DB 掃描靠欄位名對映,改欄位名不受編譯器保護。改前先 grep tag 字串。

🔗 相關工具