2
\$\begingroup\$

I have been doing OOP and imperative programming my whole career but I wanted to dabble in Scala along with functional programming. Rather than writing Java like code in Scala I wanted to try a more functional approach. I decided to try a simple Kata with Scala, Game of Life. The following is what I came up with. I admit I am very much stuck in OOP/imperative mindset. I am not sure if this is the best approach for Scala, or functional programming. I avoided functions that mutate objects, and favor returning a new copy. I was unsure what the best approach is with functional programming to ensure objects can't be created in an invalid state. Is there a way I can make my code more aligned with idiomatic Scala and functional programming.

package io.jkratz.katas.gameoflife
import scala.util.Random
case class Board(grid: Array[Array[Int]]) {
 require(grid != null, "grid cannot be null")
 require(!isJagged(grid), "grid cannot be jagged")
 require(isValid(grid), "grid contains invalid values, 0 and 1 are the only valid values")
 val rows: Int = {
 grid.length
 }
 val columns: Int = {
 grid(0).length
 }
 def evolve(): Board = {
 val newGrid = Array.ofDim[Int](rows, columns)
 for (i <- grid.indices) {
 for (j <- grid(0).indices) {
 newGrid(i)(j) = getNextCellState(i,j)
 }
 }
 Board(newGrid)
 }
 private def getNextCellState(i:Int, j: Int): Int = {
 var liveCount = 0
 val cellValue = grid(i)(j)
 for (x <- -1 to 1; y <- -1 to 1) {
 if (i + x < 0 || i + x > (this.rows - 1) || y + j < 0 || y + j > (this.columns - 1)) {
 // do nothing, out of bounds
 } else {
 liveCount += grid(i + x)(j + y)
 }
 }
 liveCount -= cellValue
 if (cellValue.equals(Board.CELL_ALIVE) && (liveCount < 2 || liveCount > 3)) {
 Board.CELL_DEAD
 } else if (cellValue.equals(Board.CELL_DEAD) && liveCount == 3) {
 Board.CELL_ALIVE
 } else {
 cellValue
 }
 }
 private def isJagged(grid: Array[Array[Int]]): Boolean = {
 var valid = true
 val size = grid(0).length
 grid.foreach(row => if (row.length.equals(size)) valid = false)
 valid
 }
 private def isValid(grid: Array[Array[Int]]): Boolean = {
 var valid = true
 for (i <- grid.indices; j <- grid(0).indices) {
 val x = grid(i)(j)
 if (x != 0 && x != 1) {
 valid = false
 }
 }
 valid
 }
}
object Board {
 val CELL_DEAD = 0
 val CELL_ALIVE = 1
 val DEFAULT_ROWS = 10
 val DEFAULT_COLUMNS = 10
 def random(rows: Int = DEFAULT_ROWS, columns: Int = DEFAULT_COLUMNS): Board = {
 val grid = Array.ofDim[Int](rows, columns)
 for (i <- grid.indices) {
 for (j <- grid(0).indices) {
 grid(i)(j) = Random.nextInt(2)
 }
 }
 Board(grid=grid)
 }
 def prettyPrint(board: Board): Unit = {
 val grid = board.grid
 for (i <- grid.indices) {
 for (j <- grid(0).indices) {
 if (grid(i)(j) == 0) print(" - ") else print(" * ")
 }
 println()
 }
 }
}

And my main entry point.

package io.jkratz.katas.gameoflife
object Life {
 def main(args: Array[String]): Unit = {
 val state0 = Board.random(5,5)
 println("State 0")
 Board.prettyPrint(state0)
 val state1 = state0.evolve()
 println("State 1")
 Board.prettyPrint(state1)
 }
}
asked Dec 18, 2018 at 0:03
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

You're doing a lot of indexing, which is efficient in an Array, but indicates that you're thinking in small steps. As you get to know the Scala Standard Library you start thinking in larger chunks because it offers many ways to process data collections all at once.

I'll start at the bottom and work my way up.

*prettyPrint() - Testing for value 0 means that the prettyPrint() method knows about the underlying representation. If you test for CELL_DEAD and/or CELL_ALIVE then prettyPrint() should still work even if the grid implementation changes.

Here I chose to turn each row into a String and then println() it.

def prettyPrint(board: Board): Unit =
 board.grid
 .map(_.map(c => if (c == CELL_DEAD) " - " else " * ").mkString)
 .foreach(println)

*random() - Most Scala collections offer many different "builder" methods on the companion object. Here I use fill() to populate a 2-dimensional Array.

def random(rows:Int = DEFAULT_ROWS, columns:Int = DEFAULT_COLUMNS): Board =
 Board(Array.fill(rows,columns)(Random.nextInt(2)))

*isValid() - The Standard Library doesn't offer many collection methods with early termination, but forall() is one of them. It stops after the first false encountered.

Here I use -2 as a bit-mask to test the value of all bits except for the lowest.

private def isValid(grid: Array[Array[Int]]): Boolean =
 grid.forall(_.forall(n => (n & -2)==0))

*isJagged() - The exists() method is the compliment of forall(). It stops after the first true encountered.

private def isJagged(grid: Array[Array[Int]]): Boolean =
 grid.exists(_.length != grid.head.length)

*liveCount - Idiomatic Scala avoids mutable variables. In order to calculate the value of liveCount once, without any post-evaluation adjustments, we'll want a way to get all valid neighbor-cell indexes, also without any post-evaluation adjustments.

val liveCount = (for {
 x <- (0 max i-1) to (i+1 min rows-1)
 y <- (0 max j-1) to (j+1 min columns-1)
} yield grid(x)(y)).sum - cellValue

*evolve() - tabulate() is another one of those "builder" methods that appears to do everything you need, in this situation, all at once. In this case, because we're building a 2-D Array, tabulate() passes two arguments, the row index and the column index, to its lambda argument. And because the getNextCellState() method takes those same two arguments in the same order, they don't need to be explicitly specified.

def evolve(): Board = Board(Array.tabulate(rows,columns)(getNextCellState))

It's worth noting that you test if grid is null but you don't test to see if it's empty. Board.random(0,0) will throw a run-time error.

answered Dec 18, 2018 at 6:43
\$\endgroup\$
1
  • \$\begingroup\$ Wow this is a great answer and well explained! Thanks for taking the time to provide a very detailed answer. \$\endgroup\$ Commented Dec 18, 2018 at 13:31

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.