Skip to content

Commit a68eec6

Browse files
committed
新增命令式程式碼重構到函數式 Pipe 流水線的實作筆記,提升程式碼簡潔性與可維護性
1 parent 3e1cf5b commit a68eec6

File tree

1 file changed

+248
-0
lines changed

1 file changed

+248
-0
lines changed
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
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

Comments
 (0)