Part 1
In my initial Clojure implementation of Tetris Analyzer I chose to represent the board as a one-dimensional vector, then in my second attempt I tried a two-dimensional vector.
The result for both was disapointingly ugly, and when it's ugly - then it's wrong!
Ugly code is often complex. The first met all my criterias of complex code:
The result for both was disapointingly ugly, and when it's ugly - then it's wrong!
Ugly code is often complex. The first met all my criterias of complex code:
- Code that is hard to read and understand
- Code that is hard to change
- When it's too much code
We will compare the rejected versions with the chosen one at the end of this post.
The new board model
After some thinking I realized that the board could be represented as a hash map where each key corresponds to a position on the board, stored as a vector "pair", [y x]:
row->str
This function converts a board row to a string:
(defn- row->str [row] (apply str (map (fn [[_ piece]] (piece->char piece)) row)))If we feed the inner map function with a board row, we get:
(map (fn [[_ piece]] (piece->char piece)) {[1 0] 9, [1 1] 0, [1 2] 1, [1 3] 2 }) ;= (\# \- \I \Z)The next two functions, apply and str, convert the list of characters to a string:
(apply str '(\# \- \I \Z)) ;= "#-IZ"
board->str
This function converts a board into a printable string format. It uses the ->> macro which allows us to arrange the functions in the order they are executed:
(defn board->str [board width] (->> board sort (partition width) (map row->str) (clojure.string/join "\n")))...which is equivalent to:
(defn board->str [board width] (clojure.string/join "\n" (map row->str (partition width (sort board)))))
It's a matter of taste which one you prefer. Let's try to explain this function, one step at a time, by calling it with a 5 x 2 board:
1. sort the board based on the order of the keys:
(sort { [0 1] 0, [0 0] 9, [1 2] 1, [0 3] 0, [0 4] 9, [1 1] 0, [1 0] 9, [0 2] 1, [1 3] 2, [1 4] 9 }) ;= ( [0 0] 9, [0 1] 0, [0 2] 1, [0 3] 0, [0 4] 9, [1 1] 9, [1 0] 0, [1 2] 1, [1 3] 2, [1 4] 9 )2. partition into rows:
(partition 5 '( [0 0] 9, [0 1] 0, [0 2] 1, [0 3] 0, [0 4] 9 , [1 1] 9, [1 0] 0, [1 2] 1, [1 3] 2, [1 4] 9 )) ;= ( ([[0 0] 9] [[0 1] 0] [[0 2] 1] [[0 3] 0] [[0 4] 9]) ([[1 0] 9] [[1 1] 0] [[1 2] 1] [[1 3] 2] [[1 4] 9]) )3. map each row to a string:
(map row->str '( ([[0 0] 9] [[0 1] 0] [[0 2] 1] [[0 3] 0] [[0 4] 9]) ([[1 0] 9] [[1 1] 0] [[1 2] 1] [[1 3] 2] [[1 4] 9])) ) ;= ("#-I-#" "#-IZ#")4. join the rows into a board, concatenate with "\n":
(clojure.string/join "\n" '("#-I-#" "#-IZ#")) ;= ("#-I-#\n#-IZ#")And if printed, we get the "board":
#-I-# #-IZ#
set-piece
The set-piece function can set a piece on a board:
(defn set-piece [board piece rotation x y] (apply assoc board (rotate-and-move-piece piece rotation x y)))
The function's test explains pretty well what it does:
;; Returns a new board with a piece set, e.g.: ;; ;; board piece rotation x y ;; ----------- ----- -------- - - ;; (set-piece empty-board 2 0 3 1) ;; ;; piece = 2 piece Z ;; rotation = 0 no rotation ;; x,y = 3,1 position on the board ;; (expect (str "#------#\n" "#--ZZ--#\n" "#---ZZ-#\n" "#------#\n" "#------#\n" "########") (board->str (set-piece empty-board 2 0 3 1) 8))
The inner form of the function produces a list with elements that has the same format as the board cells; [y x] value:
(rotate-and-move-piece 6 1 4 2) ;= ([2 4] 6, [3 4] 6, [3 5] 6, [4 4] 6)...the resulting list is used by apply and assoc to put a piece on the board. Note that the original board is left untouched and that a new board is returned. What makes this possible is the immutable collections in Clojure.
str->row
This function is defined as private by defn- as it's only used by new-board:
(defn- str->row [row y] (map-indexed #(vector [y %1] (char->piece %2)) row))...and converts a board row to board cells:
(str->row "#-TZ#" 1) ;= ([[1 0] 9] [[1 1] 0] [[1 2] 6] [[1 3] 2] [[1 4] 9])
The #( ) syntax is the shortcut form for anonymous functions in Clojure. The map-indexed function is similar to map except that it also offers an index to each element (0, 1, 2...) here represented by %1.
new-board
The last function is used to create a new board:
(defn new-board ([] (new-board 12 21)) ([rows] (into {} (mapcat #(str->row %1 %2) rows (range)))) ([width height] (into {} (for [y (range height) x (range width) :let [wall? (or (zero? x) (= x (dec width)) (= y (dec height)))]] [[y x] (if wall? 9 0)]))))This overloaded function can be called in three ways. If called with no arguments, we get a 10 x 20 board:
(board->str (new-board) 12) ;= "#----------#\n" "#----------#\n" "#----------#\n" "#----------#\n" "#----------#\n" "#----------#\n" "#----------#\n" "#----------#\n" "#----------#\n" "#----------#\n" "#----------#\n" "#----------#\n" "#----------#\n" "#----------#\n" "#----------#\n" "#----------#\n" "#----------#\n" "#----------#\n" "#----------#\n" "#----------#\n" "############"The function board->str was added to get a more readable format in this example. The second way to call this function is by giving the board size:
(board->str (new-board 6 4) 6) ;= "#----#\n" "#----#\n" "#----#\n" "######"The last way is to call it with a board position, which is done by the corresponding tests:
(expect { [0 0] 9, [0 1] 0, [0 2] 6, [0 3] 0, [0 4] 0, [0 5] 9, [1 0] 9, [1 1] 6, [1 2] 6, [1 3] 0, [1 4] 2, [1 5] 9, [2 0] 9, [2 1] 9, [2 2] 9, [2 3] 9, [2 4] 9, [2 5] 9 } (new-board ["#-T--#" "#TT-Z#" "######"]))The new-board function uses these functions: for, into, mapcat, range, zero?, = and dec (for is actually a macro). It would take up too much space here to explain them all, especially as the official documentation is excellent with lot of examples!
The rejected design
I started by representing the board as a vector (the complete source can be found here). This seemed to be a good design choice, and was used in the C++ version.
Looping collections by index is a natural thing to do in imperative languages like C++ or Java. The problem is that it's not as natural in a functional language like Clojure.
A telling example is the set-piece function in the version that represents the board as a two dimensional vector:
(defn set-piece [board x y p piece] (reduce (fn [new-board [py xprow]] (assoc new-board py (apply assoc (new-board py) xprow))) board (map #(vector %2 (prow->xprow % x p)) piece (iterate inc 0))))
The code is complex and hard to read and understand. The new solution is much more elegant:
(defn set-piece [board piece rotation x y] (apply assoc board (rotate-and-move-piece piece rotation x y)))
The lesson from this is to choose your core models with care and change the design before it's too late!
Best regards,
Joakim Tengstrand
0 comments
Post a Comment