The Life Comonadic

A Brainfuck evaluator is interesting and all, but far too one-dimensional. Lets’s kick things up a dimension, it’s time for C++ template compile-time Conway’s Game of Life.

This post translates a Haskell comonad based Life implementation into a C++ template meta-program. Much like with the Brainfuck evaluator, once you get over the syntax, C++ templates turn out to be a fairly competent functional language.

I’ll start by defining the data structures and operations that will be used to implement Life. The lazy, compile-time list previously defined will be used heavily. A row of the world is encoded as zipper of cells, with the world grid is simply a zipper of rows. Comonads for both one dimensional and two dimensional zippers make it easy to define and implement the rules of Life.

Complete source code can be found here.

One Dimensional Zipper

The zipper is a one dimensional structure, a list that also encodes the context of a position in that list. Zippers will be used both as rows of cells, and also to zipper rows to create the vertical dimension of the Life grid.

In our zipper structure, x is the focus, l is a list of elements to the right of the focus, and r is a list of elements to the right of the focus. The first element of both l and r are the direct neighbors of the focus, which requires l to be stored in reverse order.

template < typename l , typename x , typename r > struct Zipper { /*- movement -*/ /*- editing -*/ /*- fmap -*/ /*- to_list -*/ };

We change our position in the zippered list by shifting the context in either direction, referred to as left and right movement. The movement operations take the next element from the side of the zippered list we are moving towards. This becomes the new focus, and the old focus is consed onto the other side of the list.

template < typename l , typename x , typename r > struct Zipper { using left = Zipper < cdr < l > , car < l > , cons < x , r >> ; using right = Zipper < cons < x , l > , car < r > , cdr < r >> ; /*- editing -*/ /*- fmap -*/ /*- to_list -*/ };

A zipper can get and edit its focus in constant time. get returns the focus, while put replaces the current focus.

template < typename l , typename x , typename r > struct Zipper { /*- movement -*/ using get = X ; template < typename val > using put = Zipper < l , val , r > ; /*- fmap -*/ /*- to_list -*/ };

Unbound helper functions that take a zipper are also helpful since they reduce the need using the template and typename keywords. This makes the similarities between C++ templates and more traditional functional languages clearer.

template < typename z > using get = typename z :: get ; template < typename z , typename x > using put = typename z :: template put < x >;

Let’s also implement Functor for Zippers. fmap maps a function f over every element of the zipper, producing a new zipper with the results.

template < typename l , typename x , typename r > struct Zipper { /*- movement -*/ /*- editing -*/ template < template < typename > class f > using fmap = Zipper < map < f , l > , typename F < x >:: type , map < f , r >> ; /*- to_list -*/ };

An unbound version of fmap taking a zipper z will also be used.

template < template < typename > class f , typename z > using fmap = typename z :: template fmap < f >;

To more closely emulate Haskell, we could instead define a Functor interface struct and implement fmap for Zipper using template specialization of Functor .

template < typename > struct Functor ; template < typename l , typename x , typename r > struct Functor < Zipper < l , x , r >> { template < template < typename > class f > using fmap = Zipper < map < f , l > , typename F < x >:: type , map < f , r >> ; };

This approach is a bit more verbose, but may make interfaces more clear and explicit. It greatly benefits from the use of helper function that calls fmap by automatically wrapping the callee in a Functor .

template < template < typename > class f , typename x > using fmap_functor = typename Functor < x >:: template fmap < f >;

The zippers used to implement Life will all be infinite, but to print out the results of the simulation we also need a way to select a finite range of elements from a zipper. to_list takes count elements from the left and right sides of the focus and constructs a list of count * 2 + 1 elements. The left side of the list is stored in reverse order, and must be reversed again during the conversion.

template < typename l , typename x , typename r > struct Zipper { /*- movement -*/ /*- editing -*/ /*- fmap -*/ template < size_t count > using to_list = concat < reverse < take < count , L >> , concat < cons < X , Nil > , take < count , R >>> ; };

Zipper Comonad

The comonad interface requires the implementation of two operations: extract and either extend or duplicate . extend and duplicate can both be implemented in terms of each other.

For the comonad of a zipper , the extract operation is already defined as get . I’ll use the extend and duplicate comonad definition to complete the interface.

For a zipper, duplicate basically rewrites every value in the zipper to a zipper focused at that value. The result is a zipper of zippers. To actually accomplish this, we take a zipper z and create a new zipper that contains: Zipper<List<left<z>, left<left<z>>, ...>, z, List<right<z>, right<right<z>>, ...>> . The move operation generalizes this pattern for any left and right mapping functions.

template < template < typename > class f , typename x > using iterate_rest = cdr < iterate < f , x >> ; template < template < typename > class left_mapper , template < typename > class right_mapper , typename z > using move = Zipper < iterate_rest < left_mapper , z > , z , iterate_rest < right_mapper , z >> ;

