Extract Till You Drop

How to go about extracting functions isn't always obvious at first.

August 10, 2021

You know that nagging feeling you sense that patterns of duplication are lurking in your code? The more software you write, the more frequently this feeling surfaces. Sometimes, though, it's not obvious how to DRY off the code.

Here's an example that's been causing that nagging feeling in me for several days now:

(defn frames [rolls]
    (empty? rolls) []

    (is-strike? rolls)
    (cons (take 3 rolls) (frames (drop 1 rolls)))

    (is-spare? rolls)
    (cons (take 3 rolls) (frames (drop 2 rolls)))

    (cons (take 2 rolls) (frames (drop 2 rolls)))))

This function takes a list of 'deliveries' as they are designated in the official rules of American Tenpins (Bowling) and partitions them into collections of 'frames' to facilitate scoring via summation later.

The formatting of the code above makes the duplication pretty easy to spot. Do you see it? It's the variation on this line:

(cons (take ? rolls) (frames (drop ? rolls)))

Each instance is a slightly different combination of:

  1. deliveries 'taken' and
  2. deliveries 'dropped'.

Here's how I resolved that duplication today:

(defn split-frame [rolls]
  (cond (is-strike? rolls) [(take 3 rolls) (drop 1 rolls)]
        (is-spare? rolls)  [(take 3 rolls) (drop 2 rolls)]
        :else              [(take 2 rolls) (drop 2 rolls)]))

(defn ->frames [rolls]
  (if (empty? rolls) []
    (let [[frame remaining] (split-frame rolls)]
      (cons frame (->frames remaining)))))

So, the ->frames function's only job now is to recursively build the frames listing. It does this by deferring to the newly created split-frame function, which now has the singular responsibility to determine how many deliveries (rolls) to take for the current frame's score, and how many to advance/drop to setup the next frame's starting point.

Usually, when you extract functions you end up with more code overall, but in this example, we end up with 3 fewer lines of code! I think this is because of the concise nature of Clojure, which tends to exaggerate any duplication that sneaks in.

Here's another thing I'm really enjoying about Clojure and this bowling game kata: unlike renditions of this exercise in procedural or object-oriented languages, the functional approach of transforming a list of 'deliveries' into frames has no need to keep track of the running total of the score at any point. The summing operation is deferred until the very last moment of the algorithm:

(defn score [rolls]
  (->> rolls ->frames (take 10) flatten (apply +)))