|
| 1 | +--- |
| 2 | +title: " [實作筆記] 命令式程式碼重構到函數式 Pipe 流水線" |
| 3 | +date: 2025/09/17 16:53:43 |
| 4 | +tags: |
| 5 | + - 實作筆記 |
| 6 | +--- |
| 7 | +## 前言 |
| 8 | + |
| 9 | +最近小朋友在進行中文轉換規則的開發時,遇到了一個典型的程式碼異味。 |
| 10 | + |
| 11 | +你可以先挑戰看一下有沒有辦法識別。 |
| 12 | + |
| 13 | +```typescript |
| 14 | +// 重構前的程式碼 |
| 15 | +apply(text: string): string { |
| 16 | + const protectedChars = ['台'] |
| 17 | + |
| 18 | + // 1. 標記階段 |
| 19 | + let protectedText = text |
| 20 | + protectedChars.forEach((char) => { |
| 21 | + protectedText = protectedText.replaceAll(char, `<<<${char}>>>`) |
| 22 | + }) |
| 23 | + |
| 24 | + // 2. 轉換階段 |
| 25 | + let result = this.baseConverter(protectedText) |
| 26 | + |
| 27 | + // 3. 還原階段 |
| 28 | + protectedChars.forEach((char) => { |
| 29 | + const convertedChar = this.baseConverter(char) |
| 30 | + result = result.replaceAll(`<<<${convertedChar}>>>`, char) |
| 31 | + }) |
| 32 | + |
| 33 | + // 4. 自訂轉換 |
| 34 | + result = this.customConverter(result) |
| 35 | + |
| 36 | + return result |
| 37 | +} |
| 38 | +``` |
| 39 | + |
| 40 | +這篇文章記錄了我如何將一個命令式的方法重構為函數式的 pipe 流水線, |
| 41 | + |
| 42 | +讓程式碼變得更加簡潔和易讀。 |
| 43 | + |
| 44 | +## 壞味道 |
| 45 | + |
| 46 | +原始的 `ChineseConversionRule` 中的 `apply` 方法存在以下問題: |
| 47 | + |
| 48 | +1. **過多中間變數**:`text` → `protectedText` → `result` |
| 49 | +2. **命令式寫法**:透過變數重新賦值來處理資料流 |
| 50 | +3. **邏輯分散**:標記保護字符和還原的邏輯內嵌在主方法中 |
| 51 | + |
| 52 | +過多的中間變數和命令式的寫法讓程式碼顯得冗長且不夠優雅。 |
| 53 | + |
| 54 | +protectedText、result、text 都是。 |
| 55 | + |
| 56 | +這裡的挑戰是要對原始的字串加工 |
| 57 | + |
| 58 | +在不破壞原本邏輯的情況下,保留擴充的彈性,並保持程式結構清晰、可維護。 |
| 59 | + |
| 60 | +可能有很多模式可以解決(Decorator / Template Method / Pipeline) |
| 61 | + |
| 62 | +小朋友寫也得也不差了,我們試著讓它更好 |
| 63 | + |
| 64 | +## 重構過程 |
| 65 | + |
| 66 | +### 階段一:消除中間變數 |
| 67 | + |
| 68 | +第一步是消除不必要的中間變數,直接在同一個變數上操作: |
| 69 | + |
| 70 | +可以看到修改後,只有 text 一個變數,還是傳入了的 |
| 71 | + |
| 72 | +越少變數,越不用花心思思考命名,減少認知負擔,而本質上這些冗餘的變數的確是同質可以刪除的 |
| 73 | + |
| 74 | +這是一種隱性的重複壞味道。 |
| 75 | + |
| 76 | +```typescript |
| 77 | +apply(text: string): string { |
| 78 | + const protectedChars = ['台'] |
| 79 | + |
| 80 | + // 直接操作 text 變數,消除 protectedText 和 result |
| 81 | + protectedChars.forEach((char) => { |
| 82 | + text = text.replaceAll(char, `<<<${char}>>>`) |
| 83 | + }) |
| 84 | + |
| 85 | + text = this.baseConverter(text) |
| 86 | + |
| 87 | + protectedChars.forEach((char) => { |
| 88 | + const convertedChar = this.baseConverter(char) |
| 89 | + text = text.replaceAll(`<<<${convertedChar}>>>`, char) |
| 90 | + }) |
| 91 | + |
| 92 | + return this.customConverter(text) |
| 93 | +} |
| 94 | +``` |
| 95 | + |
| 96 | +### 階段二:引入 Functional Programming |
| 97 | + |
| 98 | +接下來導入函數式程式設計的概念,使用 pipe 模式來處理資料流: |
| 99 | + |
| 100 | +這裡要先看懂 `pipe`,簡單理解它把一個初始值依序丟進多個函數,前一個輸出就是下一個的輸入。 |
| 101 | + |
| 102 | +可以看到一些明顯的壞味道,**重複的 protectedChars**,1 跟 3 本身是匿名函數,讀起來也沒那麼好理解, |
| 103 | + |
| 104 | +這是重構必經之路,我們再往下走 |
| 105 | + |
| 106 | +```typescript |
| 107 | +apply(text: string): string { |
| 108 | + const protectedChars = ['台'] |
| 109 | + |
| 110 | + return this.pipe( |
| 111 | + text, |
| 112 | + // 1. 標記階段:將需要保護的字符標記為不轉換 |
| 113 | + (input) => protectedChars.reduce((acc, char) => |
| 114 | + acc.replaceAll(char, `<<<${char}>>>`), input), |
| 115 | + |
| 116 | + // 2. 轉換階段:使用 OpenCC 進行轉換 |
| 117 | + this.baseConverter, |
| 118 | + |
| 119 | + // 3. 還原階段:將標記的字符還原為原始字符 |
| 120 | + (input) => protectedChars.reduce((acc, char) => { |
| 121 | + const convertedChar = this.baseConverter(char) |
| 122 | + return acc.replaceAll(`<<<${convertedChar}>>>`, char) |
| 123 | + }, input), |
| 124 | + |
| 125 | + // 4. 使用自訂轉換器進行模糊字詞的修正 |
| 126 | + this.customConverter |
| 127 | + ) |
| 128 | +} |
| 129 | + |
| 130 | +// 自製的 pipe 函數 |
| 131 | +private pipe<T>(value: T, ...fns: Array<(arg: T) => T>): T { |
| 132 | + return fns.reduce((acc, fn) => fn(acc), value) |
| 133 | +} |
| 134 | +``` |
| 135 | + |
| 136 | +### 階段三:職責分離 |
| 137 | + |
| 138 | +將標記和還原邏輯抽取成獨立的私有方法: |
| 139 | + |
| 140 | +將重複出現的 `protectedChars` 提升為類別屬性: |
| 141 | + |
| 142 | +也不需要把匿名函數寫一坨在 pipe 裡面,這時要煩惱的只有方法的名字要怎麼才達意 |
| 143 | + |
| 144 | +但至少我們還有註解。 |
| 145 | + |
| 146 | +更進一步可以簡化方法內的參數名,因為 scope 很小,不會有認知負擔 |
| 147 | + |
| 148 | +```typescript |
| 149 | +export class ChineseConversionRule implements IRule { |
| 150 | + private baseConverter: ConvertText |
| 151 | + private customConverter: ConvertText |
| 152 | + private readonly protectedChars = ['台'] // 統一管理 |
| 153 | + |
| 154 | + // ... constructor |
| 155 | + |
| 156 | +apply(text: string): string { |
| 157 | + return this.pipe( |
| 158 | + text, |
| 159 | + // 1. 標記階段:將需要保護的字符標記為不轉換 |
| 160 | + this.markProtectedChars, |
| 161 | + |
| 162 | + // 2. 轉換階段:使用 OpenCC 進行轉換 |
| 163 | + this.baseConverter, |
| 164 | + |
| 165 | + // 3. 還原階段:將標記的字符還原為原始字符 |
| 166 | + this.restoreProtectedChars, |
| 167 | + |
| 168 | + // 4. 使用自訂轉換器進行模糊字詞的修正 |
| 169 | + this.customConverter |
| 170 | + ) |
| 171 | +} |
| 172 | + |
| 173 | + /** |
| 174 | + * 標記需要保護的字符 |
| 175 | + */ |
| 176 | + private markProtectedChars = (input: string): string => { |
| 177 | + return this.protectedChars.reduce((acc, c) => acc.replaceAll(c, `<<<${c}>>>`), input) |
| 178 | + } |
| 179 | + |
| 180 | + /** |
| 181 | + * 還原被標記的保護字符 |
| 182 | + */ |
| 183 | + private restoreProtectedChars = (input: string): string => { |
| 184 | + return this.protectedChars.reduce((acc, c) => { |
| 185 | + const convertedChar = this.baseConverter(c) // 例如:'台' -> '臺' |
| 186 | + return acc.replaceAll(`<<<${convertedChar}>>>`, c) |
| 187 | + }, input) |
| 188 | + } |
| 189 | +``` |
| 190 | +
|
| 191 | +## 重構成果 |
| 192 | +
|
| 193 | +1. **簡潔性**:主方法從 20 行縮減到 8 行 |
| 194 | +2. **可讀性**:資料流向清晰,從上到下一目了然 |
| 195 | +3. **可測試性**:每個步驟都是純函數,可以獨立測試 |
| 196 | +4. **可維護性**:職責分離,邏輯集中管理 |
| 197 | +5. **函數式**:無副作用,符合 FP 原則 |
| 198 | +
|
| 199 | +### 效能考量 |
| 200 | +
|
| 201 | +- 測試結果顯示功能完全正常,262 個測試案例全數通過,這是個大前提,沒有測試沒有重構 |
| 202 | +- 重構過程中沒有改變演算法複雜度 |
| 203 | +- Pipe 函數本身的開銷微乎其微 |
| 204 | +
|
| 205 | +## 關於 Pipe 的選擇 |
| 206 | +
|
| 207 | +在重構過程中考慮過使用現成的函式庫: |
| 208 | +
|
| 209 | +- **Ramda**:功能最完整的 FP 函式庫 |
| 210 | +- **Lodash/fp**:輕量級選擇 |
| 211 | +- **fp-ts**:型別安全的 FP 函式庫 |
| 212 | +
|
| 213 | +最終選擇自製 pipe 函數的理由: |
| 214 | +
|
| 215 | +兩行程式碼,沒有多餘依賴,寫法完全貼合專案需求。 |
| 216 | +
|
| 217 | +團隊看了就能用,不用再去學新的函式庫。 |
| 218 | +
|
| 219 | +邏輯也很單純,後續維護起來相對輕鬆。 |
| 220 | +
|
| 221 | +除非更大範圍的重複發生,不然不需要額外引用套件突增學習成本 |
| 222 | +
|
| 223 | +```typescript |
| 224 | +private pipe<T>(value: T, ...fns: Array<(arg: T) => T>): T { |
| 225 | + return fns.reduce((acc, fn) => fn(acc), value) |
| 226 | +} |
| 227 | +``` |
| 228 | +
|
| 229 | +## RD 反饋 |
| 230 | +
|
| 231 | +這次重構讓我深刻體會到函數式程式設計的優雅之處: |
| 232 | +
|
| 233 | +1. **資料即流水線**:透過 pipe 讓資料在各個函數間流動 |
| 234 | +2. **純函數的威力**:每個步驟都可預測、可測試 |
| 235 | +3. **組合勝過繼承**:透過函數組合建構複雜邏輯 |
| 236 | +4. **漸進式重構**:一步步改善,降低風險 |
| 237 | +
|
| 238 | +從命令式到函數式的重構不僅讓程式碼變得更優雅,也提升了整體的可維護性。 |
| 239 | +
|
| 240 | +雖然函數式程式設計有一定的學習曲線,但一旦掌握了基本概念,就能寫出更簡潔、更易懂的程式碼。 |
| 241 | +
|
| 242 | +重構的關鍵在於:**小步快跑,持續改善**。每一次小的改進都讓程式碼朝著更好的方向發展,這正是軟體工藝精神的體現。 |
| 243 | +
|
| 244 | +## 小結 |
| 245 | +
|
| 246 | +嗯,小朋友很會用 AI 寫作文呢。 |
| 247 | +
|
| 248 | +(fin) |
0 commit comments