🎯 什麼情境該想到我
當你看到「一個迴圈同時做了好幾件不相干的事」(一邊算總和、一邊找最小值、一邊組字串)的時候,改其中一件事總要小心翼翼繞過另一件。
⚙️ 怎麼用(步驟 / 公式)
意圖:讓每個迴圈只做一件事。這樣每段邏輯才好懂、好改,也才方便後續各自提煉成獨立函式。先別擔心效能——清晰優先,真有效能問題再另外處理。
小步安全進行:
- 複製整個迴圈一份,緊接在原迴圈之後。
- 從兩個迴圈裡各自刪掉「不屬於它」的那部分:第一個只留 A 行為,第二個只留 B 行為。
- 執行測試。
- 考慮對每個拆好的迴圈做提煉函式,讓它成為一個有名字的操作。
★ before / after(Go)
// before:一個迴圈同時算兩件不相關的事
youngest := people[0].Age
totalSalary := 0
for _, p := range people {
if p.Age < youngest { youngest = p.Age }
totalSalary += p.Salary // ★ 和上一行毫無關係
}
return fmt.Sprintf("youngest: %d, total: %d", youngest, totalSalary)
// after:一個迴圈只做一件事
youngest := people[0].Age
for _, p := range people {
if p.Age < youngest { youngest = p.Age }
}
totalSalary := 0
for _, p := range people {
totalSalary += p.Salary
}★★ 這個手法的重點:它本身沒有價值,它是為了下一步
拆完看起來只是「多跑一次迴圈」。價值在於現在每個迴圈都能被 提煉函式 抽走:
func youngestAge(people []Person) int { … }
func totalSalary(people []Person) int { … }
return fmt.Sprintf("youngest: %d, total: %d", youngestAge(people), totalSalary(people))★ 只拆不提煉 = 白做。 Fowler 明說這個手法「幾乎總是接著做提煉函式」。
拆完就停在那裡,你得到的是「更長的程式碼 + 多一次遍歷」,沒有任何好處。
★ 效能:多跑一次迴圈到底要不要緊
| 情況 | 判斷 |
|---|---|
| n 小(幾百、幾千) | 完全無感。兩次 O(n) 還是 O(n),而且資料還在 L1/L2 cache |
| n 很大、元素很大 | ★ 可能真的變慢——第二次遍歷時資料已被逐出 cache |
| 迴圈體裡有 I/O | ★★ 這才是真問題:兩次迴圈 = 兩倍的 DB/HTTP 呼叫 |
// ★★ 絕對不要這樣拆
for _, id := range ids { a[id] = db.Load(id).Name }
for _, id := range ids { b[id] = db.Load(id).Email } // ★ DB 被打兩次→ 迴圈體有副作用或 I/O 時,這個手法不適用。 先把 I/O 拉出迴圈(拆分階段),再拆。
★ 判準:先拆求清楚,profile 說慢了再合回去。 合回去很容易,而「一開始就寫成一坨」則很難拆。
Go 的兩個細節
range在迴圈開始時就取好 len(對 slice),所以迴圈內 append 不會造成無限迴圈;但兩個迴圈各自 range 一次是獨立的快照語義。- Go 1.22 起迴圈變數每輪新建。拆分後若有 closure 捕獲
p,1.22 前後行為不同——go.mod的go指令版本決定用哪種語義。
TypeScript
拆分迴圈在 TS 幾乎總是通往 map/filter/reduce,成本比 Go 低得多:
const youngest = Math.min(...people.map(p => p.age));
const totalSalary = people.reduce((s, p) => s + p.salary, 0);⚠️ Math.min(...arr) 在 arr 很大時會爆 call stack(參數個數上限,約幾萬到十萬級,依引擎而定)。大陣列要用 reduce。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
拆分會多跑一次集合,理論上較慢;但多數情況差異可忽略,需要時再優化。
-
兩件事若彼此依賴(後者要用到前者的中間結果),不能單純拆開。
-
拆分本身常只是中繼步驟,真正的收穫是拆完後的提煉;別拆完就停手。
-
★ 拆完沒有接著 提煉函式,這次重構就是負收益。
-
★ 迴圈體有 I/O 或副作用時不適用——拆開就變成兩倍的外部呼叫。
-
累加變數不能靠 以查詢取代暫時變數 消掉,它必須先被拆到自己的迴圈裡。
-
提早
break/return的迴圈不能直接拆:兩個職責的中斷條件不同,拆完語義就變了。先確認中斷條件只服務其中一件事。 -
迴圈間有順序依賴就不能拆(第二件事需要第一件事的結果)。
🔗 相關工具
- 提煉函式 Extract Function(拆分後把每個迴圈提煉成具名操作)
- 以管道取代迴圈 Replace Loop with Pipeline(拆乾淨後,往往能進一步改寫成集合管道)
- 重構
- 提煉函式 —— 拆分迴圈的必要下一步
- 以管道取代迴圈 —— 拆完之後的另一條路
- 迴圈 —— 觸發本手法的壞味道
- 拆分階段 —— 迴圈裡有 I/O 時該先做的事