練習簡介#

本章用 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-framesscore 是兩段純函式
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 版的關注點分離讓它在彈性與重用上更勝一籌。

但別忘了我們才談了短短十多行程式——後面題目越複雜,差距才會真正拉開。