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 Cells 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 anextNeighbourhoodspecified (which is theNeighbourhoodwhen there is one moreAliveCell, it is what's called Church Encoding) - I have split
underpopulationintounderpopulation0andunderpopulation1to represent0and1AliveCell
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
DeadCell, keep theNeighbourhood - On
AliveCell, 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:
Gridwill be opaque (meaning the constructor won't be exported, though it'll be aMap)- Providing going back-and-worth
Gridthrough aSet PosforAliveCell
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.