Review / Refine
We’ll take a look at some small changes to improve the World class. If only we could refactor the Real World so easily. Maybe we can?
Here’s the entire World class. We’ll just scan it, not read in detail, and see what we notice.
class World:
def __init__(self, max_x, max_y):
self.width = max_x
self.height = max_y
self.map = Map(max_x, max_y)
self.ids_used = set()
def add_block(self, x, y, aroma=0):
return self._add_entity(WorldEntity.block(x, y, aroma))
def add_bot(self, x, y, direction = Direction.EAST):
return self._add_entity(WorldEntity.bot(x, y, direction))
def _add_entity(self, entity):
self.map.place(entity)
return entity.id
def entity_from_id(self, bot_id):
return self.map.at_id(bot_id)
def execute(self, actions_list):
self.ids_used = set()
self.execute_actions(actions_list)
return [ self.fetch(bot_id) for bot_id in self.ids_used ]
def execute_actions(self, actions_list):
for action in actions_list:
self.unpack_and_execute(**action)
def unpack_and_execute(self, entity, **parameters):
if entity:
self.ids_used.add(entity)
parameters['entity_object'] = self.entity_from_id(entity)
self.execute_action(**parameters)
def execute_action(self, entity_object=None, **action_dictionary):
match action_dictionary:
case {'verb': 'add_bot',
'x': x, 'y': y, 'direction': direction}:
self.add_bot_action(x, y, direction)
case {'verb': 'drop',
'holding': holding}:
self.drop_forward_action(entity_object, holding)
case {'verb': 'step'}:
self.step(entity_object)
case {'verb': 'take'}:
self.take_forward(entity_object)
case {'verb': 'turn',
'direction': 'NORTH' | 'EAST' | 'SOUTH' | 'WEST' as direction}:
self.turn(entity_object,direction)
case {'verb': 'turn', 'direction': bad_direction}:
raise AttributeError(f'unknown direction {bad_direction}')
case {'verb': 'NORTH' | 'EAST' | 'SOUTH' | 'WEST' as direction}:
self.turn(entity_object, direction)
case _:
raise Exception(f'Unknown action {action_dictionary}')
def add_bot_action(self, x, y, direction):
bot_id = self.add_bot(x, y, Direction.from_name(direction))
self.ids_used.add(bot_id)
def drop_forward_action(self, entity, holding):
self.drop_forward(entity, self.entity_from_id(holding))
def drop_forward(self, bot, entity):
if self.map.place_at(entity, bot.forward_location()):
bot.remove(entity)
def step(self, bot):
self.map.attempt_move(bot.id, bot.forward_location()) # changes world version
self.set_bot_vision(bot)
self.set_bot_scent(bot)
def take_forward(self, bot):
is_block = lambda e: e.name == 'B'
if block := self.map.take_conditionally_at(bot.forward_location(), is_block):
bot.receive(block)
def turn(self, world_bot, direction_name):
# no change on unrecognized name
if direction_name in ['NORTH', 'EAST', 'SOUTH', 'WEST']:
world_bot.direction = Direction.from_name(direction_name)
def fetch(self, entity_id):
return self.entity_from_id(entity_id).as_dictionary()
def set_bot_vision(self, bot):
bot.vision = self.map.vision_at(bot.location)
def set_bot_scent(self, bot):
aroma_to_seek = 0
if bot.holding:
aroma_to_seek = bot.holding.aroma
bot.scent = self.map.scent_at(bot.location, aroma_to_seek)
def is_empty(self, drop_location):
return not self.map.at_xy(drop_location.x, drop_location.y)
-
I notice one private method,
_add_entity
. That’s an odd number. I seem to be ambivalent about using the underbar-private notation in Python. Improving consistency would be good. -
Some of the methods that
execute_action
calls are named ending inaction
and some are not. Consistency here would be good. If we named themaction_xyz
instead ofxyz_action
, it would make sense to keep them together. That might be an improvement. Maybe something more verb-like would be even better. -
The top-level execution calls go
execute
toexecute_actions
tounpack_and_execute
toexecute_action
. Those names don’t quite tell the story. We’ll come back to that below, I think. We’re scanning here, collecting impressions and ideas. -
drop_forward_action
callsdrop_forward
and nothing else. We’ve allowed other verbs to have a conditional such as we find indrop
. Perhaps we should inline this one. No.drop_forward_action
is making a key transformation from an id to an object. But let’s consider some parameter names below. -
The method
is_empty
is used only in tests. We could remove it by changing the tests. -
set_bot_scent
could use a ternary, which some might consider an improvement.
Enough scanning. What have we learned? Well, I think we have learned that there is almost always room for improvement in my code, and my experience suggests that that’s true for just about any code. We do not always choose to make those improvements: there is often something more important on our mind. But some of these things are so quick to do that we can do them as soon as we notice them. Almost every day while I’m writing one of these screeds, I see something trivial and improve it without mentioning it. When it’s a machine refactoring, and the tests run, why not?
Let’s look a bit harder at this:
def drop_forward_action(self, entity, holding):
self.drop_forward(entity, self.entity_from_id(holding))
def drop_forward(self, bot, entity):
if self.map.place_at(entity, bot.forward_location()):
bot.remove(entity)
The meaning of entity
swaps between those two methods. In fact, in drop_forward_action
and elsewhere, the so-called entity
is a bot, not an arbitrary entity. Let’s do some renaming. We’re going to find that we need a fair amount, I think, but the changes should all be pretty local. We can commit each one if we want to. And, according to GeePaw Hill, we should. Micro-changes.
def drop_forward_action(self, bot, holding):
self.drop_forward(bot, self.entity_from_id(holding))
def drop_forward(self, bot, entity):
if self.map.place_at(entity, bot.forward_location()):
bot.remove(entity)
Renamed drop_forward_action
parameter entity
to bot
. Now the parameters match. Commit: minor renaming.
Rename:
def drop_forward(self, bot, held_entity):
if self.map.place_at(held_entity, bot.forward_location()):
bot.remove(held_entity)
Why not block
? Because someday a bot may be able to hold other things than blocks. Commit again.
Rename step
to step_action
for consistency. Commit. Rename take_forward
. Commit. Rename turn
. Commit.
Now all the methods called from execute_action
are named xyz_action
for values of xyz
. They are grouped together already, except for this pair, which troubles me:
def drop_forward_action(self, bot, holding):
self.drop_forward(bot, self.entity_from_id(holding))
def drop_forward(self, bot, held_entity):
if self.map.place_at(held_entity, bot.forward_location()):
bot.remove(held_entity)
This is the only one that requires an extra method / step. That may be OK: it’s the only one that involves a second entity. But the drop_forward
is called from tests. Let’s see if we can make them call the action method instead.
def test_bot_can_drop_on_empty_space(self):
world = World(10, 10)
bot_id = world.add_bot(5, 5, Direction.NORTH)
bot = world.entity_from_id(bot_id)
block = WorldEntity.block(4, 4)
bot.receive(block)
assert bot.has(block)
world.drop_forward(bot, block)
assert not bot.has(block)
assert world.map.at_xy(5, 4) is block
This should be legit:
def test_bot_can_drop_on_empty_space(self):
world = World(10, 10)
bot_id = world.add_bot(5, 5, Direction.NORTH)
bot = world.entity_from_id(bot_id)
block = WorldEntity.block(4, 4)
bot.receive(block)
assert bot.has(block)
world.drop_forward_action(bot, block.id)
assert not bot.has(block)
assert world.map.at_xy(5, 4) is block
The test fails. The bug is that the block was not added to the world by the test. The test is wrong.
def test_bot_can_drop_on_empty_space(self):
world = World(10, 10)
bot_id = world.add_bot(5, 5, Direction.NORTH)
bot = world.entity_from_id(bot_id)
block_id = world.add_block(4, 4)
block = world.entity_from_id(block_id)
bot.receive(block)
assert bot.has(block)
world.drop_forward_action(bot, block.id)
assert not bot.has(block)
assert world.map.at_xy(5, 4) is block
That works as intended. The test was valid before, I think, but just by luck.
I make the corresponding change to the other uses of drop_forward
. Everyone calls the action now. This is good. Commit: adjust tests to call drop_forward_action.
Now let’s see if we want to do an inline here:
def drop_forward_action(self, bot, holding):
self.drop_forward(bot, self.entity_from_id(holding))
def drop_forward(self, bot, held_entity):
if self.map.place_at(held_entity, bot.forward_location()):
bot.remove(held_entity)
Curiously, PyCharm acts as if it is going to do the inline and then does nothing. I submit a bug report. That’s quite rare.
Shall we inline it by hand? I extract variable:
def drop_forward_action(self, bot, holding):
held_entity = self.entity_from_id(holding)
self.drop_forward(bot, held_entity)
def drop_forward(self, bot, held_entity):
if self.map.place_at(held_entity, bot.forward_location()):
bot.remove(held_entity)
I’ll try the inline again. Still no go.
I move the code myself:
def drop_forward_action(self, bot, holding):
held_entity = self.entity_from_id(holding)
if self.map.place_at(held_entity, bot.forward_location()):
bot.remove(held_entity)
And inline the variable … no … we can’t do that, it’s used twice now.
Having seen this result, I do not prefer it. Undo.
I think that’s a sign that we should stop. But first I want to review the higher level flow again:
def execute(self, actions_list):
self.ids_used = set()
self.execute_actions(actions_list)
return [ self.fetch(bot_id) for bot_id in self.ids_used ]
def execute_actions(self, actions_list):
for action in actions_list:
self.unpack_and_execute(**action)
def unpack_and_execute(self, entity, **parameters):
if entity:
self.ids_used.add(entity)
parameters['entity_object'] = self.entity_from_id(entity)
self.execute_action(**parameters)
def execute_action(self, entity_object=None, **action_dictionary):
match action_dictionary:
case {'verb': 'add_bot',
'x': x, 'y': y, 'direction': direction}:
self.add_bot_action(x, y, direction)
case {'verb': 'drop',
'holding': holding}:
self.drop_forward_action(entity_object, holding)
case {'verb': 'step'}:
self.step_action(entity_object)
case {'verb': 'take'}:
self.take_forward_action(entity_object)
case {'verb': 'turn',
'direction': 'NORTH' | 'EAST' | 'SOUTH' | 'WEST' as direction}:
self.turn_action(entity_object, direction)
case {'verb': 'turn', 'direction': bad_direction}:
raise AttributeError(f'unknown direction {bad_direction}')
case {'verb': 'NORTH' | 'EAST' | 'SOUTH' | 'WEST' as direction}:
self.turn_action(entity_object, direction)
case _:
raise Exception(f'Unknown action {action_dictionary}')
Only the top-level execute
is really public. It might be better named execute_request
or execute_requests
. Let’s do the latter.
Maybe execute isn’t the best word. Let’s think about this sequence:
execute_requests
to execute_actions
to unpack_and_execute
to execute_action
.
Perform? Do? Carry out? Let’s try something. Let’s inline the unpack. No, we can’t do that, the **parameters
thing won’t work.
Bah. I’ve stared at this long enough. Tried a few refactorings, broke something every time. Good sign that we’ve done enough: today was supposed to be small improvements, nothing requiring deep thought. Let’s sum up:
Summary
On the bright side, we’ve seen tiny changes, each taking moments to do and commit, each making (what we consider to be) small improvements to the code. They’ve added up to improve the consistency of the World’s request executing code.
There are still some things I don’t like: the name sequence down the execute_requests
chain is a bit bumpy. Probably doesn’t matter. the one step unpack_and_execute
is the largest bump: a method with an “and” in its name always suggests a need for some kind of improvement.
And the big match/case
above is a large blob of code. No real way around that as far as I can see. Would some spacing help? No, I like that even less. We might do well to encapsulate the raise
cases. We’ll probably deal with that soon, I have error handling on the list of things we need.
I’ll mention again, that while in real life we might never have a morning to spend like this one, just making small improvements, almost every day in real life will have us encounter many opportunities to make a single small improvement. If we take the opportunity, especially when our IDE will do the bulk of the work, our code will be more likely to stay alive and flexible.
I think it’s worth making the small changes, and worth stealing a couple of hours to do a few, when—if it ever happens—the pressure lowers. And sometimes, when the pressure is high, it’s a good move to decompress with a little refactoring anyway,
You get to use your own judgment, of course. Judgment, not just reaction, that’s the ticket.
See you next time!
- Last Minute Idea
- How about this for a rename:
def execute_actions(self, actions_list):
for action in actions_list:
self.execute_with_entity(**action)
def execute_with_entity(self, entity, **parameters):
if entity:
self.ids_used.add(entity)
parameters['entity_object'] = self.entity_from_id(entity)
self.execute_action(**parameters)
I think I prefer that! Woot! Committed.