Endless Forms
… not necessarily most beautiful, but there seems always to be something to talk about even in simple programs like the Game of Life. Today we remove the façade.1
The way that I do my work does feel a bit like the code is evolving from one form to another. When I’m at my best—every so rarely—there are tiny changes occurring one after another. The program stays alive, while morphing from one shape to another. That feels good to me, and there’s at least one reason why it should:
Proceeding from green to green in small steps means that the program is always ready to be shipped, containing every capability built so far.
In my life, I believe that having the best available version always ready to be handed to someone who wanted it would have made a big difference. Projects would not have been cancelled, customers would have been more happy, and so on. Of course, I would not rewrite the past to make that happen, because where I am right now is pretty OK and you never know what would have happened. Quite possibly I’d never have become engaged in the “Agile” movement. Or, possibly, I would have created it years sooner. One never knows, do one?
Anyway, I’m not allowed to go back on my own timeline, so let’s go forward with the Game of Life.
The Story So Far
Yesterday, we converted all our tests back to object-oriented, away from using the plain functions we had done the day before. But the actual class involved is just a façade, calling the already-existing functions. All the real work is done by these two methods, and you can see that they just call the same old functions.
class Grid:
def evolve(self):
self.cells = evolve(self.cells)
return self
def as_string(self, x0=0, y0=0, x1=10, y1=10):
return as_string(self.cells, x0, y0, x1, y1)
Now, this is a perfectly valid situation, a perfectly good approach to providing an OO-style interface to a library of functions. Quite often, when faced with some library of functions, providing an OO interface makes using the library much easier, because we can limit the interface to the parts of the library that we want to use, limiting what we have to think about in our daily work.
And, of course, as we’ll see today, the façade is also a good small step toward having everything really inside classes, if we like to work that way. And, here at my house, we do. So today, we’ll begin moving the code all the way to being in methods, not top-level functions.
There are things that concern me. I’ll say a bit more as we look at the code, but I have the feeling that we’re missing an object or idea. There’s just a sense that things don’t quite fit together right. Possibly that feeling will go away as we put things together, but we’ll see.
as_string
We’ll start with as_string. Now a Python Person might have implemented __str__ to print the grid, but I don’t feel right about that. I could be wrong. But for now we just need to bring this inside:
class Grid:
def as_string(self, x0=0, y0=0, x1=10, y1=10):
return as_string(self.cells, x0, y0, x1, y1)
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)
This one seems easy enough: we just move the code up and change grid to self.cells. We do not, however, have a test for printing: I’ve been just looking to see if it looked right. Let’s do the right thing and create a simple test. We have most of what we need already:
def test_as_string(self):
grid = Grid()
grid.add(5, 4)
grid.add(5, 5)
grid.add(5, 6)
str = grid.as_string(0,0,20,20)
print(str)
grid = grid.evolve()
str = grid.as_string(0,0,20,20)
print(str)
It’s just that I printed the result and looked at it. Also, as I read this, I think there’s a bug! This is what happens when you don’t test. The test above should produce twenty lines twenty cells wide, 0,0 to 20,20. I think it doesn’t. So we’ll see when we test. This isn’t pretty:
def test_as_string(self):
result1 = \
'''....................
....................
....................
....................
.....*..............
.....*..............
.....*..............
....................
....................
....................
....................
....................
....................
....................
....................
....................
....................
....................
....................
....................'''
grid = Grid()
grid.add(5, 4)
grid.add(5, 5)
grid.add(5, 6)
str = grid.as_string(0,0,20,20)
assert str == result1
grid = grid.evolve()
str = grid.as_string(0,0,20,20)
print(str)
But I think it’s what’s supposed to happen, or close to it. Test. Green, except for a debug printing of “grid” that I had in there for display reasons, and that we really don’t want. I was mistaken about the problem I expected, because another print had caused me to question whether the width and height were going to be correct. Let’s scale this down just for ease of reading.
def test_as_string(self):
result1 = \
'''..........
..........
..........
..........
.....*....
.....*....
.....*....
..........
..........
..........'''
result2 = \
'''.......
.......
.......
...***.
.......
.......'''
grid = Grid()
grid.add(5, 4)
grid.add(5, 5)
grid.add(5, 6)
str = grid.as_string() # checks defaults
assert str == result1
grid = grid.evolve()
str = grid.as_string(1,2,8,8)
assert str == result2
I changed the first check not to include the parameters, which checks the defaults (0,10), then used different values in the second check, to ensure that values provided are honored. Green. Commit: new test for as_string.
Well!
I hadn’t expected to need to do that. But it was the right thing to do. When we see that something isn’t tested, we’d better test it … especially if we plan to change it. Which we do. Move code inside the class now:
class Grid:
def as_string(self, x0=0, y0=0, x1=10, y1=10):
alive = '*'
dead = '.'
lines = []
for y in range(y0, y1):
row = [alive if (x,y) in self.cells else dead for x in range(x0, x1)]
lines.append(''.join(row))
return '\n'.join(lines)
I did that by hand. I wonder if PyCharm could have done it for me. Roll back to find out. It cannot. In fact it does something wrong. I’ll have to send them a bug report, after I make sure I’m on the current version. Do again. Tests are OK.
There remain references to as_string, the function. Find them and fix. Ah, it was a test for the old function, replaced by our new test. We’re green. Commit: move as_string function into class.
evolve
Now it’s time to move evolve into the class. That looks like this just now:
class Grid:
def evolve(self):
self.cells = evolve(self.cells)
return self
def evolve(grid: LifeGrid) -> LifeGrid:
neighbor_counts: NeighborDict = count_neighbors(grid)
stay_alive = get_staying(grid, neighbor_counts)
born = get_born(grid, neighbor_counts)
return stay_alive | born
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:
for dx, dy in neighbors:
neighbor_counts[(dx + x, dy + y)] += 1
return neighbor_counts
def get_staying(was_already_alive: LifeGrid, neighbor_counts: NeighborDict):
twos_and_threes: LifeGrid = {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: LifeGrid = {cell for cell, num in neighbor_counts.items() if num == 3}
return threes - was_already_alive
We can surely do this in four steps. But here we begin to see what troubles me. There’s something fishy about those methods. I can’t put my finger on it but it’s something about the past and the future. We’ll just go ahead: I have no doubt that we can make it just work.
One method at a time, first creating a new working method to preserve the shape of the current evolve, which I intend to use to make Grid immutable at some future time. First extract method:
def evolve(self):
self.cells = self.next_generation()
return self
def next_generation(self):
return evolve(self.cells)
Test. Commit: refactoring to bring evolve inside class.
Yes, I really did just commit that tiny change. Why not? The code runs and is better.
NOw bring evolve into the next_generation method:
def evolve(self):
self.cells = self.next_generation()
return self
def next_generation(self):
grid = self.cells
neighbor_counts: NeighborDict = count_neighbors(grid)
stay_alive = get_staying(grid, neighbor_counts)
born = get_born(grid, neighbor_counts)
return stay_alive | born
Green. Commit. Bring up another method.
def next_generation(self):
grid = self.cells
neighbor_counts: NeighborDict = self.count_neighbors()
stay_alive = get_staying(grid, neighbor_counts)
born = get_born(grid, neighbor_counts)
return stay_alive | born
def count_neighbors(self) -> 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 self.cells:
for dx, dy in neighbors:
neighbor_counts[(dx + x, dy + y)] += 1
return neighbor_counts
That took me a few tries. Green, commit. Now think why.
Mainly, because I had to change the function to say self.cells instead of grid, and also had to change the call in next_generation to refer to the new method. Let’s do that in a better order for the next one:
From this:
def next_generation(self):
grid = self.cells
neighbor_counts: NeighborDict = self.count_neighbors()
stay_alive = get_staying(grid, neighbor_counts)
born = get_born(grid, neighbor_counts)
return stay_alive | born
Extract method:
def next_generation(self):
grid = self.cells
neighbor_counts: NeighborDict = self.count_neighbors()
stay_alive = self.get_staying(grid, neighbor_counts)
born = get_born(grid, neighbor_counts)
return stay_alive | born
def get_staying(self, grid, neighbor_counts):
stay_alive = get_staying(grid, neighbor_counts)
return stay_alive
Green, commit. Move code up.
def get_staying(self, grid, neighbor_counts):
was_already_alive = self.cells
twos_and_threes: LifeGrid = {cell for cell, num in neighbor_counts.items()
if num in {2, 3}}
return twos_and_threes & was_already_alive
That works. Commit. Do again same way. Extract:
def next_generation(self):
grid = self.cells
neighbor_counts: NeighborDict = self.count_neighbors()
stay_alive = self.get_staying(grid, neighbor_counts)
born = self.get_born(grid, neighbor_counts)
return stay_alive | born
def get_born(self, grid, neighbor_counts):
born = get_born(grid, neighbor_counts)
return born
Green, commit. I try inline and it seems to work:
def get_born(self, grid, neighbor_counts):
threes: LifeGrid = {cell for cell, num in neighbor_counts.items() if num == 3}
born = threes - grid
return born
We’re green, and we’ll commit, but I am not as happy as I might be.
In my happy-go-lucky approach this morning, I haven’t been paying enough attention to those calling sequences, which are including grid and, in my view, should not. Fix the new one.
def next_generation(self):
grid = self.cells
neighbor_counts: NeighborDict = self.count_neighbors()
stay_alive = self.get_staying(grid, neighbor_counts)
born = self.get_born(neighbor_counts)
return stay_alive | born
def get_born(self, neighbor_counts):
was_already_alive = self.cells
threes: LifeGrid = {cell for cell, num in neighbor_counts.items() if num == 3}
born = threes - was_already_alive
return born
Hm, that’s resolving some of my general discomfort. We’ll talk about that in a moment. Fix the other one. Change Signature will do it.
def next_generation(self):
grid = self.cells
neighbor_counts: NeighborDict = self.count_neighbors()
stay_alive = self.get_staying(neighbor_counts)
born = self.get_born(neighbor_counts)
return stay_alive | born
def get_staying(self, neighbor_counts):
was_already_alive = self.cells
twos_and_threes: LifeGrid = {cell for cell, num in neighbor_counts.items()
if num in {2, 3}}
return twos_and_threes & was_already_alive
Green, commit: All functionality inside class Grid.
Now there is some debris and detritus to be deleted:
def GridFunction() -> LifeGrid:
return set()
def add(x: int, y: int, grid: LifeGrid) -> None:
grid.add((x, y))
def as_string(grid: LifeGrid, x0=0, y0=0, x1=10, y1=10):
alive = '*'
dead = '.'
lines = []
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)
Gone. Green. Commit: tidying.
I want some type hints back on those methods. Stand back, here’s the whole thing:
class Grid:
def __init__(self) :
self.cells: LifeGrid = set()
def __len__(self):
return len(self.cells)
def add(self, x, y) -> None:
self.cells.add((x, y))
def evolve(self) -> Self:
self.cells = self.next_generation()
return self
def next_generation(self) -> LifeGrid:
neighbor_counts: NeighborDict = self.count_neighbors()
stay_alive = self.get_staying(neighbor_counts)
born = self.get_born(neighbor_counts)
return stay_alive | born
def get_born(self, neighbor_counts) -> LifeGrid:
was_already_alive = self.cells
threes: LifeGrid = {cell for cell, num in neighbor_counts.items() if num == 3}
born = threes - was_already_alive
return born
def get_staying(self, neighbor_counts) -> LifeGrid:
was_already_alive = self.cells
twos_and_threes: LifeGrid = {cell for cell, num in neighbor_counts.items()
if num in {2, 3}}
return twos_and_threes & was_already_alive
def count_neighbors(self) -> 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 self.cells:
for dx, dy in neighbors:
neighbor_counts[(dx + x, dy + y)] += 1
return neighbor_counts
def has(self, pair) -> bool:
return pair in self.cells
@property
def size(self) -> int:
return len(self.cells)
def as_string(self, x0=0, y0=0, x1=10, y1=10) -> str:
alive = '*'
dead = '.'
lines = []
for y in range(y0, y1):
row = [alive if (x,y) in self.cells else dead for x in range(x0, x1)]
lines.append(''.join(row))
return '\n'.join(lines)
Just one more thing. I’d like the evolve method to return a new instance, so that Grid is closer to immutable. Should be easy enough:
First this:
class Grid:
def __init__(self, cells=None) :
self.cells: LifeGrid = cells if cells else set()
Test. Green. Commit: working toward immutable.
From this:
def evolve(self) -> Self:
self.cells = self.next_generation()
return self
To this:
def evolve(self) -> Self:
cells = self.next_generation()
return self.__class__(cells)
We need a test for that. Sorry.
def test_new_instance(self):
grid = Grid()
new_grid = grid.evolve()
assert new_grid is not grid
Now, of course our class isn’t immutable yet: we still have the add method, which is certainly changing the instance. Perhaps we should have a factory method that handles this sort of thing, or we could require everyone to give us a set of tuples on creation. But at least our new generations don’t rewrite the cells, and I feel better about that.
Another long article, so let’s sum up.
Summary
My feeling that things were squidgy has abated. It had something to do with the get methods not referring to the member variables but in fact they do. None of the methods want to be static, so I think we’re good.
We’ve added two new tests, one of them substantive, the printing one. We’ve got all the functionality back under the class covers. We’ve made a solid step toward the class being immutable. We have type hinting throughout. We had 13 commits in two hours, an average interval of less than 10 seconds, although the maximum was probably about 20 minutes, still within my “not in trouble yet” limit.
Is it perfect? No, my guess is that it isn’t. We might have another review, but I expect we’ll just find little wrinkles, names we might prefer and such.
I like the object format better: it keeps things that belong together together. YMMV, and feel free to hit me up on mastodon or something if you want to comment.
See you next time!
-
“endless forms most beautiful and most wonderful have been, and are being, evolved.” – Charles Darwin, The Origin of Species. ↩