Game of Life kata: branchless Grid
In my previous log I have tackled Conway's Game Of Life.
In conclusion, I have stated that we could implement the full kata (i.e. even
the grid) without branches (no if
/else
/case
/pattern matching
, so no sum types).
As a reminder, we ended up with this:
-- ...
describe "Reproduction (three live neighbours)" $ do
it "Any dead cell with exactly three live neighbours becomes a live cell" $
reproduction.next Dead `shouldBe` Alive
it "Any live cell with three live neighbours lives on to the next generation" $
reproduction.next Alive `shouldBe` Alive
describe "Overpopulation (more than three live neighbours)" $ do
it "Any live cell with more than three live neighbours dies" $
overpopulation.next Alive `shouldBe` Dead
describe "Survive (two live neighbours)" $ do
it "Any Dead cell with fewer than three live neighbours stays dead on to the next generation" $
survive.next Dead `shouldBe` Dead
it "Any dead cell with exactly three live neighbours becomes a live cell" $
survive.next Alive `shouldBe` Alive
describe "Underpopulation (zero or one live neighbours)" $ do
it "Any Dead cell with fewer than three live neighbours stays dead on to the next generation" $
underpopulation.next Dead `shouldBe` Dead
it "Any live cell with fewer than two live neighbours dies" $
underpopulation.next Alive `shouldBe` Dead
-- ...
data Cell
= Alive
| Dead
deriving stock (Eq, Show)
newtype Neighbours
= Neighbours { getNeighbours :: Int }
deriving newtype (Eq, Ord, Show, Num)
newtype Neighbourhood
= Neighbourhood { next :: Cell -> Cell }
reproduction = Neighbourhood $ const Alive
overpopulation = Neighbourhood $ const Dead
survive = Neighbourhood id
underpopulation = Neighbourhood $ const Dead
Let's start with the neighbourhood selection.
If I have to draft things up, I would come up with an implementation like this:
neighbourhood =
\case
0 -> underpopulation
1 -> underpopulation
2 -> survive
3 -> reproduction
_ -> overpopulation
But there are two problems:
- There is branching
- I have no direct way to test it directly (I could test the function with its behavior, but it's not handy)
Note: here is the limit of the kata, in production code I would keep the here-above implementation while testing the functions.
Let's tackle the testability issue through refactoring adding the neighbourhood name:
data Neighbourhood = Neighbourhood
{ name :: String
, next :: Cell -> Cell
}
reproduction =
Neighbourhood
{ name = "reproduction"
, next = const Alive
}
overpopulation =
Neighbourhood
{ name = "overpopulation"
, next = const Dead
}
survive =
Neighbourhood
{ name = "survive"
, next = id
}
underpopulation =
Neighbourhood
{ name = "underpopulation"
, next = const Dead
}
Then, instead of taking a number for the function, let's take a Cell
s list
-- ...
it "No alive neighbours should be 'underpopulation'" $
(neighbourhood []).name `shouldBe` "underpopulation"
-- ...
neighbourhood = const underpopulation
Then we need to test survive
(2):
-- ...
it "Two alive neighbours should be 'survive'" $
(neighbourhood [Alive, Alive]).name `shouldBe` "survive"
-- ...
data Neighbourhood = Neighbourhood
{ name :: String
, next :: Cell -> Cell
, nextNeighbourhood :: Neighbourhood
}
underpopulation0 =
Neighbourhood
{ name = "underpopulation"
, next = const Dead
, nextNeighbourhood = underpopulation1
}
underpopulation1 =
Neighbourhood
{ name = "underpopulation"
, next = const Dead
, nextNeighbourhood = survive
}
survive =
Neighbourhood
{ name = "survive"
, next = id
, nextNeighbourhood = reproduction
}
reproduction =
Neighbourhood
{ name = "reproduction"
, next = const Alive
, nextNeighbourhood = overpopulation
}
overpopulation =
Neighbourhood
{ name = "overpopulation"
, next = const Dead
, nextNeighbourhood = overpopulation
}
neighbourhood = foldr (const (.nextNeighbourhood)) underpopulation0
So, a lot of things went on here:
- For each
Neighbourhood
, there is anextNeighbourhood
specified (which is theNeighbourhood
when there is one moreAlive
Cell
, it is what's called Church Encoding) - I have split
underpopulation
intounderpopulation0
andunderpopulation1
to represent0
and1
Alive
Cell
There's have few more coverage tests:
it "No alive neighbours should be 'underpopulation'" $
(neighbourhood []).name `shouldBe` "underpopulation"
it "One alive neighbours should be 'underpopulation'" $
(neighbourhood [Alive]).name `shouldBe` "underpopulation"
it "Two alive neighbours should be 'survive'" $
(neighbourhood [Alive, Alive]).name `shouldBe` "survive"
it "Three alive neighbours should be 'reproduction'" $
(neighbourhood [Alive, Alive, Alive]).name `shouldBe` "reproduction"
it "Four alive neighbours should be 'overpopulation'" $
(neighbourhood [Alive, Alive, Alive, Alive]).name `shouldBe` "overpopulation"
Good, now, we are able to break things dealing with Dead
Cell
.
-- ...
it "Two alive neighbours and three dead should be 'survive'" $
(neighbourhood [Dead, Alive, Dead, Alive, Dead]).name `shouldBe` "survive"
-- ...
neighbourhood = foldr go underpopulation0
where go =
\case
Alive -> (.nextNeighbourhood)
Dead -> id
Here we are:
- On
Dead
Cell
, keep theNeighbourhood
- On
Alive
Cell
, go to thenextNeighbourhood
But, but, but, conditions are back with the parttern matching
, we have to
rework the Cell
(using Church Encoding again):
newtype Cell = Cell (forall a. forall a. a -> a -> a)
show = runCell "Dead" "Alive"
x == y = runCell (Left ()) (Right ()) x == runCell (Left ()) (Right ()) y
runCell d a (Cell f) = f d a
alive = Cell $ \_ a -> a
dead = Cell const
I have to admit that's the first time I have written Eq
and Show
instances
for a function-based type (and I'm glad it went so well).
Finally we can rewrite neighbourhood
:
neighbourhood = foldr (runCell id (.nextNeighbourhood)) underpopulation0
Note: at this point I find the code way more brittle than a
case
/pattern matching
.
That's what happen when you have to parameters with the same type but which
position is important (i.e. are not interchangeable without changing the output).
Let's quickly improve that:
newtype WhenDead a = WhenDead a
newtype WhenAlive a = WhenAlive a
runCell (WhenDead d) (WhenAlive a) (Cell f) = f d a
neighbourhood = foldr (runCell (WhenDead id) (WhenAlive (.nextNeighbourhood))) underpopulation0
A bit verbose, but less error prone.
We can now finally proceed to the Grid
.
For simplicity reasons, instead of an infinite Grid
, we'll have a finite one.
Then we'll start with a simple design:
Grid
will be opaque (meaning the constructor won't be exported, though it'll be aMap
)- Providing going back-and-worth
Grid
through aSet Pos
forAlive
Cell
For the tests, I have chosen the blinker
on a 3x3 Grid
for our test cases:
describe "Grid" $ do
let verticalBlinkerCells = [Pos 1 0, Pos 1 1, Pos 1 2]
it "Vertical blinker should become horizontal" $
aliveCells (nextGrid $ mkGrid (Pos 2 2) verticalBlinkerCells)
`shouldBe` [Pos 0 1, Pos 1 1, Pos 2 1]
it "Vertical blinker should become vertical after two generations" $
aliveCells (nextGrid $ nextGrid $ mkGrid (Pos 2 2) verticalBlinkerCells)
`shouldBe` verticalBlinkerCells
Let's start with types:
data Pos = Pos
{ posX :: Int,
}
deriving stock (Eq, Ord, Show)
newtype Grid
= Grid { getGrid :: Map.Map Pos Cell }
deriving stock (Eq, Show)
Then the helpers:
mkGrid limits alives =
Grid $
Map.fromList
[ let p = Pos x y in (p, if Set.member p alives then alive else dead)
| x <- [0 .. limits.posX]
, y <- [0 .. limits.posY]
]
aliveCells =
Set.fromList
. mapMaybe (\(p, c) -> runCell (WhenDead Nothing) (WhenAlive $ Just p) c)
. Map.toList
. getGrid
And finally the function to evolve the Grid
:
nextGrid (Grid grid) =
Grid $
Map.mapWithKey (\p -> (neighbourhood $ neighbours p).next) grid
where neighbours (Pos x y) =
[ Map.findWithDefault dead (Pos (x + dx) (y + dy)) grid
| dx <- [(-1) .. 1]
, dy <- [(-1) .. 1]
, dx /= 0 || dy /= 0
]
And we're done.
Sure, it's not perfect, especially it will be slow on wide Grid
with few
Alive
Cell
, but I think it's acceptable for a code kata.