duplicate maps the go_left and go_right functions, which shift a zipper left or right respectively, over a zipper z using move .

template < typename z > struct go_left { using type = typename z :: left ; }; template < typename z > struct go_right { using type = typename z :: right ; }; template < typename z > using duplicate = move < go_left , go_right , z > ;

extend is then implemented using duplicate .

template < template < typename > class f , typename z > using extend = fmap < f , duplicate < z >> ;

Plane Zipper

The zipper is a one dimensional data structure while we need a two-dimensional grid for life. So, much like how you can use an arrays of arrays in C to create a 2D matrix, we’ll use a zipper of zippers to build an infinite grid.

PlaneZipper take a zipper of zippers z .

template < typename z > struct PlaneZipper { /*- movement -*/ /*- editing -*/ /*- fmap -*/ };

The outermost zipper acts as the vertical axis. Each inner zipper contains a single row of cells of the world. Movement is accomplished by shifting the focus, much like with zipper , but now we can move in four directions: up, down, left, and right.

Movement along the vertical simply moves the outer zipper to focus on a new row. This is accomplished by the up and down operations.

Movement along the horizontal is a bit more complicated. Simply moving the inner zipper left or right, z::get::left or z::get::right , only moves a single row of the grid, leaving all other rows in place. Instead, horizontal movement in the grid must shift all rows, so that the cells remain in the same relative positions, and so that the current row moves to a new focus. Shifting all rows of the grid in one direction or the other is accomplished by a fmap over the outer zipper using the go_left and go_right zipper movement functions.

template < typename z > struct PlaneZipper { using up = PlaneZipper < typename z :: left > ; using down = PlaneZipper < typename z :: right > ; using left = PlaneZipper < fmap < go_left , z >> ; using right = PlaneZipper < fmap < go_right , z >> ; /*- editing -*/ /*- fmap -*/ };

The actual value at the focus is read by first reading the outer zipper to get the current row zipper and then reading the row zipper. Editing the focus similarly edits the outer zipper to replace the current row, with the current row edited to replace the value at the focus.

template < typename z > struct PlaneZipper { /*- movement -*/ using get = get < get < z >> ; template < typename val > using put = PlaneZipper < put < z , put < typename z :: get , val >>> ; /*- fmap -*/ };

PlaneZipper also is a Functor. Mapping a function f over a plane zipper applies f to every value in the grid, building a new grid from the results. For each row in zipper z , we fmap the outer zipper first with a functor do_fmap . do_fmap is applied to every row in the grid and is basically a manually curring of the fmap function, fmapping the row with function f .

template < typename z > struct PlaneZipper { /*- movement -*/ /*- editing -*/ template < template < typename > class f > struct do_fmap { template < typename x > struct apply { using type = fmap < f , x > ; }; }; template < template < typename > class f > using fmap = PlaneZipper < fmap < do_fmap < f >:: template apply , z > > ; };

Plane Zipper Comonad

The PlaneZipper comonad already implements extend as get . duplicate is implemented by creating a grid of plane zippers focused at each value. vertical create the vertical shift components of this grid, while horizontal creates the rows.

template < typename z > using horizontal = move < go_left , go_right , z > ; template < typename z > using vertical = move < go_up , go_down , z > ;

duplicate applies both the vertical and horizontal duplication to build the plane zipper of contexts.

template < typename z > struct go_horizontal { using type = horizontal < z > ; }; template < typename z > using duplicatePlane = PlaneZipper < fmap < go_horizontal , vertical < z >>> ; template < template < typename > class F , typename z > using extendPlane = fmap < F , duplicatePlane < z >> ;

Life

After establishing all of our data structures and operations, implementing life turns out to the easiest part of the whole process.

Conway’s Game of Life is simulated on a 2D, infinite grid of cells. Every cell is either alive or dead. Time is broken into discrete steps or generations. A transition function determines the state of the next generation based entirely on the state of the current generation.

For every cell in the grid, the transition function does the following:

If the cell is alive and it has less than 2 living neighbors, the cell dies.

If the cell is alive and it has 2 or 3 living neighbors, it stays alive.

If the cell is alive and it has more than 3 living neighbors, the cell dies.

If the cell is dead and it has 3 living neighbors, the cell becomes alive.

Cell

Cells are a boolean state of either living or dead. We’ll encode cells using the Cell type, along with aliases for living and dead cell types.

template < bool alive > struct Cell { static const bool value = alive ; }; using DeadCell = Cell < false > ; using LiveCell = Cell < true > ;

Rules

The next state of a cell is determined solely by the cell’s current state and the state of its direct neighbors. living_neighbors_count takes a plane zipper z and by examines the state of the focus’s eight neighboring cells to determine the total number of living neighboring cells of the focus.

