I was recently talking to someone about Scala best practices and revisited my TicTacToe code. Here’s version two!
A bunch of the improvements were insignificant. The entire diff can be viewed here.
Parentheses were removed from methods that didn’t take any parameters.
Default values for method parameters were removed to be clearer to the library user.
The Owner type was removed since it didn’t add any clarity.
The board length is now a variable, so you could easily play on an X by X game board.
All internal methods are now private.
I thought I knew about this when I first wrote TicTacToe. If the parent object of a case class is sealed, all match statements need to include every case. If every case isn’t covered, the match will throw a compile error.
We’ll never need an instance of the parent class, so we can make the Player a trait.
The player types, X and O, don’t need to hold a value, so we should use a case object instead of a case class. Case objects are singletons, so there is only ever one of them created.
Use an IndexedSeq instead of a List
When writing a library, you want to use the most generic type possible. We use an IndexedSeq instead of a Seq because the Range type inherits from IndexedSeq. For our TicTacToe board, no extra work is done to convert our board to an IndexedSeq.
Since we’re using an IndexedSeq instead of a List, we need to change how we check if a tile is empty or not. The syntax for decomposing a Seq is messy and I always forget it. We can use the isEmpty methods to check that the board space exists and that the board space is empty.
Give Better Names to Variables
Rename nextPlayer to currentPlayer. I always confuse next Thursday and this coming Thursday. I don’t know why I thought nextPlayer was less confusing.
Make the Game Status a Part of the Board Type
The old TicTacToe would calculate the game status every time Board.gameState was called. This is the most expensive part of TicTacToe. We definitely want to calculate the new status only once when we create a new state for the board.
One benefit of checking for victory when the user makes a moves is we only have to check the row, column, and diagonals for that move. We can ignore the entire rest of the board. We can split these checks into three different methods. Compare the new way of checking for a victory to the old way. The logic is simpler and we get to keep our immutable variables!
Refactor the Game Loop to be Tail Recursive
The TicTacToe game is recursive. We want it to be tail recursive, so it will reuse the same stack frame instead of indefinitely growing the stack. Our entire game logic is wrapped in a try catch block, but only the game.move method can throw an exception. Instead of using a lowercase try, we can use an uppercase Try, specifically a scala.util.Try. By wrapping our execution in a Try, we can assign it to a variable and use a match statement. Now our Try block has a clear execution path and always calls playGame in the tail position.
Using an IndexedSeq instead of a List doesn't make a lot of sense in our example, but it was something I learned when reviewing my TicTacToe implementation with someone who knows Scala better than me. Seqs have worse syntax for decomposing them into a head and a tail than Lists do.