🎯 什麼情境該想到我

當你發現「一個欄位其實比較常被另一個類別存取,或它在概念上更該屬於別的類別」的時候,或每次修改某資料時,總要牽動另一個類別一起改。

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

意圖:資料結構是軟體的骨架,欄位放對地方能讓程式更好懂、更好改。把欄位搬到「更常存取它、或概念上更該擁有它」的類別。

小步安全進行:

  1. 確認來源欄位有被良好封裝(有取值/設值方法或存取器);若沒有,先做封裝欄位。
  2. 執行測試。
  3. 在目標類別建立對應欄位與存取方法。
  4. 確保來源物件能取得目標物件(有參考可指向它)。
  5. 調整來源的存取器,讓它改為委派到目標物件的欄位。
  6. 執行測試。
  7. 逐步把使用端改為直接存取目標,最後移除來源欄位。

★ 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。程式碼改完不等於線上資料改完——這是重構裡少數「無法只靠測試保證」的一類。

🔗 相關工具