I've been reading Nils Holm's An Introduction to Array Programming in Klong and more generally about array programming in APL and K. While it is a bit mind-bending it is also very fun. As an exercise, I've decided to implement Conway's Game of Life.
Klong is, briefly, an array programming language in the vein of K or J. The language is terse in the extreme, but is generally very small. The book itself is a brisk tour of the language and a fun read.
board::[5 7]
[5 7]
tile::[3 3]
[3 3]
&*tile^2
[0 0 0 0 0 0 0 0 0]
Here I'm expanding (&
) the square (^2
) of the head of the tile
(*tile
). My one concession is the idea that tiles will be square,
otherwise I'd want rotate to get a width value to multiply instead of
squaring.
popindex::[1 2 3 4 7]
[1 2 3 4 7]
(&*tile^2):=1,popindex
[0 1 1 1 1 0 0 1 0]
I then amend (:=
) the empty (and still-flat) "tile" with the
population indexes. Obviously it isn't the shape of the tile though,
it is supposed to be 3x3. So I need to reshape it with :^
:
tile:^(&*tile^2):=1,popindex
0 1 1
1 1 0
0 1 0
I think a good name for this function that takes a population index and returns a matrix might be "cell":
cell::{tile:^(&*tile^2):=1,x}
:monad
cell(popindex)
0 1 1
1 1 0
0 1 0
This next part has proved troublesome to me, as a total novice. I have tried to pad out the cell with zeroes up to the given board size, 5x7. Perhaps naively, I have made a dyad that accepts an array and a padding size:
pad::{x,(#x)_(&y)}
The function will concatenate (,
) onto some x the result of dropping
(_
) the length of x (#x
) from a sequence of y-number of zeroes
(&y
). As an example:
pad([1 1 1]; 5)
[1 1 1 0 0]
pad([1 1 1]; 7)
[1 1 1 0 0 0 0]
This allows for the variable width and height, but at the expense of complicating the invocation. Because I don't think you can apply a dyad (function of two arguments) with an "each" operation over a matrix in the way you would over an array of rank-1, I instead create a monad (using the Klong terminology here, it is just a partial application).
What this looks like in practice:
pad(; 7)
:monad
pad(; 7)([1 1 1])
[1 1 1 0 0 0 0]
I mention the expense of complicating the invocation because this next bit is a doozy:
+pad(;*board)'+(pad(;*1:+board)'cell(popindex))
0 1 1 0 0 0 0
1 1 0 0 0 0 0
0 1 0 0 0 0 0
0 0 0 0 0 0 0
0 0 0 0 0 0 0
Working right to left:
cell(popindex)
*
)
of the rotated board (1:+board
). The rotation is used to take the
column portion of the row-column array'
) row I apply the partially applied pad function,
pad(;*1:+board)'
. At this point the columns have been extended to
7 elements+
)pad(;*board)
Now, to center the cell onto the board, I'm first going to define a
temporary padded
to cut down some of the, uh, noise:
(I've colorized the 1's here to make the changes made more obvious at each step)
padded::+pad(;*board)'+(pad(;*1:+board)'cell(popindex)) 0 1 1 0 0 0 0 1 1 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
A rotation (:+
) is just the thing to shift the entire matrix down. I
added 2 rows, so I want to shift 1 down to center vertically:
1:+padded 0 0 0 0 0 0 0 0 1 1 0 0 0 0 1 1 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0
In order to shift right a rotation is also just the thing, but because the matrix is a row-major vector of vectors, I first have to transpose the matrix so the rotation is in the right "direction".
+1:+padded 0 0 1 0 0 0 1 1 1 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2:++1:+padded 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 1 1 1 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0
I'll rotate (:+
) by 2 and transpose (+
) again to get back to the
original layout (5x7):
+2:++1:+padded 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 1 1 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0
In order to drive the rotation amounts directly from the available
information rather than hard-coding it; I take the difference of the
board and tile arrays and divide by 2 (floor division :%
):
(board-tile):%2
[1 2]
Wrapping this in a function that accepts the initial state:
center::{[rc]; rc::(board-tile):%2; +(*|rc):++(*rc):+x} :monad center(padded) 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 1 1 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0
With a center function defined, I think I've reach a good starting place for the neighbor search and generation steps. With that, I'll define the population index derived state as "start" and include the centering:
start::{center(+pad(;*board)'+(pad(;*1:+board)'cell(x)))} :monad start(popindex) 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 1 1 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0
The rules for Conway's Game of Life say that a point is alive in the next generation if it has 3 neighors or 4 neighbors and is currently alive. My first naive attempt was to construct two distinct rotations, left-right and up-down:
{+x:++start(popindex)}'[-1 0 1] 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 1 1 0 0 0 0 0 0 1 1 0 0 1 1 0 0 0 0 0 0 1 1 0 0 0 0 0 0 1 1 0 0 0 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 {x:+start(popindex)}'[-1 0 1] 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 1 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0
The problem with this is that it doesn't account for diagonal neighbors:
0 0 0 1 1 0 0 0 0 1 1 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 ↑ ┌───────────────┐ 0 0 0 0 0 0 0 │ 0 0 0 0 0 0 0 │ 0 0 0 0 0 0 0 0 0 1 1 0 0 0 │ 0 0 0 1 1 0 0 │ 0 0 0 0 1 1 0 0 1 1 0 0 0 0 ←│ 0 0 1 1 0 0 0 │→ 0 0 0 1 1 0 0 0 0 1 0 0 0 0 │ 0 0 0 1 0 0 0 │ 0 0 0 0 1 0 0 0 0 0 0 0 0 0 │ 0 0 0 0 0 0 0 │ 0 0 0 0 0 0 0 └───────────────┘ ↓ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 1 1 0 0 0 0 0 0 1 0 0 0
The solution I managed to come up with is probably not ideal, and it
is certainly a bit gnarly. Recognizing that up-down and left-right
need to vary independently to cover the diagonal cases, I made a triad
to apply each of the two functions; the first argument is applied to
the up-down portion, the second to the left-right, the third is the
matrix to shift. With this "move" function of three argument, I
partially apply it twice over the array [-1 0 1]
to build out 9
distinct functions of one argument, to which a "start" matrix is
supplied.
move::{[a b]; a::x; b::y; {a:+x}({+b:++x}(z))} :triad search::{[a]; a::x; ,/{[f]; f::x; move(f;;a)'[-1 0 1]}'[-1 0 1]} :monad search(start(popindex)) 0 0 1 1 0 0 0 0 0 0 1 1 0 0 0 0 0 0 1 1 0 0 1 1 0 0 0 0 0 0 1 1 0 0 0 0 0 0 1 1 0 0 0 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 1 1 0 0 0 0 0 0 1 1 0 0 1 1 0 0 0 0 0 0 1 1 0 0 0 0 0 0 1 1 0 0 0 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 1 1 0 0 0 0 0 0 1 1 0 0 1 1 0 0 0 0 0 0 1 1 0 0 0 0 0 0 1 1 0 0 0 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 1 0 0
The actual shifts/moves aren't important, instead representing
intermediate states of the calculation. I'm looking for the count of
neighboring points. Thankfully, this part is simple, it is a sum of
the 9 separate matrices. Here is a neat application of array
programming, it is using the "add" (+
) and "reduce" (/
) across 9
matrices. I'll update the "search" function to return the sum, and can
rename this summing function "neighbors":
+/(search(start(popindex)))
0 0 1 2 2 1 0
0 1 3 4 3 1 0
0 1 4 5 4 1 0
0 1 3 3 2 0 0
0 0 1 1 1 0 0
neighbors::{[a]; a::x; +/,/{[f]; f::x; move(f;;a)'[-1 0 1]}'[-1 0 1]}
:monad
neighbors(start(popindex))
0 0 1 2 2 1 0
0 1 3 4 3 1 0
0 1 4 5 4 1 0
0 1 3 3 2 0 0
0 0 1 1 1 0 0
I was concerned at how the "liveness" check was going to work when I first started on this problem. I was having difficulty imagining the check for "neighbors equal to 4 if point was alive in the last generation" without using loops. It turns out encoding the rules has been the easiest part of the whole thing:
current::start(popindex) 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 1 1 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 neighbors(current)=3 0 0 0 0 0 0 0 0 0 1 0 1 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 (neighbors(current)=4)¤t 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 nextgen::{(neighbors(x)=3)|((neighbors(x)=4);x)} :monad
The nextgen
function takes a single argument for the current state
(a matrix), and then checks if the number of neighbors is equal to 3
(=3
) or (|
) if the number of neighbors is equal to 4 (=4
)
and (&
) the current state is occupied. Below are 3 subsequent
generations of the "current" matrix (starting population index):
current 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 1 1 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 nextgen(current) 0 0 0 0 0 0 0 0 0 1 1 1 0 0 0 0 1 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 nextgen(nextgen(current)) 0 0 0 1 0 0 0 0 0 1 1 0 0 0 0 1 0 0 1 0 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 nextgen(nextgen(nextgen(current))) 0 0 1 1 0 0 0 0 0 1 1 1 0 0 0 1 0 0 1 0 0 0 0 1 1 0 0 0 0 0 1 1 0 0 0
As a small quality of life issue, the repeated calls to nextgen
are
a little annoying. The easiest fix I came up with was to create a
small recursive function that takes a generation number and a starting
state:
generation::{:[0=x; y; generation(x-1; nextgen(y))]}
Unfortunately (half-joking), this is the first instance of an if
(:[
) in the entire program. It tests whether the generation number
is 0 (0=x
), and if so, returns the current generation, otherwise it
recurses with x-1
and the next generation.
Demonstrated below, I invoke generation
with the "current" matrix,
applied over each ('
) of the array created by !5
, which is [0 1 2 3 4]
.
generation(;current)'!5 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 1 0 0 0 0 1 0 0 0 0 0 0 0 0 1 1 0 0 0 0 1 1 1 0 0 0 0 1 1 0 0 0 0 0 1 1 1 0 0 0 1 0 0 1 0 0 0 0 1 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 1 0 0 0 1 0 0 1 0 0 0 1 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 1 0 0 0 0 0 1 1 0 0 0 0 0 1 1 0 0 0 0 1 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 1 0 0 1 0 0
Having spent only a few days reading about array programming it is all very alien. The interactive nature of everything is a fun change that feels like it allows quick changes across stages of the problem. Thinking in arrays is difficult and I think I am still carrying around some imperative programming baggage. Surely there are idioms to better handle the kind of unpacking I ended up doing in several places with "head of array", "head of array-drop-one". I can imagine using an array language for something like image manipulation or digital signal processing.
This was a lot of writing for a 10 line program!
board::[5 7]
tile::[3 3]
cell::{tile:^(&*tile^2):=1,x}
pad::{x,(#x)_(&y)}
center::{[rc]; rc::(board-tile):%2;
+(*|rc):++(*rc):+x}
start::{center(+pad(;*board)'+(pad(;*1:+board)'cell(x)))}
move::{[a b]; a::x; b::y; {a:+x}({+b:++x}(z))}
neighbors::{[a]; a::x; +/,/{[b]; b::x; move(b;;a)'[-1 0 1]}'[-1 0 1]}
generation::{:[0=x; y; generation(x-1; {(neighbors(x)=3)|((neighbors(x)=4)&x)}(y))]}