Of the two approaches I’ve tried, I have a preference. Let’s discuss that and align the code with my wishes. Takes two tries. So it goes.

In this little Game of Life exercise, we can see many of the same issues that will appear in essentially every program we create, be they small or large. Of course, with small ones, we can be more casual, since it’s usually easy enough to figure out what’s going on when there isn’t much code. But I find it useful to treat my small programs with the same care I would a large one. It’s good practice, in case I ever write a really large program again; it keeps my brain lubricated; it gives me something to write about; it’s a good feeling to perform something well, even if the thing itself is no big deal.

Over the past three sessions, we’ve tried two basic design approaches, one where the game is a class of our own creation, and one where it is a simple Python set manipulated by free-standing methods. And I have a preferenceL I prefer the object-oriented form. Let’s see if I can explain why, not to try to convince you of anything, just to find out what I think.

Encapsulation
With the Grid as a Python set, the object is open to anything the programmer might do to it. All the methods and functions that process a set could be applied to it. True, we have our few functions in mind, add, evolve, and as_string, but at a whim, the programmer might perform any set operation on the thing, with who knows what results. Now, I can probably trust myself not to do that, or at least to do it wisely, but I have been known to be untrustworthy, and in a real situation … it’s not that I don’t trust my team, it’s just that it seems like good practice to keep things more safe than that.
Packaging
I don’t do much real Python packaging: my programs are small, I don’t have a team to work with, and I don’t get a sense of much payoff from it. I do generally keep each class in its own file, but that’s as far as I generally go. But with these top-level functions, something more needs to be done, it seems to me. There are only the three methods that you’re “supposed” to use, but there are seven functions that make up the game logic, and, encapsulation again, I don’t like having those just hanging out there. I’m sure there’s some packaging thing that would make it better, maybe just from grid import add sort of thing, but I don’t like it.

Now you may have a different set of values, and you surely have a different set of problems than I have, but I prefer the class-object style. And, because I just might do something about making a better display for the game, my plan is to convert back to the object form.

There are two ways we could go.

Actually at least three
Some folks would argue that we could subclass, or even monkey patch set to add our methods to it. I was taught—and have subsequently learned—not to do that. Why? Again, encapsulation. If we subclass that system class, we again leave the entire method space of the set available. Same argument. We just don’t do that.

Anyway …

We could just belay all this stuff and pull back the last OO version. Alternatively, we could move forward from here, refactoring this code back to object form. If I were in a hurry, and given that we haven’t changed much else, pulling back the old version would certainly be faster: some random Git manipulation to convince it that we can commit that old version and there we go.

But I’m going to do it by moving forward, to see whether it can be done in lots of small tests with minimal breakage along the way. So here’s the problem that I set for myself:

Refactor the current function-based Game of Life code to an object-oriented form with one or more classes as needed, with all the functionality suitably encapsulated. Do so in small steps, ideally breaking no tests.

I say “ideally”. I suspect that I’ll make mistakes along the way, and I want the tests to find those problems. But if possible, I want to keep all the tests running without changes until the very end, when we’ll adjust them line by line.

For that to happen, we’ll need to keep the function-based approach working and slowly move capability inside the class, probably leaving the existing functions in place, forwarding to the new object. We’ll find out whether this is possible, and if so, just what it takes, as we work.

Let’s get started.

