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