🎯 什麼情境該想到我

當你「發現同樣的幾個資料項目老是黏在一起出現」的時候——好幾個類別都有這組欄位、好幾個函式簽名都帶著這組參數(像 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 formatprotobuf/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 隔開對外契約。

  • 不要把不相關的欄位硬綁成一包,那會長成 全域資料 式的垃圾桶。

🔗 相關工具