🎯 什麼情境該想到我
當你「用一個裸露的記錄(record)或雜湊/字典到處直接讀寫欄位」的時候——把它換成類別。類別能隱藏內部結構、控管欄位存取,也讓「存起來的值」和「算出來的值」對使用者一視同仁,不必在意差別。
⚙️ 怎麼用(步驟 / 公式)
意圖:把可變的裸資料記錄換成類別,以便控管欄位存取、隱藏內部結構。
動作要點(小步安全進行):
- 對持有這筆記錄的變數做封裝變數 Encapsulate Variable,讓存取集中到取值函式。
- 建立一個類別包住這筆記錄,讓取值函式回傳這個類別的實例;在類別裡提供對應的取值/設值方法。
- 逐一把「直接讀寫記錄欄位」的地方改成呼叫類別的存取方法,每步測試。
- 全部改完後,去掉對外直接暴露原始記錄的路徑,讓內部結構真正被隱藏。
- 之後可自由改變內部欄位表示,甚至把某些欄位改成「即時計算」而外界無感。
★ 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 字串。
🔗 相關工具
- 封裝變數 Encapsulate Variable(封裝記錄的第一步)
- 封裝集合 Encapsulate Collection(欄位是集合時的專門處理)
- 以物件取代基本型別 Replace Primitive with Object(把記錄中某個有規則的欄位進一步升級)
- 重構
- 封裝集合 —— 欄位是 slice/map 時的下一步
- 以物件取代基本型別 —— 單一欄位版的同一件事
- 值物件 —— 不可變記錄的終點
- 純資料類別 —— 過度封裝的反效果
- 基本型別偏執 —— 觸發本手法的壞味道