Sweet Little Idea
Hello, loves!
Sweet little idea … I have one. Very small but I think it’ll make things just a bit nicer. We’ll see.
I’ve been improving the code both in the last few articles and sometimes when you weren’t watching. If there has been a theme to the work, it has been to move responsibility for things to the places where they seem to fit better. Something something cohesion. I know—or used to know—all that theory, but these days I follow much simpler practices, removing duplication, observing Feature Envy and moving code to the class we’ve been fondling a bit too much, and so on.
Much of what I’ve done the past few days has added capability to Cell class. I think it’s worth mentioning how that works, since a Cell knows essentially nothing:
class Cell:
def __init__(self, x, y):
self._xy = (x,y)
A Cell is just a tuple with delusions of grandeur, because it can do many things. Its methods include add_content, attempt_move, available_neighbors, can_move_to, center_position, contents, creating, distance, has_passage, is_available, is_in_a_room, layout, manhattan_distance, neighbors, offset_by, randomized_neighbors, remove_content, room, vector_diff, x, xy, y.
How can an object containing just x-y coordinates do all that? It’s a bit tricky, but I think not too tricky, considering the power it gives us.
During game execution, all the Cell instances are stored in the DungeonLayout, which includes data structures for associating content with cells, for adding content, for allocating cells, creating rooms and so on. DungeonLayout creates all the Cells that will ever exist during the game . It creates them with Cell(x,y), and stores them in a dictionary indexed by (x,y).
Once that is done, if any code creates Cell(x,y), they get, not a new Cell, but the one that the layout created. It goes like this:
class DungeonLayout:
def __init__:
...
self._create_cells(max_x, max_y)
...
def _create_cells(self, max_x, max_y):
with Cell.creating(self):
Cell.layout = None
for x in range(max_x):
for y in range(max_y):
self.cells[(x, y)] = Cell(x, y)
# cell access
def _at_xy(self, xy):
return self.cells.get(xy, None)
Meanwhile, Cell has these methods:
class Cell:
layout = None
@classmethod
@contextmanager
def creating(cls, layout):
Cell.layout = None
try:
yield
finally:
Cell.layout = layout
def __new__(cls, x, y):
if cls.layout:
# noinspection PyProtectedMember
return cls.layout._at_xy((x,y))
else:
return super().__new__(cls)
def __init__(self, x, y):
self._xy = (x,y)
creating is what Python calls a context manager. It’s commonly used to open a file and return it for subsequent use, guaranteeing that it’ll be closed when the calling code exists the with block.
In this case, we clear Cell’s class variable layout and yield, dropping into the double loop in _create_cells. When that code creates a Cell, we enter Cell.__new__ and discover that there is no layout, so we create the Cell, and return it. The super()--new__ runs the __init__ and we have a brand new cell, which DungeonLayout duly tucks away.
When that creation loop ends, the context manager code puts the DungeonLayout instance into its class member layout.
The effect of all that is that after DungeonLayout creates all the Cells, any time we say Cell(x,y), the __new__ code runs, finds cls.layout, and instead of creating yet another Cell, returns the one that is already in DungeonLayout.
From the viewpoint of a Cell, we now have access to the layout that contains the Cell. Individual cells do not contain a pointer to the layout, but the class does. So inside Cell code, self.layout will return the current layout. And that’s how we can implement something like this:
class Cell:
@property
def room(self):
return self.layout.get_room(self)
@property
def is_available(self):
return self.room is None
@property
def is_in_a_room(self):
return self.room is not None
In our code, if we want to know a Cell’s room, or whether it is available, we just ask it. Inside the works, that turns into a lookup inside the layout, which has enough information to answer the question.
The result is much more readable code, since all the layout.contents_at(cell) kind of thing is expressed as cell.contents(). And much of what I’ve been doing over the past few days has been moving little methods into Cell,making the code all a bit more clear.
- Isn’t that less efficient?
- Yes. It is one method call more costly. Generally speaking, even in Python, which is notoriously slow, adding a single method dispatch is almost always negligible, unless it’s in a very long very tight loop. In practice, I would never hesitate to defer something rather than inline it, often even just for clarity. Here we get not just clarity but a simpler notation.
Today’s Idea
OK, that was then. What about now? I’m thinking about this method:
def offset_by(self, offset):
x,y = offset
return Cell(x+self.x, y+self.y)
We use this method when we calculate neighbors, and occasional other times. And it’s just fine. Except that rather than this:
class Cell:
def neighbors(self):
return [neighbor
for xy in [(-1, 0), (1, 0), (0, -1), (0, 1)]
if (neighbor := self.offset_by(xy))]
I’d prefer to write this:
def neighbors(self):
return [neighbor
for xy in [(-1, 0), (1, 0), (0, -1), (0, 1)]
if (neighbor := self + xy)]
No biggie. I just like it better. Let’s make it so:
class Cell:
def __add__(self, other):
x,y = other
return Cell(self.x+x, self.y+y)
Now we can change offset_by to use +.
def offset_by(self, offset):
return self + offset
Now we can inline offset_by. PyCharm fails to find two. I do them by hand. Remove the method. Commit.
Scanning Cell, I notice this:
class Cell:
def vector_diff(self, target):
return self.x - target.x, self.y - target.y
We could implement __sub__ and simplify the users of that method. It’s really only used here:
def manhattan_distance(self, target):
x,y = self.vector_diff(target)
return abs(x) + abs(y)
def distance(self, target):
return math.hypot(*self.vector_diff(target))
def vector_diff(self, target):
return self.x - target.x, self.y - target.y
There is a test that uses it as well, though it didn’t really need to. I don’t think we get any mileage out of doing __sub__ and since the dunder methods are at least one layer down in the bag of tricks, I wouldn’t use them without a decent amount of returned value. Here, I don’t see it.
Summary
Same idea, a named method could be replaced by an arithmetic operator. In both cases, the operator would be used correctly. But in the one case I chose to do it, and in the other, I did not. The difference: adding the + capability impacted a bit more code and some of it was outside Cell. In the case of -, all the production code impacted is inside Cell itself, so no real expressive advantage materializes.
Bottom line, for me, a very small change, a small but non-zero code improvement, the program is just a bit easier to work with.
Worth it? If you’re a human, certainly. If you’re not a human (or some other kind of sentient biology-based organism) stop reading my site.
See you next time!
Postscript
class Cell:
def attempt_move(self, direction):
return self._get_connection_in_direction(direction).move()
def _get_connection_in_direction(self, direction):
return self._get_connection_offset(direction.value)
def _get_connection_offset(self, offset):
return self._get_connection(self + offset)
We can inline:
def attempt_move(self, direction):
return self._get_connection_in_direction(direction).move()
def _get_connection_in_direction(self, direction):
return self._get_connection(self + direction.value)
And inline again:
def attempt_move(self, direction):
return self._get_connection(self + direction.value).move()
Worth it? Maybe. I’m keeping it.