🎯 什麼情境該想到我
當你「一個類別承擔了太多責任、越長越大」的時候——尤其當裡面有一小群欄位和方法明顯自成一組、常一起變動——把這組相關的資料與行為抽出來,成立一個新的類別,讓每個類別的職責更單純。
⚙️ 怎麼用(步驟 / 公式)
意圖:一個類別責任太多時,把其中一組相關的欄位與方法抽出成新類別。
動作要點(小步安全進行):
- 決定要如何切分職責,確認哪一組欄位與方法該搬出去。
- 建立一個新(子)類別來承接被分出的責任;若舊類別改名更貼切也一併處理。
- 在舊類別中建立指向新類別實例的連結(一個欄位)。
- 用「搬移欄位」把相關欄位一個個移到新類別,每步測試。
- 用「搬移函式」把相關方法移到新類別(先移被呼叫端、再移呼叫端),每步測試。
- 檢視兩邊介面,去掉不再需要的部分,並決定新類別要對外公開到什麼程度。
★ 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 的機械化步驟(每一步都可編譯、可測)
- 建新型別,欄位先複製過去(不刪舊的)。
- 新型別欄位設為公開、舊型別加一個新型別的欄位;舊欄位暫時保留。
- 舊型別的方法改成轉發:
func (p *Person) AreaCode() string { return p.tel.AreaCode }。
→ ★ 這一步做完就編譯 + 測試。外部呼叫端完全沒感覺。 - 逐一把舊欄位的讀寫改成走新型別,每改一處測一次。
- 舊欄位沒有任何引用後才刪掉(
go build ./...幫你確認)。 - 最後決定要不要把
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沒有inlinetag;要扁平只能用匿名嵌入。改完必須跑 round-trip 測試與一次真實 API 回應比對。 -
切完兩邊還互相呼叫來呼叫去 → 切線錯了。好的切線讓兩邊幾乎不需要對話。
-
不要切成一堆只有兩個欄位的型別。判準是「新型別有自己的名字、自己的不變式、自己的行為」;沒有 → 只是 冗員元素。
-
雙向引用是壞味道:
Person持有Telephone,Telephone又回指Person→ 你只是把一個大型別拆成兩個互相纏繞的型別(見 內幕交易)。 -
反向操作是 內聯類別。切完發現新型別幾乎沒事做,就合回去,不要硬撐。
🔗 相關工具
- 內聯類別 Inline Class(本手法的反向操作)
- 隱藏委託關係 Hide Delegate(抽出新類別後,常用它隱藏對客戶端的委託)
- 引入參數物件 Introduce Parameter Object(成群資料先聚成物件,往往是提煉類別的前奏)
- 重構
- 內聯類別 —— 反向操作
- 搬移欄位、搬移函式 —— 提煉類別實際上是這兩者的組合
- 隱藏委託關係 —— 切完後要不要暴露新型別
- 過大類別、發散式變化 —— 觸發本手法的壞味道
- 工具-Go嵌入機制 —— 匿名嵌入的規則與陷阱
- 編寫內聚的程式碼 —— 切線的判準