🎯 什麼情境該想到我

當你「發現同樣或幾乎一樣的程式結構出現在多個地方」的時候——最典型的訊號是修一個問題時,得記得去別處改同樣的碼。

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

這味道是什麼徵兆:重複讓你每次修改都要在多處同步,只要漏改一處就產生 bug;而且閱讀時要花力氣去比對「這兩段到底哪裡不一樣」。重複的存在通常代表少了一個該被抽出來的共同概念。

通常用哪些重構手法對治:

  • 提煉函式(Extract Function):把重複片段抽成一個函式,多處共用。
  • 拉上移函式(Pull Up Method):若重複出現在同繼承體系的各子類,把它上移到父類。
  • 以管道取代迴圈(Replace Loop with Pipeline):若重複是類似的迴圈處理,用集合管道統一表達。

原則:讓每一項知識在系統中只有「單一、明確、權威」的表述(DRY)。

★ 三種重複,對治完全不同

樣貌對治
① 字面重複同一段碼被複製貼上提煉函式;同一型別內最簡單
② 結構重複邏輯相同、變數名與型別不同泛型(Go 1.18+)、提煉超類/抽介面
③ ★ 巧合重複現在長得一樣,但背後是兩個不同的原因★ 什麼都不要做
# 可以直接跑的偵測(golangci-lint,2026-09-24 查證的 linter 清單)
#   dupl      —— 偵測重複的程式碼片段(可調 threshold)
#   goconst   —— 重複出現的字串字面量,建議抽成常數

★★ 最重要的一段:什麼時候重複是對的

★ DRY 的原意是「每一條知識只該有一個權威表述」,不是「不准有相同的字元」。

判準:這兩段碼會因為「同一個原因」而一起改變嗎?

// ★ 會 —— 稅率規則改了,兩處都要改 → 真重複,該抽
func invoiceTax(amount float64) float64 { return amount * 0.05 }
func receiptTax(amount float64) float64 { return amount * 0.05 }
 
// ★ 不會 —— 一個是營業稅、一個是手續費,剛好都 5% → 巧合重複
func salesTax(amount float64) float64  { return amount * 0.05 }
func serviceFee(amount float64) float64 { return amount * 0.05 }
// 抽成 percent5() 之後:營業稅調成 6%,你會改到手續費

錯誤的抽象比重複貴得多,因為:

  • 重複的成本是線性的(改 N 個地方),而且看得見。
  • 錯誤抽象的成本是發散的:下一個人不敢改共用函式,只好加參數
    → calc(amount, isTax, isFee, applyRounding bool) → 這就是「錯誤抽象的墳墓」。

★ 實務準則:等到第三次出現再抽(Rule of Three)。
兩次時你看不出「哪部分是本質、哪部分是巧合」;三次時通常就看得出來了。
→ 見 工具-DRY原則、工具-從相似定義提煉抽象。

★ Go 的三種去重工具與各自的代價

// ① 函式(首選)
func clamp(v, lo, hi int) int { … }
 
// ② 泛型(Go 1.18+)—— 對治「結構重複」
func Map[A, B any](s []A, f func(A) B) []B { … }
// ⚠️ 代價:泛型碼可讀性較差,且無法在方法上宣告新的型別參數
//     func (s *Store[T]) Convert[U any](…)   ★ 編譯錯誤 —— Go 不支援方法的型別參數
 
// ③ 程式碼產生(go:generate)—— 對治「泛型解不掉的重複」
//go:generate stringer -type=Kind
// ⚠️ 代價:產生的碼要進版控,而且是另一種形式的重複(只是自動的)

⚠️ Go 裡「看起來重複但不該去重」的四種

// ① ★ 錯誤處理樣板 —— 這是慣用語,不是重複
if err != nil { return fmt.Errorf("load: %w", err) }
// 包成 check(err) 會失去每層各自的脈絡訊息,而且 Go 沒有例外可以往上丟
 
// ② ★ 表格測試的每一列 —— 重複是刻意的,為了可讀性
{name: "空字串", in: "", want: 0},
{name: "單字",   in: "a", want: 1},
 
// ③ 跨 bounded context 的相似型別 —— 訂單在「出貨」和「計費」眼中是不同的東西
//    刻意重複兩個 Order struct,勝過一個服務兩邊的上帝物件
 
// ④ ★ 跨服務的共用函式庫
//    抽出 shared lib 讓兩個服務共用 → 兩個服務從此必須一起部署(見 [[內幕交易]])

TypeScript

TS 的結構型別讓去重比 Go 容易,但也更容易做過頭:

// ✅ 用工具型別去重,而不是新增一個抽象層
type CreateUser = Omit<User, 'id' | 'createdAt'>;
type UserPatch  = Partial<CreateUser>;
// ★ 但 Omit/Pick 鏈太深會讓型別錯誤訊息完全無法閱讀 —— 兩層是上限

🧪 我實際套用的紀錄

  • (待填)

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

  • 「長得像」不代表「本質相同」。兩段碼碰巧相似但會因不同原因而各自變化時,強行合併反而製造耦合。

  • 消除重複前先確認它們真的表達同一個知識、會一起改變。

  • 過度抽象化以消滅每一處微小重複,可能換來更難懂的間接層。

  • ★★ 巧合重複不要去重。判準是「會不會因為同一個原因一起改」。錯誤的抽象比重複貴得多。

  • ★ Rule of Three:等第三次出現再抽。 兩次時你分不出本質與巧合。

  • ★ if err != nil 的重複是 Go 的慣用語,包裝它會丟掉每層的脈絡。

  • 表格測試裡的重複是刻意的,是為了可讀性。

  • 跨服務抽共用函式庫 = 建立部署耦合。兩個服務從此得一起改、一起發。

  • 泛型不能宣告在方法上(func (s *S[T]) F[U any]() 編譯錯誤),這會限制你的去重手段。

  • 去重後要確認測試還在測原本的東西。抽出共用函式後,兩邊的邊界條件測試常常被合併掉——那些邊界原本可能真的不同。

🔗 相關工具