🎯 什麼情境該想到我
當你「發現同樣或幾乎一樣的程式結構出現在多個地方」的時候——最典型的訊號是修一個問題時,得記得去別處改同樣的碼。
⚙️ 怎麼用(步驟 / 公式)
這味道是什麼徵兆:重複讓你每次修改都要在多處同步,只要漏改一處就產生 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]()編譯錯誤),這會限制你的去重手段。 -
去重後要確認測試還在測原本的東西。抽出共用函式後,兩邊的邊界條件測試常常被合併掉——那些邊界原本可能真的不同。
🔗 相關工具
- 過長函式 Long Function(提煉函式同時對治重複與過長)
- 發散式變化 Divergent Change(重複常導致改動散落多處)
- 重構
- 工具-DRY原則 —— 「哪種重複該去」的判準
- 工具-從相似定義提煉抽象 —— 抽象的時機
- 提煉函式、提煉超類 —— 主要對治手法
- 以管道取代迴圈、拆分迴圈 —— 相似迴圈的處理
- 重複的 switch —— 重複的一種特例