🎯 什麼情境該想到我
當你「發現同樣的幾個資料項目老是黏在一起出現」的時候——好幾個類別都有這組欄位、好幾個函式簽名都帶著這組參數(像 startDate/endDate、street/city/zip)。
⚙️ 怎麼用(步驟 / 公式)
這味道是什麼徵兆:這些總是成群結隊的資料,其實是一個尚未被命名的概念在喊「我想變成一個物件」。判斷小技巧:試著刪掉其中一個資料項,若剩下的就失去意義,代表它們本該是一體。
通常用哪些重構手法對治:
- 提煉類別(Extract Class):把這組資料變成一個有名字的類別(如 DateRange)。
- 引入參數物件(Introduce Parameter Object):把成群的參數包成一個物件傳遞。
- 保持物件完整(Preserve Whole Object):之後傳遞時直接傳這個物件,而非再拆開。
好處不只縮短參數列——成物件後,行為(如「範圍是否重疊」)也有地方可放,往往能連帶消除重複。
★ 怎麼具體認出來:一個機械化的檢查
★ 判準:把其中一個欄位刪掉,剩下的還有意義嗎?
沒有(start沒有end就是廢的)→ 它們是一個概念,該變成一個型別。
// ★ 訊號:同一組參數/欄位在多個簽章裡一起出現
func AmountInvoiced(start, end time.Time) Money
func Recipe(start, end time.Time) []Item
func Report(start, end time.Time) []Row
// ↑─────────────↑ 三次一起出現 → 這是一個 DateRange
type Address struct{ … }
type Customer struct {
BillingStreet, BillingCity, BillingZip string // ★ 前綴在喊「我是一個 Address」
ShippingStreet, ShippingCity, ShippingZip string
}可以直接跑的粗略檢查:
# ★ 找出重複出現的參數組合
grep -ohE 'func [A-Za-z]+\(([^)]*)\)' --include='*.go' -r . | sed 's/.*(//;s/)//' | sort | uniq -c | sort -rn | head -20★★ 為什麼會痛:具體情境
// ① ★ 驗證邏輯被複製 N 份,而且會漂移
if end.Before(start) { return errBadRange } // 這行在三個地方,其中一個寫成 !end.After(start)
// ② ★ 順序搞錯不會報錯
AmountInvoiced(end, start) // ★ 同型別相鄰 —— 編譯通過,數字全錯
// ③ 加一個維度要改所有簽章
func AmountInvoiced(start, end time.Time, tz *time.Location) Money // ★ 三處都要改
// ④ 沒有地方放衍生查詢
// 「這個區間有幾天」「包含某一天嗎」→ 只能寫成自由函式,散落各處★ 對治與收手判準
參數成群出現 ──▶ [[引入參數物件]]
欄位成群出現 ──▶ [[提煉類別]]
單一欄位帶著驗證 ──▶ [[以物件取代基本型別]]
★ 關鍵:新型別一定要長出方法,否則白做。
type DateRange struct{ Start, End time.Time }
func NewDateRange(s, e time.Time) (DateRange, error) { // ★ 驗證只有一個入口
if e.Before(s) { return DateRange{}, errors.New("end before start") }
return DateRange{s, e}, nil
}
func (r DateRange) Contains(t time.Time) bool { … } // ★ 衍生查詢有家了
func (r DateRange) Days() int { … }
func (r DateRange) Overlaps(o DateRange) bool { … }★ 停在「只有兩個欄位的 struct」= 只是把長參數列換成長欄位列。
收手的判準是:原本散落在呼叫端的if/計算,有沒有真的搬進來?
★★ 什麼時候這其實不是味道
| 情況 | 說明 |
|---|---|
| ★ 只在一個函式裡同時出現 | 判準是「在多個簽章裡一起出現」。單一函式不算 |
| ★ 剛好同型別,但是不同概念 | func Move(dx, dy int) 的 dx/dy 是一個 Vector;但 func Resize(w, h int) 的 w/h 是 Size —— 不要合成同一個型別 |
| 標準庫的既有簽章 | copy(dst, src)、io.CopyN(dst, src, n) |
| ★ 包成型別後,語義變得更糟 | func Between(lo, hi int) 包成 Range 之後,呼叫端要寫 Between(Range{1, 10}) —— 兩個參數時收益很小 |
| 純資料的 wire format | protobuf/JSON 對映的扁平欄位 |
★ 判準:這一組資料有沒有「自己的規則」?
有(end不得早於start、zip要符合格式)→ 值得成為型別。
沒有(純粹兩個獨立的數字剛好同時傳)→ 不必。
⚠️ Go 的兩個實務代價
// ① ★ 零值歧義
Search(Query{Offset: 20}) // Limit 是 0 —— 想要 0 筆還是忘了填?
// → 讓零值等於「用預設」,或提供建構函式
// ② ★ 序列化形狀會變
// before {"start":"…","end":"…"}
// after {"range":{"start":"…","end":"…"}} ★ 下游全部要改
// → encoding/json 沒有 inline 選項;要維持扁平只能用匿名嵌入(實測見 [[提煉類別]])TypeScript
// ★ TS 的物件字面量讓「參數泥團」的痛點小得多(具名、順序無關)
function amountInvoiced({ start, end }: DateRange): Money
// ★ 但 TS 版的這個味道是「inline 的物件型別散落在多個簽章」
function a(r: { start: Date; end: Date }) {} // ★ 沒有名字 → 三個地方各寫一次
type DateRange = { start: Date; end: Date }; // ✅ 命名它🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
只有一兩個地方偶爾一起出現、且未來不會成長,可能還不值得為它建類別。
-
確認這組資料真的表達「同一個概念」再合併,別把碰巧相鄰的無關值硬綁一起。
-
★ 判準是「在多個簽章裡一起出現」。只在一個函式裡同時出現不算。
-
★ 型別相同不等於概念相同。
Resize(w, h)和Move(dx, dy)不該共用型別。 -
★ 停在「只有欄位的 struct」= 白做。收手判準是「呼叫端的驗證與計算有沒有真的搬進來」。
-
兩個參數時收益很小:
Between(Range{1,10})比Between(1, 10)囉唆。 -
★ params struct 有零值歧義,分不出「沒給」和「給零值」。
-
★ 序列化形狀會變形,且不受編譯器保護。用獨立 DTO 隔開對外契約。
-
不要把不相關的欄位硬綁成一包,那會長成 全域資料 式的垃圾桶。
🔗 相關工具
- 過長參數列 Long Parameter List(資料泥團是造成長參數列的常見原因)
- 基本型別偏執 Primitive Obsession(成群的基本型別常一起升級為領域物件)
- 純資料類別 Data Class(新提煉出的類別要記得放進行為,別淪為純資料)
- 重構
- 引入參數物件 —— 參數成群時
- 提煉類別 —— 欄位成群時
- 以物件取代基本型別 —— 單一欄位帶驗證時
- 過長參數列 —— 最常見的表徵
- 基本型別偏執 —— 同一個根因的另一面
- 值物件 —— 新型別的目標形態