The Game of Life code is quite simple. What if we didn’t use classes, just vanilla functions?

My programming experience over the past three decades (OMG) has been almost all object-oriented. The grooves in my brain lead me to reach immediately for the class creation capability in any language I use that supports classes. But the Game of Life code is quite simple. There’s a class for the game, and a class for using Curses to display the game. I haven’t showed you the Curses code, if I’m not mistaken. Depending on how things go here, maybe we’ll look at it today. But my main notion this morning is to refactor the game code so that it no longer uses a class, just naked functions. We’ll see if we like it better that way.

TL;DR

Very small steps, 15 commits, two hours, no trouble, goes rather nicely. Skip to summary.

The Details …

First thing will be to peel out the supporting tests and code that I wrote at the beginning of the game effort, to get rolling, to try to remember Python, and to see how the Python structures behaved.

This method is only called from tests:

class Grid:
    def number_of_neighbors(self, x: int, y: int) -> int:
        count = 0
        for n_x, n_y in self._neighbors:
            if (x+n_x, y+n_y) in self.live_cells:
                count += 1
        return count

I probably wrote it to practice handling the indexing into the live_cells using the neighbors list. Remove the method and its tests. THat also lets me remove this method:

class Grid:
    def remove(self, x: int, y: int) -> None:
        self.live_cells.remove((x, y))

Tests all pass. Commit.

This method is only called from the test shown:

class Grid:
    def neighbor_cells(self, x, y):
        return [(x+nx, y+ny) for nx, ny in self._neighbors]


    def test_grid_neighbor_cells(self):
        grid = Grid()
        neighbors = [(-1,-1), (-1,0), (-1,1), (0,-1), (0,1), (1,-1), (1,0), (1,1)]
        cells = grid.neighbor_cells(10,20)
        for nx, ny in neighbors:
            assert((10+nx, 20+ny)) in cells

Remove those, test, commit. We’re left with the Grid’s as_string method, and the actual evolution code. It looks like this:

class Grid:
    def __init__(self):
        self._neighbors = [(-1,-1), (-1,0), (-1,1), (0,-1), (0,1), (1,-1), (1,0), (1,1)]
        self.live_cells:set[tuple[int, int]] = set()

    def add(self, x: int, y: int) -> None:
        self.live_cells.add((x, y))

    def evolve(self):
        neighbor_counts = self.count_neighbors()
        stay_alive = self.get_staying(neighbor_counts)
        born = self.get_born(neighbor_counts)
        self.live_cells = stay_alive | born

    def get_born(self, neighbor_counts):
        threes = {cell for cell, num in neighbor_counts.items() if num == 3}
        return threes - self.live_cells

    def get_staying(self, neighbor_counts):
        twos_and_threes = {cell for cell, num in neighbor_counts.items() if num in {2, 3}}
        return twos_and_threes & self.live_cells

    def count_neighbors(self):
        neighbor_counts = collections.defaultdict(int)
        for x, y in self.live_cells:
            for dx, dy in self._neighbors:
                neighbor_counts[(dx + x, dy + y)] += 1
        return neighbor_counts

    def as_string(self, x0=0, y0=0, x1=10, y1=10):
        alive = '*'
        dead = '.'
        lines = ['\ngrid']
        for y in range(y0, y1):
            row = [alive if (x,y) in self.live_cells else dead for x in range(x0, x1)]
            lines.append(''.join(row))
        return '\n'.join(lines)

Now I’d really like to refactor this in small steps, although it wouldn’t be that hard to do it all in one go. Small steps let me find my inevitable mistakes sooner, because if I’ve only changed one line and things break, well, we know where the mistake must be.

Let’s begin by moving the _neighbors constant list outside the class. We need to make the constant, and we need to change all the references to self._neighbors to refer to the constant. Easily done by hand. Is there a machine-refactoring that might make it easier? I think that there might be, using a python property.

First I create the list, with a new name, outside the class:

Neighbors = [(-1,-1), (-1,0), (-1,1), (0,-1), (0,1), (1,-1), (1,0), (1,1)]

class Grid:

Tests are green. Commit: make Neighbors constant list.

Then I create a property _neighbors, removing the init line that saved the list as a member.

class Grid:
    def __init__(self):
        self.live_cells:set[tuple[int, int]] = set()

    @property
    def _neighbors(self):
        return Neighbors

Tests should run. They do. Commit: make _neighbors property.

Now inline the property and remove it. [EXPLETIVE DELETED]! PyCharm can’t inline a decorated function. Foiled! I’ll have to do it manually.

[LOL] Sometimes it pays to look. There’s only one reference to _neighbors anyway.

    def count_neighbors(self):
        neighbor_counts = collections.defaultdict(int)
        for x, y in self.live_cells:
            for dx, dy in Neighbors:
                neighbor_counts[(dx + x, dy + y)] += 1
        return neighbor_counts

Green. Remove property. Commit: use Neighbors constant list.

Well, that would have been clever had it worked. Even so it went in small steps and we’re where we wanted to be. No harm done.

I wonder if PyCharm can move a member up. No such luck. OK, we’ll do this one manually. We start here:

class Grid:
    def __init__(self):
        self.live_cells:set[tuple[int, int]] = set()

We want Live_cells to be external to the class. Declare it there, leaving the init.

Neighbors = [(-1,-1), (-1,0), (-1,1), (0,-1), (0,1), (1,-1), (1,0), (1,1)]
Live_cells:set[tuple[int, int]] = set()

class Grid:
    def __init__(self):
        self.live_cells:set[tuple[int, int]] = set()

I think we’ll see about using some multi-cursor magic here. I get all the statements with self.live_cells and edit them to Live_cells. Tests fail. I am briefly confused. Turns out that Python will read a global if there is no local of the same name but if you assign to the name, it turns local. You do get a warning. Anyway we need this:

    def evolve(self):
        global Live_cells
        neighbor_counts = self.count_neighbors()
        stay_alive = self.get_staying(neighbor_counts)
        born = self.get_born(neighbor_counts)
        Live_cells = stay_alive | born

There’s a test referring to grid.live_cells. Changed to refer to the global. We’re green. Commit: Live_cells extracted from Grid. For safety, I did this in Grid: init:

class Grid:
    def __init__(self):
        global Live_cells
        Live_cells = set()

I’m getting impatient. These small steps are frustrating, especially when they don’t quite work. Let’s keep our shirt on here, and start moving functions out one at a time.

I think PyCharm wants to help. When I look at this method, for example:

class Grid:
    def count_neighbors(self):
        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

PyCharm observes that the method could be static, because it doesn’t reference self, and it offers to make it into a function. Let’s try that and see what it does.

Neighbors = [(-1,-1), (-1,0), (-1,1), (0,-1), (0,1), (1,-1), (1,0), (1,1)]
Live_cells:set[tuple[int, int]] = set()

def count_neighbors():
    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

That is exactly what I would have done! All right! Test. Green. Commit: change method to function.

Repeat for each method that PyCharm recognizes as static.

def get_staying(neighbor_counts):
    twos_and_threes = {cell for cell, num in neighbor_counts.items()
                       if num in {2, 3}}
    return twos_and_threes & Live_cells

Commit.

def get_born(neighbor_counts):
    threes = {cell for cell, num in neighbor_counts.items() if num == 3}
    return threes - Live_cells

Commit. Repeat for the rest, committing each one. Manually reorder them to this result:

Neighbors = [(-1,-1), (-1,0), (-1,1), (0,-1), (0,1), (1,-1), (1,0), (1,1)]
Live_cells:set[tuple[int, int]] = set()

def add(x: int, y: int) -> None:
    Live_cells.add((x, y))
    
def evolve():
    global Live_cells
    neighbor_counts = count_neighbors()
    stay_alive = get_staying(neighbor_counts)
    born = get_born(neighbor_counts)
    Live_cells = stay_alive | born