The good news is that we only have a few tests, because Life is so simple. Here’s the definitive check as an example:

    def test_blinker(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

Now we see immediately that the OO form of this test would probably be different. Normally I wouldn’t think that we’d want len or in to work on our object. But for the sake of the example, we’ll see what we can do.

For this test to run, we need a new class whose name will be Grid. And its instances have to have add, len, evolve, and it has to support in. I think the first three are easy and I don’t know about in, but I’m sure the info is out there. Some magic method, I’m sure.

I think, however, that we’re going to need to break refine our rule about not breaking a test. Since there are a few things to do to make that one run, it might be easiest to let it break briefly and use its failures to drive out the next change.

There is a trick that we must consider. If we implement __getattr__ on our object, it’ll be called every time we do something we shouldn’t. No. Too fancy.

Let’s change Grid() to return a class and see what happens. If it looks like we can deal with it, we’ll go forward. If it doesn’t, we’ll revert and think of a better idea. If we can’t think of a better idea, we can scrap this article and pretend it never happened.

Added in post
This goes on a while and doesn’t work. Scan quickly for the sense, knowing what I didn’t know, that I was going to roll this back. Or skip.
# def Grid() -> LifeGrid:
#     return set()

class Grid:
    def __init__(self):
        self.cells = LifeGrid

Test, expecting chaos. We’ll just pick tests to fix, more or less top down. Here’s one:

    def test_grid_class(self):
        grid = Grid()
>       assert len(grid) == 0
               ^^^^^^^^^
E       TypeError: object of type 'Grid' has no len()

The OK way to do this is to implement _len__.

    def __len__(self):
        return len(self.cells)

Unfortunately, Python doesn’t like this: ‘Expected type Sized …’. I think we’ll have to belay some of our nice type definitions. OK.

class Grid:
    def __init__(self):
        self.cells = set[GridCoordinates]

    def __len__(self):
        return len(self.cells)

Now, I should mention that PyCharm has already identified a zillion lines that aren’t going to work. We could use its go to next error thing, which it probably has, to find them. But the tests will serve to do the same thing.

No! I can’t create that set that way. Should have said this:

class Grid:
    def __init__(self):
        self.cells: LifeGrid = set()

    def __len__(self):
        return len(self.cells)

Declare the type, but create the generic. Test. New error:

    def add(x: int, y: int, grid: LifeGrid) -> None:
>       grid.add((x, y))
        ^^^^^^^^
E       AttributeError: 'Grid' object has no attribute 'add'

That’s more like it. We need an add method. Implement it.

    def add(self, pair):
        self.cells.add(pair)

This moves us forward:

    def test_blinker(self):
        grid = Grid()
        add(5, 4, grid)
        add(5, 5, grid)
        add(5, 6, grid)
>       grid = evolve(grid)
               ^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
test_conway.py:61: in evolve
    neighbor_counts: NeighborDict = count_neighbors(grid)
                                    ^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
    def count_neighbors(grid: 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 grid:
E       TypeError: 'Grid' object is not iterable

Ideally, we’ll implement evolve on the new class now. However, I am feeling dissatisfied. I feel that we’ve have done better to change all the tests to expect objects and move from there. We won’t really want to leave all the stub forwarding methods in there anyway.

Trying to be too clever. Roll back.

“Ever tried. Ever failed. No matter. Try again. Fail again. Fail better” – Beckett

Try again

This time, I’ll just modify my critical test first, the blinker. No, in fact I’ll write a new one based on the old one, leaving the old one in for now. The new one will assume we have a class GridObject.

    def test_blinker_oo(self):
        grid = GridObject()
        grid.add(5, 4)
        grid.add(5, 5)
        grid.add(5, 6)
        grid = grid.evolve()
        assert grid.size == 3
        print(grid.as_string())
        assert grid.has((4,5))
        assert grid.has((5,5))
        assert grid.has((6,5))

This complains for lack of GridObject. Let’s fake it. I basically just typed this in based on running the test and watching what it said:

class GridObject:
    def __init__(self):
        self.cells: LifeGrid = set()

    def add(self, x, y):
        self.cells.add((x, y))

    def evolve(self):
        self.cells = evolve(self.cells)
        return self

    def has(self, pair):
        return pair in self.cells

    @property
    def size(self):
        return len(self.cells)

    def as_string(self):
        return as_string(self.cells)

The class is basically just a shell at this point: the old functions are doing all the work. But the protocol is defined and we can now convert our other tests to the object format. But first let’s rename Grid to GridFunction and GridObject to Grid. I think we’ll do another rename later but this might reduce our editing somewhat.

Done with two commands. Tests are green, let’s get to committing. This is going to work, unless it doesn’t.

Now to convert the tests:

    def test_grid_class(self):
        grid = Grid()
        assert len(grid) == 0

Let’s see about implementing len just for the convenience of this test. I wouldn’t normally do that but let’s pretend it’s done a lot.

class Grid:
    def __len__(self):
        return len(self.cells)

Test is green.

    def test_as_string(self):
        grid = Grid()
        grid.add(5, 4)
        grid.add(5, 5)
        grid.add(5, 6)
        str = grid.as_string()
        print(str)
        grid = grid.evolve()
        str = grid.as_string()
        print(str)

Green. Commit, darn it!

    def test_glider(self):
        grid = Grid()
        grid.add(1, 3)
        grid.add(2, 3)
        grid.add(3, 3)
        grid.add(3, 2)
        grid.add(2, 1)
        print(grid.as_string())
        grid = grid.evolve()
        print(grid.as_string())
        grid = grid.evolve()
        print(grid.as_string())
        grid = grid.evolve()
        print(grid.as_string())
        grid = grid.evolve()
        print(grid.as_string())

Green (and prints the right thing.) Commit.

def main():
    grid = Grid()
    grid.add(1, 3)
    grid.add(2, 3)
    grid.add(3, 3)
    grid.add(3, 2)
    grid.add(2, 1)
    view = CursesView(grid, gen=50)
    view.show()

And I need to convert my curses code, which we’ve never looked at here. Same kind of thing, with one additional thing: as_string takes optional parms:

    def as_string(self, x0=0, y0=0, x1=10, y1=10):
        return as_string(self.cells, x0, y0, x1, y1)

Test. Green. Test the Curses. Looks good.

Commit, then let’s assess.

Where are we?

We have the tests converted to assume that we have a Grid class. They are sending class messages, no longer using the free-standing functions. And, since they say grid=grid.evolve(), they are assuming that the evolve method returns a new Grid, which is not the case. Finally, the class itself includes essentially no useful functionality, just calling those functions that we’re trying to get rid of.

But the tests are all running and the code is completely capable of being committed and put into production.

Because the article is long, I have nearly finished my morning banana, and I am ready for a nice iced chai, we’ll wrap this article here, and next time move the functions inside the class where they belong. That should be easy enough. (Oh no, I should not have said that!)

Summary

I tried to do a clever thing, evolving toward a class while keeping all the tests running, and after a while realized that I was going around the square the wrong direction, covering three sides when one would do. So I rolled back, created an object-oriented test, and created a stub object that just calls the existing functions. That was sufficient to get all the tests green, and from then on we could push to prod any time we wanted. Now, the production code has the proper protocol in use, and we can make the protocol more object-oriented at our leisure. All the nasty is hidden under the object’s methods with no one else relying on the original functions.

The good thing about all that is that I recognized that the initial idea, while it seemed tasty, wasn’t, and we rolled back to try another idea. It would have been possible to push on. It always is. But rolling back as soon as we recognize trouble is often a very good idea. If the time invested is small, it’s probably always a good idea, even if we just type in the same code again. Odds are, we’ll do it better even then.

A false start, no big deal. So it goes, and next time goes better. See you next time!