Small Improvement
Let’s review yesterday’s conversion of the Game of Life from an object-oriented design to simple functions. I have at least one idea already.
We have about four hours invested in the Game of Life now, two to write it and write it up, and two more yesterday to convert from a Grid class to simple functions. Let’s see what we have here.
Here’s my “definitive” test to see that the game works:
def test_stop_light(self):
grid = Grid()
add(5, 4, grid)
add(5, 5, grid)
add(5, 6, grid)
grid = evolve(grid)
assert len(grid) == 3
print(as_string(grid))
assert (4,5) in grid
assert (5,5) in grid
assert (6,5) in grid
OK, thing one, this should be called test_blinker because that’s what they call the set of three cells that cycle from three vertical to three horizontal, back and forth, like this:
.*.
.*.
.*.
...
***
...
Renamed, tested, commit. A good start, a commit already. Ship it!
Now the thing that I’d like to do, I probably can’t. I’d like to be able to say, instead of evolve(grid), grid.evolve(). I just prefer that notation. Unfortunately, the object grid is now a Python set, and does not understand evolve. So unless we revert to having the grid be a class, I guess we can’t do that.
Possibly this will be the reason why I wind up preferring the class form of the game to the function form, if in fact that is where I come down. Ah well. Let’s just scan the code and see what we can see. Here’s everything about grid:
def Grid() -> set[tuple[int, int]]:
return set()
def add(x: int, y: int, live_cells) -> None:
live_cells.add((x, y))
def evolve(live_cells):
neighbor_counts = count_neighbors(live_cells)
stay_alive = get_staying(live_cells, neighbor_counts)
born = get_born(live_cells, neighbor_counts)
return stay_alive | born
def count_neighbors(live_cells):
neighbors = [(-1,-1), (-1,0), (-1,1), (0,-1), (0,1), (1,-1), (1,0), (1,1)]
neighbor_counts = collections.defaultdict(int)
for x, y in live_cells:
for dx, dy in neighbors:
neighbor_counts[(dx + x, dy + y)] += 1
return neighbor_counts
def get_staying(live_cells, neighbor_counts):
twos_and_threes = {cell for cell, num in neighbor_counts.items()
if num in {2, 3}}
return twos_and_threes & live_cells
def get_born(live_cells, neighbor_counts):
threes = {cell for cell, num in neighbor_counts.items() if num == 3}
return threes - live_cells
def as_string(grid, x0=0, y0=0, x1=10, y1=10):
alive = '*'
dead = '.'
lines = ['\ngrid']
for y in range(y0, y1):
row = [alive if (x,y) in grid else dead for x in range(x0, x1)]
lines.append(''.join(row))
return '\n'.join(lines)
I am trying to develop the habit of declaring the types of functions and parameters, so let’s go through and do that. I’ve not used the new type notation, and this is a good time to try it, like this:
type LifeGrid = set[tuple[int, int]]
def Grid() -> LifeGrid:
return set()
def add(x: int, y: int, live_cells: LifeGrid) -> None:
...
def evolve(live_cells: LifeGrid) -> LifeGrid:
...
def count_neighbors(live_cells: LifeGrid):
...
def get_staying(live_cells: LifeGrid, neighbor_counts):
...
def get_born(live_cells: LifeGrid, neighbor_counts):
...
def as_string(grid: LifeGrid, x0=0, y0=0, x1=10, y1=10):
...
Now it happens that neighbor_counts is a default dictionary. I’m not sure how to type hint that. We could just call it a dict[tuple[int, int]->int] or something like that. I’ll ask the Internet … ah. We can do this:
type Coord = tuple[int, int]
type LifeGrid = set[Coord]
type NeighborDict = collections.defaultdict[Coord, int]
Coord is for convenience, and to encapsulate the notion that we use a two-integer tuple as a critical, dare I say “key” element in our design, pun intended.
Here’s the whole program again, with type hints.
type Coord = tuple[int, int]
type LifeGrid = set[Coord]
type NeighborDict = collections.defaultdict[Coord, int]
def Grid() -> LifeGrid:
return set()
def add(x: int, y: int, live_cells: LifeGrid) -> None:
live_cells.add((x, y))
def evolve(live_cells: LifeGrid) -> LifeGrid:
neighbor_counts: NeighborDict = count_neighbors(live_cells)
stay_alive = get_staying(live_cells, neighbor_counts)
born = get_born(live_cells, neighbor_counts)
return stay_alive | born
def count_neighbors(live_cells: LifeGrid) -> NeighborDict:
neighbors = [(-1,-1), (-1,0), (-1,1), (0,-1), (0,1), (1,-1), (1,0), (1,1)]
neighbor_counts: NeighborDict = collections.defaultdict(int)
for x, y in live_cells:
for dx, dy in neighbors:
neighbor_counts[(dx + x, dy + y)] += 1
return neighbor_counts
def get_staying(live_cells: LifeGrid, neighbor_counts: NeighborDict):
twos_and_threes = {cell for cell, num in neighbor_counts.items()
if num in {2, 3}}
return twos_and_threes & live_cells
def get_born(live_cells: LifeGrid, neighbor_counts: NeighborDict):
threes = {cell for cell, num in neighbor_counts.items() if num == 3}
return threes - live_cells
def as_string(grid: LifeGrid, x0=0, y0=0, x1=10, y1=10):
alive = '*'
dead = '.'
lines = ['\ngrid']
for y in range(y0, y1):
row = [alive if (x,y) in grid else dead for x in range(x0, x1)]
lines.append(''.join(row))
return '\n'.join(lines)
I suppose we should call Coord something better. We only use it twice, so if it is long no harm done. GridCoordinates? We’ll do that.
from collections import defaultdict
type GridCoordinates = tuple[int, int]
type LifeGrid = set[GridCoordinates]
type NeighborDict = defaultdict[GridCoordinates, int]
Note that I also used from collections import defaultdict, which let me drop collections. from the type of NeighborDict.
What else? I think the variables live_cells might better be called grid, for example:
def add(x: int, y: int, grid: LifeGrid) -> None:
grid.add((x, y))
Yes.
Oh! I’ve been forgetting to commit. Test. Red!! Forgot to remove collections. here:
def count_neighbors(live_cells: LifeGrid) -> NeighborDict:
neighbors = [(-1,-1), (-1,0), (-1,1), (0,-1), (0,1), (1,-1), (1,0), (1,1)]
neighbor_counts: NeighborDict = defaultdict(int)
for x, y in live_cells:
for dx, dy in neighbors:
neighbor_counts[(dx + x, dy + y)] += 1
return neighbor_counts
Green. Commit: type hinting, renaming.
I rename all the live_cells to grid. Green, commit.
But that included renaming live_cells to grid in these two functions:
def get_staying(grid: LifeGrid, neighbor_counts: NeighborDict):
twos_and_threes = {cell for cell, num in neighbor_counts.items()
if num in {2, 3}}
return twos_and_threes & grid
def get_born(grid: LifeGrid, neighbor_counts: NeighborDict):
threes = {cell for cell, num in neighbor_counts.items() if num == 3}
return threes - grid
These functions were tricky to understand even before the rename. Let’s try different naming for these:
def get_staying(was_already_alive: LifeGrid, neighbor_counts: NeighborDict):
twos_and_threes = {cell for cell, num in neighbor_counts.items()
if num in {2, 3}}
return twos_and_threes & was_already_alive
def get_born(was_already_alive: LifeGrid, neighbor_counts: NeighborDict):
threes = {cell for cell, num in neighbor_counts.items() if num == 3}
return threes - was_already_alive
I think that does the trick, even better than when it was just live_cells. Green, commit.
Let’s sum up.
Summary
Well. We’ve gone hog-wild with type hinting. And I should mention that it is actually helpful. Here are two screen shots showing squiggles and diagnostic popups when I don’t provide the right types in a function call:


So, you can take or leave type hinting, but my guess is that, for me it is a good idea to use it at east on method parameters and return types. I’ll continue to try to develop the habit and see what happens, but I think it’ll find enough issues to be worth the immediate trouble and I’m sure it’ll be helpful when I review the code later.
I’m pleased with the names in get_staying and get_born, which help the functions read more clearly.
My current leaning is that I mildly prefer the object-oriented version of the code. It seems to me to do a better job of encapsulating the ideas of the Game of Life, and it’s more compatible with my most recent few decades of work, which has been entirely OO. If I’d been doing functional all this time, I might go another way … but I’d also perhaps not be using Python. Scheme or something even more esoteric, perhaps.
So, improved code, and I think I prefer it the other way. Good to know, worth a few hours of experimentation. Maybe we’ll refactor back to objects next time, just for fun.
See you next time!