def count_neighbors():
    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(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(neighbor_counts):
    threes = {cell for cell, num in neighbor_counts.items() if num == 3}
    return threes - Live_cells

def as_string(x0=0, y0=0, x1=10, y1=10):
    alive = '*'
    dead = '.'
    lines = ['\ngrid']
    for y in range(y0, y1):
        row = [alive if (x,y) in Live_cells else dead for x in range(x0, x1)]
        lines.append(''.join(row))
    return '\n'.join(lines)

class Grid:
    def __init__(self):
        global Live_cells
        Live_cells = set()

Commit.

Now, we really don’t want all these functions referring to those globals, do we?

I might have thought of this sooner. I think there’s probably only one reference to Neighbors.

def count_neighbors():
    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

So there’s one global down. I’d like to pass the live cells around rather than have everyone reference them directly. Take this function for example:

def get_born(neighbor_counts):
    threes = {cell for cell, num in neighbor_counts.items() if num == 3}
    return threes - Live_cells

Let’s change signature on that to pass in Live_cells:

change signature

def get_born(live_cells, neighbor_counts):
    threes = {cell for cell, num in neighbor_counts.items() if num == 3}
    return threes - live_cells

That was not quite as smooth as it might have been: I had to change Live_cells to live_cells by hand. I think if I name the part Live_cells I can then rename it, making both changes machine-driven rather than human-driven. Next:

def get_born(live_cells, neighbor_counts):
    threes = {cell for cell, num in neighbor_counts.items() if num == 3}
    return threes - live_cells

Commit.

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

Commit. Shall I spare you the others? Here they are: we need to talk about `evolve anyway:

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)
    live_cells = stay_alive | born

Our test for evolve looks like this:

    def test_stop_light(self):
        # probably too hard
        grid = Grid()
        add(5, 4, Live_cells)
        add(5, 5, Live_cells)
        add(5, 6, Live_cells)
        evolve(Live_cells)
        assert len(Live_cells) == 3
        print(as_string())
        assert (4,5) in Live_cells
        assert (5,5) in Live_cells
        assert (6,5) in Live_cells

Let’s move to get rid of the Live_cells global altogether. We’ll modify evolve to return the new live cells rather than set them. That will break some tests.

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

Test. Fix test:

    def test_stop_light(self):
        grid = set()
        add(5, 4, grid)
        add(5, 5, grid)
        add(5, 6, grid)
        grid = evolve(grid)
        assert len(grid) == 3
        print(as_string())
        assert (4,5) in grid
        assert (5,5) in grid
        assert (6,5) in grid

Commit: evolve returns result, does not set it.

I replace the class with this:

def Grid() -> set[tuple[int, int]]:
    return set()

Let’s remove the Grid class and the Live_cells global and see what happens.

My tests all run withe Grid function, but when I remove the Live_cells global a few fail. That encourages some simple substitutions, like this:

    def test_stop_light(self):
        grid = set()
        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

I basically multi-cursor all the Live_cells to grid, and add the assignment where there’s an evolve. We are green.

Commit: Converted to pure functions.

Summary

15 commits, green on every one. Often machine-refactored. A few manual edits, but all simple substitutions. Possibly I could have done more of those by machine, though at this instant I don’t quite see how.

Two hours, including writing this immense article. The Grid class is removed, replaced by a small collection of functions, all of which accept a set[tuple[int,int]] as a parameter, with a function Grid that returns an initial empty set.

Could I have done all of this in one big push? Frankly, I doubt it, because a couple of things startled me and there were substantial bits of work done in the signature changes. So even if we had tried to take bigger bites, those essentially must be done one at a time, but there would have been many tests failing for many reasons. Almost certain to confuse md.

Was this faster? I don’t know. I’m pretty sure it wasn’t substantially slower overall, because tests never broke until the final changes removing the globals.

Was there a faster or smoother way? Possibly. One possibility would have been to change the signatures of the methods to accept a set (the member variable of the Grid class), and then move them out later. But it went smoothly as it was, so I’m not sure changing the order would have helped.

Net net net, I think this was about as fast as it might have been, and since I never really found myself saying WTF, it was unquestionably very smooth.

Do I prefer this result? I’m not sure. We’ll look at it next time and see what we think. For today, it’s what we set out to do.



The Code

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)