🎯 什麼情境該想到我

當你「一個類別承擔了太多責任、越長越大」的時候——尤其當裡面有一小群欄位和方法明顯自成一組、常一起變動——把這組相關的資料與行為抽出來,成立一個新的類別,讓每個類別的職責更單純。

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

意圖:一個類別責任太多時,把其中一組相關的欄位與方法抽出成新類別。

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

  1. 決定要如何切分職責,確認哪一組欄位與方法該搬出去。
  2. 建立一個新(子)類別來承接被分出的責任;若舊類別改名更貼切也一併處理。
  3. 在舊類別中建立指向新類別實例的連結(一個欄位)。
  4. 用「搬移欄位」把相關欄位一個個移到新類別,每步測試。
  5. 用「搬移函式」把相關方法移到新類別(先移被呼叫端、再移呼叫端),每步測試。
  6. 檢視兩邊介面,去掉不再需要的部分,並決定新類別要對外公開到什麼程度。

★ before / after(Go)

// before:Person 同時管「人」和「電話」
type Person struct {
    Name        string
    OfficeAreaCode   string          // ★ 這兩個欄位總是一起出現
    OfficeNumber     string
}
func (p *Person) TelephoneNumber() string { return "(" + p.OfficeAreaCode + ") " + p.OfficeNumber }
 
// after
type TelephoneNumber struct {
    AreaCode string
    Number   string
}
func (t TelephoneNumber) String() string { return "(" + t.AreaCode + ") " + t.Number }
 
type Person struct {
    Name           string
    officeTelephone TelephoneNumber           // ★ 小寫:先不公開,保留轉發以便逐步遷移
}
func (p *Person) TelephoneNumber() string { return p.officeTelephone.String() }   // ★ 舊 API 不變

★ 怎麼找切點:三個可操作的訊號

訊號怎麼看
★ 欄位的使用叢集列出每個方法用到哪些欄位,畫成矩陣。兩群幾乎不重疊的欄位 = 兩個型別
命名前綴officeAreaCode、officeNumber、officeExtension —— 前綴在喊「我是另一個型別」
子集會一起變動每次需求變動只碰其中三個欄位(發散式變化)
          Name  AreaCode  Number   ← ★ 兩群完全不重疊 → 明顯的切線
Greet()    ✔
Format()   ✔
Dial()            ✔        ✔
IsLocal()         ✔

★★ Go 的機械化步驟(每一步都可編譯、可測)

  1. 建新型別,欄位先複製過去(不刪舊的)。
  2. 新型別欄位設為公開、舊型別加一個新型別的欄位;舊欄位暫時保留。
  3. 舊型別的方法改成轉發:func (p *Person) AreaCode() string { return p.tel.AreaCode }。
    → ★ 這一步做完就編譯 + 測試。外部呼叫端完全沒感覺。
  4. 逐一把舊欄位的讀寫改成走新型別,每改一處測一次。
  5. 舊欄位沒有任何引用後才刪掉(go build ./... 幫你確認)。
  6. 最後決定要不要把 tel 公開,或是繼續只暴露轉發方法(隱藏委託關係)。

⚠️ Go 的地雷在第 5 步之前:

// ★ 這兩個 tag 會讓 JSON/DB 悄悄多一層或少一層
type Person struct {
    Tel TelephoneNumber `json:"tel"`      // ★ JSON 從 {"area_code":…} 變成 {"tel":{"area_code":…}}
}
type Person struct {
    Tel TelephoneNumber `json:",inline"`  // ★★ 沒有 inline 這個選項,而且結果比「無效」更糟
}

實測 Go 1.26.4(2026-09-25):

巢狀具名欄位  {"name":"x","tel":{"area":"02","number":"12345678"}}
`json:",inline"`  {"name":"x","Tel":{"area":"02","number":"12345678"}}   ★ key 變成 "Tel"
匿名嵌入      {"name":"x","area":"02","number":"12345678"}              ✅ 扁平

encoding/json 沒有 inline 選項——名字部分留空只會讓它退回用 Go 欄位名當 key("Tel"),
不是被忽略,是靜默改成另一個錯的形狀。要維持原本的扁平 JSON,只能用匿名嵌入:

type Person struct {
    Name string `json:"name"`
    TelephoneNumber        // ★ 匿名嵌入 → 欄位在 JSON 裡仍是扁平的
}

→ 但嵌入會把新型別的所有匯出方法也帶進 Person(見 工具-Go嵌入機制),要想清楚。

Go 沒有 class,所以有第三個選項

做法什麼時候
具名欄位 + 轉發預設選擇。控制得最精確
匿名嵌入要維持扁平 JSON、或真的想把方法一起繼承過來
★ 拆成另一個 package新型別有自己的不變式與依賴時。Go 的封裝邊界是 package——真正的隔離只有這一招

🧪 我實際套用的紀錄

  • (待填)

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

  • 若抽出後新類別責任太薄、幾乎沒剩什麼,代表切過頭,該反向做內聯類別 Inline Class。

  • 對治的壞味道:龐大類別(Large Class)、資料泥團(Data Clumps)。

  • 反向手法:內聯類別 Inline Class。

  • ★ 一定要留轉發期(步驟 3)。直接改所有呼叫端 = 一個巨大的、review 不動的 diff。

  • ★ JSON/DB 的欄位形狀會變,而且不受編譯器保護。encoding/json 沒有 inline tag;要扁平只能用匿名嵌入。改完必須跑 round-trip 測試與一次真實 API 回應比對。

  • 切完兩邊還互相呼叫來呼叫去 → 切線錯了。好的切線讓兩邊幾乎不需要對話。

  • 不要切成一堆只有兩個欄位的型別。判準是「新型別有自己的名字、自己的不變式、自己的行為」;沒有 → 只是 冗員元素。

  • 雙向引用是壞味道:Person 持有 Telephone,Telephone 又回指 Person → 你只是把一個大型別拆成兩個互相纏繞的型別(見 內幕交易)。

  • 反向操作是 內聯類別。切完發現新型別幾乎沒事做,就合回去,不要硬撐。

🔗 相關工具