Hominy
Python Asteroids+Invaders on GitHub
How many ways can we think of to do a thing? How can we decide among them? CW: Ignorant Savages.
This morning, while trying not to get up yet, I thought of yet another way to handle the Invader starting y coordinates. We have already had at least three ways to do it. How many ways are there, which is the best, and how do we decide?
Over a quarter-century ago, Kent Beck taught the C3 team that whenever a design discussion went on for too long (I think he said ten minutes and that we extended it to twenty), the issue should be settled by an experiment. He also spoke, other times, about “letting the code participate in our design discussions”, which is a lovely metaphor, in my opinion.
Back in those days, we spoke often of things like “code smells” and the code telling us things. “A comment is the code’s way of asking to be made more clear.”
No, we were not ignorant coding savages sitting in the desert cooking up a meal of cryptic C statements over a smokey fire made of old program listings and punch cards, imagining mysterious gods in the sky and strange voices in the computer.1 We were speaking of a sense one can get of the code cooperating in the work or fighting back against what we’re tying to do with it. What that sense is really, no one knows, but then we don’t really know what vision is either. What we do know and try to express in many ways is that programming sometimes feels smooth and easy and other times feels difficult, and that much of that difference is driven by the quality of the code. We’ll notice the feelings before we really grasp that there’s something the code needs.
To decide which design is better, we should first of all recognize that what we mean is “which design do we prefer for this situation”, and recognize that different individuals will prefer different choices. The most valuable thing during th decision process may be to draw out the reasons for those preferences. Alex may see that this one is more difficult to change than that one. Blake is newer to programming, and doesn’t yet understand how that one works. Cameron notices that this other one is more efficient than the current one.
Those differences can help us come together on a decision, if we can all come to appreciate everyone’s concerns, and, as with Blake, we might just realize that Blake would do well to pair a bit more with Cameron, who is really good at demonstrating new techniques.
But to understand those different designs well enough to have those views legitimately, it seems to me that we need to see them implemented in real code, at least in tests if not in situ.
By way of example, I’ll present here some solutions to the y coordinate problem as I can think of, so that we can talk about them more clearly.
Y Coordinate Solutions
Let’s do our solutions using tests, which would generally be easier than plugging each one into the actual objects, and might actually give us a more consistent look at the advantages and dis- of the various ways. The team might have these starting ideas in mind:
- Open Code
- Small Helper Objects
- Generator
- Looper Object
We have a nice test for the generator to serve as a prototype for the others:
def test_y_generator(self):
def gen_y():
def convert(y_8080):
return 0x400 - 4*y_8080
yield convert(u.INVADER_FIRST_START)
index = 0
while True:
yield convert(u.INVADER_STARTS[index])
index = (index + 1) % len(u.INVADER_STARTS)
y_generator = gen_y()
assert next(y_generator) == 1024 - 4*u.INVADER_FIRST_START
for i in range(8):
assert next(y_generator) == 1024 - 4*u.INVADER_STARTS[i]
assert next(y_generator) == 1024 - 4*u.INVADER_STARTS[0]
But we need also to look at how it plugs into the InvaderFleet:
def __init__(self, generator=None):
self.y_generator = self.use_or_create(generator)
y = next(self.y_generator)
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5 * 64, y)
self.invader_group = InvaderGroup()
self.invader_group.position_all_invaders(self.origin)
self.direction = 1
self.step_origin()
@staticmethod
def use_or_create(generator):
return generator if generator else generate_y()
def next_fleet(self):
return InvaderFleet(self.y_generator)
We can see here that our protocol is that the initial InvaderFleet is created with no generator
, and that in that case it creates one. The generator returns the y coordinate on a call to next
, and the generator is passed to the new instance of the fleet when we create it.
So we can code up other objects that can do a similar job.
Here’s the one called Looper:
def test_looper(self):
class Looper:
def __init__(self):
self.index = 0
self.items = [u.INVADER_FIRST_START, ] + list(u.INVADER_STARTS)
def next(self):
result = 1024 - 4*self.items[self.index]
self.index += 1
if self.index >= len(self.items):
self.index = 1
return result
looper = Looper()
assert looper.next() == 1024 - 4*u.INVADER_FIRST_START
for i in range(8):
assert looper.next() == 1024 - 4*u.INVADER_STARTS[i]
assert looper.next() == 1024 - 4*u.INVADER_STARTS[0]
That’s a pretty simple little class, and it could easily be passed around just like the generator version. i don’t like it because of the necessity to loop back to one rather than zero, and because the if statement could be wrong. (In fact I initially had it wrong). We could put the first value last. Maybe that would be better.
Ron tried to improve Looper to remove to the objection about the comparison and the if, but he was unable to make it better:
def test_looper(self):
class Looper:
def __init__(self):
self.index = 8
self.items = list(u.INVADER_STARTS) + [u.INVADER_FIRST_START, ]
self.length = len(u.INVADER_STARTS)
def next(self):
result = 1024 - 4*self.items[self.index]
self.index += 1
if self.index >= self.length:
self.index = 0
return result
looper = Looper()
assert looper.next() == 1024 - 4*u.INVADER_FIRST_START
for i in range(8):
assert looper.next() == 1024 - 4*u.INVADER_STARTS[i]
assert looper.next() == 1024 - 4*u.INVADER_STARTS[0]
The code just got worse. So Looper in its original form is the better of those two.
We could review the old solution, with multiple helper objects:
def test_invader_origin_helper(self):
helper = OriginHelper.make_helper(None)
assert helper.starting_y == 1024 - 4*u.INVADER_FIRST_START
assert helper.next_index == 0
def test_invader_origin_helper_integer(self):
helper = OriginHelper.make_helper(0)
assert helper.starting_y == 1024 - 4*u.INVADER_STARTS[0]
assert helper.next_index == 1
class OriginHelper:
@classmethod
def make_helper(cls, start):
if start is None:
return StartingHelper()
else:
return RunningHelper(start)
class StartingHelper:
@property
def starting_y(self):
return 1024 - 4*u.INVADER_FIRST_START
@property
def next_index(self):
return 0
class RunningHelper:
def __init__(self, index):
self.index = index % len(u.INVADER_STARTS)
@property
def starting_y(self):
return 1024 - 4*u.INVADER_STARTS[self.index]
@property
def next_index(self):
return (self.index + 1) % len(u.INVADER_STARTS)
When we compare this solution to the looper or generator solutions, it’s hard to prefer it. I can’t even say why I preferred it long enough to leave it in for a day. I guess it did look better than what we started with:
class InvaderFleet(InvadersFlyer):
step = Vector2(8, 0)
down_step = Vector2(0, 32)
def __init__(self, start_index=-1):
self.invader_group = InvaderGroup()
if start_index == -1:
self.start_index = -1
# Wants to be more clear. This forces next index to 0 as desired after first start.
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_FIRST_START))
else:
self.start_index = start_index % len(u.INVADER_STARTS)
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_STARTS[self.start_index]))
self.invader_group.position_all_invaders(self.origin)
self.direction = 1
self.step_origin()
def next_fleet(self):
new_index = (self.start_index + 1) % len(u.INVADER_STARTS)
return InvaderFleet(new_index)
After plugging in the three little classes, the production code was like this:
def __init__(self, start_index=None):
self.invader_group = InvaderGroup()
helper = OriginHelper.make_helper(start_index)
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5 * 64, helper.starting_y)
self.next_index = helper.next_index
self.invader_group.position_all_invaders(self.origin)
self.direction = 1
self.step_origin()
def next_fleet(self):
return InvaderFleet(self.next_index)
So it was clearly better there, but the guts of the solution weren’t as compact as Looper or the generator.
The team might discuss building their own generator object, which is a bit more of the old-school way to do a generator, but would agree that the generator function is probably simpler for this case. If they weren’t sure, they’d quickly gin up a generator class, which, since they have decided would be more code and no better, I’ll spare you.
Note that we have several different ways of seeing the code: we have reviewed one existing test, then implemented another like it. We have reviewed some code in place, that we don’t like. We have tried to revise the code in one of our examples and decided that we didn’t have a better idea after all. And we have discussed one alternative briefly and seen that it would be more code to do the same thing we have now.
With the one exception, we’ve looked at concrete code that someone proposed, and in the case we didn’t try, it was because no one was motivated to try it, based on a decent understanding of what was involved. If no one wants to do it, we feel reasonably confident that it’s not a great idea.
We decide based on as much concrete information, from examples, to give us experience with the alternatives. The point, I’ll argue, is the experience.
I think that Alex and Cameron might decide that the generator is preferable,, though a bit deep in the bag of tricks, and they might resolve to work to bring Blake up to speed a bit on some of the intricacies of Python. On the other hand, if Blake happened to pick up the card that said to improve the code from the original open version, and decided to do it with the Looper, that would be OK too.
The Lesson
In my experience, as educated by Kent and others, I make my best determinations about which code to prefer when I have the code in front of me, or at least enough of it to get a solid sense of what it would be like. My imagination doesn’t quite pick up details like the funky if statement or the little skip and hop you have to do to get the list you want.
When it comes down to a real team effort, most of the decisions will be made by whoever is programming the solution. I would very much wish that the work was done by a pair, or by a large ensemble, rather than by one individual. I would pair at home, if I could tolerate having someone in my home. And if I could find someone who would get up at 6 AM to do it. Or who would do it at all. Maybe.
But some decisions definitely need more consideration, and in those cases I would want to see at least tests and the code we would put in the system, if not see the code in situ. Yes, that’s writing and changing more code than we would write if we just decided. But just as most of us can’t imagine a book or even a chapter or even an article in our head, but have to write it down and edit it, we surely cannot correctly envision working code in our heads.
Some ancient wise person said “Good judgment comes from experience, and experience comes from bad judgment”.2 I believe strongly that, while we can learn a lot by reading and discussion and study, we truly learn primarily by doing.
We become better at any art by doing the art. One way of learning by doing is to do the same thing in many different ways. There is generally no objective “best” among the ways, but the more experience we have, the better our choices are likely to work out.
Something to think about: think about coding … by doing the coding. Create experience and learn from it.
See you next time!