|
| 1 | +# Thinking and Sleeping Better With Clojure |
| 2 | + |
| 3 | +To prioritize a list of todo-items, as in my Prioritization-app I recently launched, I needed to get a list of the unique combinations for the user to choose from: |
| 4 | + |
| 5 | +It goes a little bit like this: |
| 6 | + |
| 7 | +> Which is more important: **A** or **B**? |
| 8 | +> Which is more important: **A** or **C**? |
| 9 | +> Which is more important: **B** or **C**? |
| 10 | +
|
| 11 | +I wrote a version in Ruby for the app and then a sketch in Clojure to compare readability and expressiveness. |
| 12 | + |
| 13 | +In my opinion, the Clojure version is much more readable and was far easier to reason about. |
| 14 | + |
| 15 | +Probably because I'm not good to solve problems in Ruby but there might be more than meets the eye here. |
| 16 | + |
| 17 | +## Creating Comparisons |
| 18 | + |
| 19 | +Let's say I have a todo-list of `A, B, C and D`. |
| 20 | + |
| 21 | +The list of comparisons I want is `A ↔ B, A ↔ C, A ↔ D, B ↔ C, B ↔ D and C ↔ D` to use later in my app. |
| 22 | + |
| 23 | +From the matrix below, you can see, I want only the _unique_ comparisons (`A ↔ B` and `B ↔ A` are interchangeable for my purposes) and skip dupes that compare an element with itself (`A ↔ A, B ↔ B, C ↔ C, D ↔ D`) |
| 24 | + |
| 25 | +To demonstrate, I only want the top-right triangle of comparisons: |
| 26 | + |
| 27 | +| × | A | B | C | D | |
| 28 | +|:---:|:---|---|---|---| |
| 29 | +| A | _skip (dupe)_ | **A ↔ B** | **A ↔ C** | **A ↔ D** | |
| 30 | +| B | _skip_ | _skip (dupe)_ | **B ↔ C** | **B ↔ D** | |
| 31 | +| C | _skip_ | _skip_ | _skip (dupe)_ | **C ↔ D** | |
| 32 | +| D | _skip_ | _skip_ | _skip_ | _skip (dupe)_ | |
| 33 | + |
| 34 | +### Thinking, Testing and Implementing in Ruby |
| 35 | + |
| 36 | +Now, since I started the app in Ruby, I translated the behavior above as follows: |
| 37 | + |
| 38 | +```ruby |
| 39 | +describe '.unique_combinations' do |
| 40 | + context 'input list is empty' do |
| 41 | + it 'returns an empty list' do |
| 42 | + result = Combinator.unique_combinations([]) |
| 43 | + expect(result).to eq([]) |
| 44 | + end |
| 45 | + end |
| 46 | + |
| 47 | + context 'input list only has one element' do |
| 48 | + it 'returns an empty list' do |
| 49 | + result = Combinator.unique_combinations([1]) |
| 50 | + expect(result).to eq([]) |
| 51 | + end |
| 52 | + end |
| 53 | + |
| 54 | + context 'input list only has two elements' do |
| 55 | + it 'returns a list with combination a' do |
| 56 | + result = Combinator.unique_combinations([1, 2]) |
| 57 | + expect(result).to eq([[1,2]]) |
| 58 | + end |
| 59 | + |
| 60 | + it 'returns a list with combination b' do |
| 61 | + result = Combinator.unique_combinations([:foo, :bar]) |
| 62 | + expect(result).to eq([[:foo, :bar]]) |
| 63 | + end |
| 64 | + end |
| 65 | + |
| 66 | + context 'input list only has three elements' do |
| 67 | + it 'returns a list with 3 combinations' do |
| 68 | + result = Combinator.unique_combinations([1, 2, 3]) |
| 69 | + expect(result).to eq([[1, 2], [1, 3], [2, 3]]) |
| 70 | + end |
| 71 | + end |
| 72 | + |
| 73 | + context 'input list only has four elements' do |
| 74 | + it 'returns a list with 6 combinations' do |
| 75 | + result = Combinator.unique_combinations([1, 2, 3, 4]) |
| 76 | + expect(result).to eq([[1, 2], [1, 3], [1, 4], [2, 3], [2, 4], [3, 4]]) |
| 77 | + end |
| 78 | + end |
| 79 | + End |
| 80 | +end |
| 81 | +``` |
| 82 | + |
| 83 | +...and wrote, after several failed attempts and one sleepless night, the following, working but not very elegant solution. I felt weird about it, because even looking at it now, I can't fully explain it well. It works, it has reasonable test-coverage. But I'm not proud of it. |
| 84 | + |
| 85 | +```ruby |
| 86 | +def self.unique_combinations(ids) |
| 87 | + return [] if ids.length < 2 |
| 88 | + |
| 89 | + all_combinations = [] |
| 90 | + |
| 91 | + for i1 in ids |
| 92 | + for i2 in ids |
| 93 | + if i1 != i2 |
| 94 | + contains = false |
| 95 | + for c in all_combinations |
| 96 | + if c[1] == i1 and c[0] == i2 |
| 97 | + contains = true |
| 98 | + end |
| 99 | + end |
| 100 | + unless contains |
| 101 | + all_combinations << [i1, i2] |
| 102 | + end |
| 103 | + end |
| 104 | + end |
| 105 | + end |
| 106 | + |
| 107 | + return all_combinations |
| 108 | + end |
| 109 | +``` |
| 110 | + |
| 111 | +Okay, fine I thought. How would I solve this issue in Lisp? |
| 112 | + |
| 113 | +### Thinking, Testing and Implementing in Clojure |
| 114 | + |
| 115 | +I don't know why but thinking in terms of lists and recursion instead of iteration made it simpler for me. |
| 116 | + |
| 117 | +The top-level function works as follows: |
| 118 | + |
| 119 | +```clojure |
| 120 | +(final-combinations '(A B C D)) |
| 121 | +;; => '((A B) (A C) (A D) (B C) (B D) (C D)) |
| 122 | +``` |
| 123 | + |
| 124 | +Yup, that's the list I want. |
| 125 | +Thinking about it I had an idea to break the problem further down: |
| 126 | + |
| 127 | +The complete list of unique comparisons, the one I want, is... |
| 128 | + |
| 129 | +1. the **union** of each row of one element combined with all the others. That's `A` combined with `B, C and D` |
| 130 | +2. Each row has an increasing **offset** from the left that starts at 0 for row 1 and increases by 1 for each following row. |
| 131 | +3. I have to subtract the **dupes** such as `A ↔ A` because I don't need them. |
| 132 | + |
| 133 | +For illustration purposes, here is the table again: |
| 134 | + |
| 135 | +| × | A | B | C | D | |
| 136 | +|:---:|:---|---|---|---| |
| 137 | +| A | _skip (dupe)_ | **A ↔ B** | **A ↔ C** | **A ↔ D** | |
| 138 | +| B | _skip_ | _skip (dupe)_ | **B ↔ C** | **B ↔ D** | |
| 139 | +| C | _skip_ | _skip_ | _skip (dupe)_ | **C ↔ D** | |
| 140 | +| D | _skip_ | _skip_ | _skip_ | _skip (dupe)_ | |
| 141 | + |
| 142 | +Given the above 3 steps, I basically just wrote them out as functions. |
| 143 | + |
| 144 | +I derived the first function, `combine` which takes `x` and `l` and returns a list of all possible combinations, including the ones we'll later skip. |
| 145 | + |
| 146 | +```clojure |
| 147 | +(with-test |
| 148 | + (defn combine |
| 149 | + ([x l] |
| 150 | + (combine x l '())) |
| 151 | + ([x l acc] |
| 152 | + (cond (empty? l) (reverse acc) |
| 153 | + :else (recur x (rest l) (conj acc (list x (first l)))) ))) |
| 154 | + |
| 155 | + (is (= (combine 1 '()) '())) |
| 156 | + (is (= (combine 1 '(1)) '((1 1)))) |
| 157 | + (is (= (combine 1 '(2)) '((1 2)))) |
| 158 | + (is (= (combine 1 '(1 2)) '((1 1) (1 2)))) |
| 159 | + (is (= (combine 1 '(1 2 3)) '((1 1) (1 2) (1 3))))) |
| 160 | +``` |
| 161 | + |
| 162 | +Then, I use `combine` to create basically the table above but with nothing yet removed. |
| 163 | + |
| 164 | +```clojure |
| 165 | +(with-test |
| 166 | + (defn combine-lists |
| 167 | + ([l1 l2] |
| 168 | + (combine-lists l1 l2 '())) |
| 169 | + ([l1 l2 acc] |
| 170 | + (cond (empty? l1) (reverse acc) |
| 171 | + :else (recur (rest l1) l2 (cons (combine (first l1) l2) acc)) ))) |
| 172 | + |
| 173 | + (is (= (combine-lists '() '()) '())) |
| 174 | + (is (= (combine-lists '(1) '(1)) '(((1 1))))) |
| 175 | + (is (= (combine-lists '(1 2) '(1 2)) '(((1 1) (1 2)) |
| 176 | + ((2 1) (2 2)))))) |
| 177 | +``` |
| 178 | + |
| 179 | +Then I introduce the idea of an offset. To only take those elements in a list that are after a certain offset. |
| 180 | + |
| 181 | +```clojure |
| 182 | +(with-test |
| 183 | + (defn take-rest |
| 184 | + ([offset l] |
| 185 | + (cond (= offset 0) l |
| 186 | + :else (recur (dec offset) (rest l))))) |
| 187 | + |
| 188 | + (is (= (take-rest 0 '()) '())) |
| 189 | + (is (= (take-rest 1 '()) '())) |
| 190 | + (is (= (take-rest 0 '(1)) '(1))) |
| 191 | + (is (= (take-rest 0 '(1 2)) '(1 2))) |
| 192 | + (is (= (take-rest 1 '(1 2)) '(2))) |
| 193 | + (is (= (take-rest 1 '(1 2 3)) '(2 3))) |
| 194 | + (is (= (take-rest 2 '(1 2 3)) '(3))) |
| 195 | + (is (= (take-rest 2 '(1 2 3 4)) '(3 4)))) |
| 196 | +``` |
| 197 | + |
| 198 | +Now I can use `take-rest` and apply it to each row in my matrix... |
| 199 | + |
| 200 | +```clojure |
| 201 | +(defn unique-combinations |
| 202 | + ([offset ll] |
| 203 | + (unique-combinations offset ll '())) |
| 204 | + ([offset ll acc] |
| 205 | + (cond (empty? ll) (reverse acc) |
| 206 | + :else (recur (inc offset) (rest ll) (cons (take-rest offset (first ll)) acc))))) |
| 207 | +``` |
| 208 | + |
| 209 | +And finally remove the identity items such as `A ↔ A` |
| 210 | + |
| 211 | +```clojure |
| 212 | +(with-test |
| 213 | + (defn final-combinations |
| 214 | + [l] |
| 215 | + (filter #((= (first %) (second %)))) (partition 2 (flatten (unique-combinations 1 (combine-lists l l))))) |
| 216 | + |
| 217 | + (is (= (final-combinations '()) '())) |
| 218 | + (is (= (final-combinations '(1)) '())) |
| 219 | + (is (= (final-combinations '(1 2)) '((1 2)))) |
| 220 | + (is (= (final-combinations '(1 2 3)) '((1 2) (1 3) (2 3)))) |
| 221 | + (is (= (final-combinations '(1 2 3 4)) '((1 2) (1 3) (1 4) (2 3) (2 4) (3 4))))) |
| 222 | +``` |
| 223 | + |
| 224 | +Granted, this code is not yet refactored and in total more lines than the Ruby version, but here is the rub: |
| 225 | + |
| 226 | +It took me **20 minutes** to think up and implement the solution in Clojure and 2 days including a sleepless night to do it in Ruby. |
| 227 | + |
| 228 | +I don't know what that means but I'll probably use a Lisp like Clojure for problems like this in the future. Just to make sure I get some sleep. |
| 229 | + |
| 230 | +* |
| 231 | + |
1 | 232 | # clojure-scribbles |
2 | 233 |
|
3 | 234 | A few experiments learning Clojure, mostly recursive functions inspired by "The Little Schemer." |
|
0 commit comments