🎯 什麼情境該想到我
當你發現「一個欄位其實比較常被另一個類別存取,或它在概念上更該屬於別的類別」的時候,或每次修改某資料時,總要牽動另一個類別一起改。
⚙️ 怎麼用(步驟 / 公式)
意圖:資料結構是軟體的骨架,欄位放對地方能讓程式更好懂、更好改。把欄位搬到「更常存取它、或概念上更該擁有它」的類別。
小步安全進行:
- 確認來源欄位有被良好封裝(有取值/設值方法或存取器);若沒有,先做封裝欄位。
- 執行測試。
- 在目標類別建立對應欄位與存取方法。
- 確保來源物件能取得目標物件(有參考可指向它)。
- 調整來源的存取器,讓它改為委派到目標物件的欄位。
- 執行測試。
- 逐步把使用端改為直接存取目標,最後移除來源欄位。
★ before / after(Go)
// before:折扣率放在 Customer 上,但每個合約可以有不同折扣
type Customer struct {
Name string
DiscountRate float64 // ★ 其實是「合約」的屬性,不是「客戶」的
Contracts []*CustomerContract
}
// after
type CustomerContract struct {
StartDate time.Time
DiscountRate float64 // ★ 搬到真正擁有它的型別
}★ 怎麼判斷一個欄位放錯了:四個訊號
| 訊號 | 例子 |
|---|---|
| 總是跟另一個型別的欄位一起被改 | 改 order.Status 時總是同時改 shipment.State |
| ★ 存取它的程式碼幾乎都在別的型別裡 | Customer.DiscountRate 只被 Contract 的方法讀 |
| 它的生命週期跟不上宿主 | User.CurrentSessionToken —— session 會來會走,user 不會 |
| 同一份資料要在兩處同步維護 | order.Total 和 invoice.Total 各算一次 → 一定會不一致 |
★★ Go 的機械化步驟(先封裝,才能搬)
這個手法有一個硬前提:欄位必須先被封裝。 直接公開的欄位被 N 處讀寫,你沒有攔截點。
// 第 0 步:先做 [[封裝變數]]
type Customer struct {
discountRate float64 // ★ 改小寫
}
func (c *Customer) DiscountRate() float64 { return c.discountRate }
func (c *Customer) SetDiscountRate(r float64) { c.discountRate = r }
// → 編譯器會列出所有直接存取的地方,逐一改成走方法。★ 這一步做完先 commit。
// 第 1 步:目標型別加欄位,源型別的存取方法改成轉發
func (c *Customer) DiscountRate() float64 { return c.contract.DiscountRate } // ★ 單一真相來源
func (c *Customer) SetDiscountRate(r float64) { c.contract.DiscountRate = r }
// → 舊欄位 discountRate 刪掉。編譯 + 測試,外部無感。
// 第 2 步:呼叫端逐批改成直接問 contract,最後刪掉 Customer 的轉發⚠️ 絕對不要停在「兩邊都有一份」的中間狀態。 那是同步 bug 的溫床——
若真的需要過渡期,讓其中一份成為唯一真相,另一份只是轉發(如上),而不是兩份都可寫。
⚠️ Go 特有的三個地雷
① JSON / DB schema 會變
// before {"name":"x","discount_rate":0.1}
// after {"name":"x","contract":{"discount_rate":0.1}} ★ 下游全部要改→ 對外契約不能跟著內部模型動。用獨立的 DTO 型別做序列化。
ORM/sqlx 的欄位對映同理——靠字串對映的東西,編譯器不保護你。
② 零值語義
type Contract struct{ DiscountRate float64 }
c.contract = nil // ★ 指標 → c.DiscountRate() panic
var c Contract // ★ 值 → DiscountRate 是 0,「沒有折扣」還是「沒有合約」?搬移後欄位可能變成「可能不存在」。這是語義變化,不只是位置變化——
轉發方法要決定:回傳零值、回傳 (float64, bool)、還是回錯。
③ 欄位搬走後 struct 的記憶體佈局改變
不影響正確性,但若那個 struct 有幾百萬個實例,欄位順序會影響 padding。
搬移完可以用 go vet -fieldalignment(需 x/tools 的 fieldalignment analyzer)或
unsafe.Sizeof 確認沒有意外變胖。先量再調,不要提前優化。
工具支援
⚠️ gopls 沒有 Move Field。Rename 可以幫你安全改名(找出所有引用),
但欄位搬家要手動——所以「先封裝」這一步特別重要,它把「找出所有引用」的工作交給編譯器。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
欄位未封裝時直接搬會很痛;先做封裝欄位再搬。
-
若來源物件沒有通往目標物件的參考,搬移成本會變高,需先建立關聯。
-
若兩邊都頻繁用到,硬搬只是把耦合換個方向,先評估。
-
★ 沒封裝就不要搬。公開欄位被 N 處直接讀寫,沒有攔截點;先做 封裝變數 再搬。
-
★ 絕對不要留「兩邊各一份」的中間狀態。要過渡期就讓其中一份成為唯一真相、另一份純轉發。
-
★ 對外 JSON/DB 欄位對映會變形,而且不受編譯器保護。 改完必須跑 round-trip 測試與 migration 檢查。
-
★ 搬移常常伴隨「可能不存在」的新語義(宿主變成指標或 optional)。要明確決定回傳零值、
(v, ok)還是 error。 -
有現成資料的系統要配 migration。程式碼改完不等於線上資料改完——這是重構裡少數「無法只靠測試保證」的一類。
🔗 相關工具
- 搬移函式 Move Function(欄位常和使用它的函式一起搬)
- 提煉類別 Extract Class(若一群欄位總是一起搬,也許該獨立成新類別)
- 重構
- 封裝變數 —— 搬移的硬前提
- 搬移函式 —— 幾乎總是要一起做
- 提煉類別 —— 搬移的欄位夠多時
- 純資料類別、依戀情結 —— 觸發本手法的壞味道