練習簡介#
本章用 TDD 推導另一個經典 kata——保齡球計分(Bowling Game)——以對照 OO 與函式式風格。完整版可在《Clean Craftsmanship》第 1 章看到,這裡是壓縮版。
計分規則:一場保齡球共 10 個 frame,每 frame 至多兩球。打完 10 球(spare)下一球加分;打全倒(strike)下兩球加分。完美比賽(perfect game)= 300 分。
Java 版本#
從什麼都沒有的測試開始#
public class BowlingTest {
@Test
public void nothing() throws Exception {
}
}接著測試「能建出 Game」、「能滾一球」:
@Test
public void canRoll() throws Exception {
Game g = new Game();
g.roll(0);
}
public class Game {
public void roll(int pins) {}
}把建立 Game 的重複拉到 setUp,前兩個無意義的測試也就刪掉了:
public class BowlingTest {
private Game g;
@Before
public void setUp() throws Exception {
g = new Game();
}
}gutterGame 與 allOnes#
@Test
public void gutterGame() throws Exception {
for (int i = 0; i < 20; i++) g.roll(0);
assertEquals(0, g.score());
}
public int score() { return 0; }@Test
public void allOnes() throws Exception {
for (int i = 0; i < 20; i++) g.roll(1);
assertEquals(20, g.score());
}
public class Game {
private int score;
public void roll(int pins) { score += pins; }
public int score() { return score; }
}把測試中重複的 for 抽出 rollMany。
oneSpare 強迫重構演算法#
@Test
public void oneSpare() throws Exception {
rollMany(2, 5);
g.roll(7);
rollMany(17, 0);
assertEquals(24, g.score());
}要通過這個測試,必須把計分邏輯從 roll 搬到 score,並以「frame」為單位走訪:
public int score() {
int score = 0;
int frameIndex = 0;
for (int frame = 0; frame < 10; frame++) {
if (isSpare(frameIndex)) {
score += 10 + rolls[frameIndex + 2];
frameIndex += 2;
} else {
score += rolls[frameIndex] + rolls[frameIndex + 1];
frameIndex += 2;
}
}
return score;
}oneStrike 與 perfectGame#
加上 strike 條件,再透過幾個輔助方法讓 score 讀來如散文:
public int score() {
int score = 0;
int frameIndex = 0;
for (int frame = 0; frame < 10; frame++) {
if (isStrike(frameIndex)) {
score += 10 + strikeBonus(frameIndex);
frameIndex++;
} else if (isSpare(frameIndex)) {
score += 10 + spareBonus(frameIndex);
frameIndex += 2;
} else {
score += twoBallsInFrame(frameIndex);
frameIndex += 2;
}
}
return score;
}perfectGame 不需要任何修改就直接通過:
@Test
public void perfectGame() throws Exception {
rollMany(12, 10);
assertEquals(300, g.score());
}Clojure 版本#
Clojure 不需要 Game 類別、也沒有 roll。直接從 gutter game 開始:
(should= 0 (score (repeat 20 0)))
(defn score [rolls] 0)
repeat回傳重複值的序列,(repeat 20 0)是「20 個 0」的序列。
allOnes#
(should= 20 (score (repeat 20 1)))
(defn score [rolls]
(reduce + rolls))
reduce +把整個串列加總。reduce之後會發揮更大用處。
oneSpare 引出 frame#
把 rolls 拆成兩球一組的 frame:
(defn to-frames [rolls]
(partition 2 rolls))
(defn add-frame [score frame]
(+ score (reduce + frame)))
(defn score [rolls]
(reduce add-frame 0 (to-frames rolls)))
partition 2把[1 2 3 4 5 6]變成[[1 2] [3 4] [5 6]]。
但 spare 還沒過。為了把獎勵球塞進 frame,作者寫了一段「靠各種小技巧拼起來」的程式:
(defn to-frames [rolls]
(let [frames (partition 2 rolls)
possible-bonuses (map #(take 1 %) (rest frames))
possible-bonuses (concat possible-bonuses [[0]])]
(map concat frames possible-bonuses)))
(defn add-frame [score frame-and-bonus]
(let [frame (take 2 frame-and-bonus)]
(if (= 10 (reduce + frame))
(+ score (reduce + frame-and-bonus))
(+ score (reduce + frame)))))關鍵語法說明:
#(take 1 %):匿名函式,%是其唯一參數。%n代表第 n 個參數concat:串接多個 list;(concat [1 2] [3 4])得到[1 2 3 4]- 第二個
possible-bonuses是新的綁定,不是重新賦值
Clojure 提供太多「便利的小工具」,可以把資料快速喬成想要的形狀。一旦失控,這類小聰明就會主宰程式。當小技巧開始繁殖,就該重寫。
重寫成 loop#
(defn to-frames [rolls]
(loop [remaining-rolls rolls
frames []]
(cond
(empty? remaining-rolls)
frames
(= 10 (reduce + (take 2 remaining-rolls)))
(recur (drop 2 remaining-rolls)
(conj frames (take 3 remaining-rolls)))
:else
(recur (drop 2 remaining-rolls)
(conj frames (take 2 remaining-rolls))))))
(defn add-frames [score frame]
(+ score (reduce + frame)))
(defn score [rolls]
(reduce add-frames 0 (to-frames rolls)))這版乾淨多了,結構也開始接近 Java 版本。
oneStrike 加一條 cond 分支#
(cond
(empty? remaining-rolls)
frames
(= 10 (first remaining-rolls))
(recur (rest remaining-rolls)
(conj frames (take 3 remaining-rolls)))
(= 10 (reduce + (take 2 remaining-rolls)))
(recur (drop 2 remaining-rolls)
(conj frames (take 3 remaining-rolls)))
:else
(recur (drop 2 remaining-rolls)
(conj frames (take 2 remaining-rolls))))perfectGame 不會自動通過#
(should= 300 (score (repeat 12 10)))這次 Java 版本直接通過、Clojure 版本卻失敗——為什麼?
(defn score [rolls]
(reduce add-frames 0 (take 10 (to-frames rolls))))
to-frames會盡力把 rolls 拆成所有可能的 frame——它會超過 10 個!必須在score裡用take 10強制截斷。這也意味著 Clojure 版本把「累積擲球」與「計算分數」分得更開:
to-frames是一段純粹的資料轉換。
結論#
| 觀察點 | Java 版本 | Clojure 版本 |
|---|---|---|
| 是否有 Game 類別 | 有 | 無 |
| 累積與計分 | 耦合在 Game | 解耦:to-frames 與 score 是兩段純函式 |
cond vs if/else | 結構相似 | 結構相似 |
| 結果輸出 | 直接得到分數 | 先得到「含獎勵球的 frame」 |
| perfectGame 是否需要改動 | 不需 | 需要明確 take 10 |
幾個值得深思的點:
- OO 的便利可能藏著違反 SRP 的耦合:Game 同時負責「收球」與「計分」;Clojure 拆得更開
- Clojure 容易養出小技巧:要小心
(map #(take 1 %) ...)這種「半通不通」的便利寫法繁殖;當小技巧多到妨礙閱讀,就該重寫 - 同一個結構可以產生不同副產品:Java 的 if/else 和 Clojure 的 cond 形似,但 Java 直接累積分數,Clojure 卻先得到「含獎勵球的 frame」。後者是個漂亮的關注點分離
哪個比較好?Java 版較簡單但較耦合;Clojure 版的關注點分離讓它在彈性與重用上更勝一籌。
但別忘了我們才談了短短十多行程式——後面題目越複雜,差距才會真正拉開。