template < typename z > struct living_neighbors_count { template < typename neighbor > struct get_weight { enum { value = neighbor :: get :: value ? 1 : 0 }; }; enum { value = get_weight < typename z :: left >:: value + get_weight < typename z :: right >:: value + get_weight < typename z :: up >:: value + get_weight < typename z :: down >:: value + get_weight < typename z :: left :: up >:: value + get_weight < typename z :: left :: down >:: value + get_weight < typename z :: right :: up >:: value + get_weight < typename z :: right :: down >:: value }; };

The Life transition function is encoded in life_rule . It determines the next state of the cell at the focus of plane zipper z based on its current state and the state of its neighbors.

template < typename z > struct life_rule { using living = living_neighbors_count < z > ; using type = typename std :: conditional < living :: value == 2 , typename z :: get , Cell < living :: value == 3 >>:: type ; };

evolve applies the transition function to all cells in the plane zipper world .

template < typename world > struct evolve { using type = extendPlane < life_rule , world > ; };

Output

Our compile time Life implementation outputs a type that encodes the state of the game world. This type is completely useless on its own, but we can write simple printing functions that transform the type into a set of operations that print it out at runtime.

Printing of the various types uses the Print struct interface.

template < typename > struct Print ;

Print is specialized for each type we are interested in, with specializations implementing a static Do function that writes the value of the input type to stdout.

template < > struct Print < Cell < true >> { static void Do () { std :: cout << "X" ; } }; template < > struct Print < Cell < false >> { static void Do () { std :: cout << "-" ; } };

Lists are printed by first printing the head of the list, and then recursively printing the rest of the list.

template < typename X , template < typename > class XS > struct Print < List < X , XS >> { static void Do () { Print < car < List < X , XS >>>:: Do (); Print < cdr < List < X , XS >>>:: Do (); } }; template < > struct Print < Nil > { static void Do () { /* noop */ } };

One-dimensional zippers are printed by converting them to a fixed size list, in this case five elements from each side of the focus for a total of eleven columns, and then printing the contents of the list. Since these zippers form the rows in the Life world, a new line is printed after the contents of the zipper are printed.

template < typename l , typename x , typename r > struct Print < Zipper < l , x , r >> { static void Do () { Print < typename Zipper < l , x , r >:: template to_list < 5 > >:: Do (); std :: cout << "

" ; } };

Finally, two-dimensional plane zippers are printed by printing the rows of the world. Again, five elements from each size of the focus are taken for a total of eleven rows.

template < typename z > struct Print < PlaneZipper < z >> { static void Do () { Print < typename z :: template to_list < 5 > >:: Do (); std :: cout << "

" ; } };

Putting It All Together

The base state of Life is an infinite plane of dead cells. Each row is a zippered, infinite lazy lists of dead cells.

using inf_dead_list = gen < DeadCell > ; using dead_row = Zipper < inf_dead_list , DeadCell , inf_dead_list > ;

The initial state of a row is written with the line function.

template < typename ... values > using line = Zipper < inf_dead_list , DeadCell , concat < list_from_params < values ... > , inf_dead_list >> ;

The vertical is a zippered, infinite lazy list of dead rows, forming a dead plane of cells.

using inf_dead_rows = gen < dead_row > ; using dead_plane = Zipper < inf_dead_rows , dead_row , inf_dead_rows > ;

The initial state of the world is expressed as a zipper of rows, with each row expressed using line . This example shows a glider structure.

using glider = PlaneZipper < Zipper < inf_dead_rows , dead_row , concat < list_from_params < line < DeadCell , LiveCell , DeadCell > , line < DeadCell , DeadCell , LiveCell > , line < LiveCell , LiveCell , LiveCell >> , inf_dead_rows >>> ;

----------- ----------- ----------- ----------- ----------- ----------- -------X--- --------X-- ------XXX-- ----------- -----------

Running

Each generation of the world is constructed by invoking evolve on the current state. For example, the third generation of the glider is computed, typename evolve<typename evolve<glider>::type>::type .

An infinite list of generations of the glider structure is expressed, iterate<evolve, glider> .

To actually print out the results, we must select a fixed number of generations from the list. This example prints the first 4 generations of the glider.

int main ( int argc , const char * argv []) { Print < take < 4 , iterate < evolve , glider >>>:: Do (); return 0 ; }

----------- ----------- ----------- ----------- ----------- ----------- -------X--- --------X-- ------XXX-- ----------- ----------- ----------- ----------- ----------- ----------- ----------- ----------- ----------- ------X-X-- -------XX-- -------X--- ----------- ----------- ----------- ----------- ----------- ----------- ----------- ----------- --------X-- ------X-X-- -------XX-- ----------- ----------- ----------- ----------- ----------- ----------- ----------- ----------- -------X--- --------XX- -------XX-- -----------

Any more than the first few generations, or printing larger areas, is completely impractical because of how long compilation takes.