Review: The Machine
We begin to look back at the evolution of the Bots’ state machine. In this article we’ll quickly review the three ways we have represented the Bot state. In the next, we’ll look at the process of getting from there to here.
THe overall behavior of the state machine has been pretty consistent, although not totally so. It has always had three states, walking, looking, and laden, where the Bot just wanders, seeks blocks, and seeks places to get rid of blocks. Kind of a boring existence, but, hey, it’s a living. Let’s review the three ways we’ve done this. I’d suggest a “quick read” of the following code, not quite just scanning over it, but also not drilling in too deeply. But you do you. Here’s the most recent code:
Class Per State
Here we have one class for each state, with two methods, update
and action
, and all the information that the machine needs stored in a Knowledge object, passed as a parameter. update
returns the state that the object should be in based on prior state and current knowledge. action
returns a list of zero or one text commands, take
, drop
, or nothing.
We refer to a Knowledge object, not shown, and it should be fairly clear what it knows, from the references to it.
class Bot:
def __init__(self, x, y, direction=Direction.EAST):
...
self.state = Walking()
def do_something(self):
self.update()
self._knowledge.tired -= 1
self.state = self.state.update(self._knowledge)
self.do_state_actions()
self.move()
class Walking:
def action(self, _knowledge):
return []
def update(self, knowledge):
if knowledge.tired <= 0:
if knowledge.has_block:
return Laden()
else:
return Looking()
return Walking()
class Looking:
def update(self, knowledge):
if knowledge.has_block:
knowledge.tired = 5
return Walking()
else:
return Looking()
def action(self, knowledge):
if knowledge.can_take:
return ['take']
return []
class Laden:
def update(self, knowledge):
if not knowledge.has_block:
knowledge.tired = 5
return Walking()
else:
return Laden()
def action(self, knowledge):
if knowledge.tired <= 0:
if knowledge.can_drop:
return ['drop']
return []
Method Object
In this scheme, which went through several evolutions, the state machine is embedded in a new object class, Machine, which moves all the procedural code that was formerly part of Bot into its own class. The change from code in one object into a method object generally addresses separation of concerns, moving a coherent set of concerns out of the original object and into the new one. In this case, the concerns have to do with the states of the machine, which are quite evident below, with the two phases of operation in each state named and separated out.
In earlier versions of the method object Machine, we were referencing directly back to the Bot instance, and we evolved into using the Knowledge instance used here. Again, that object is not shown, but what it knows should be clear from its methods.
class Bot:
def do_something(self):
self.update()
actions = self.state.state(self._knowledge)
for action in actions:
match action:
case 'take':
self.world.take_forward(self)
case 'drop':
self.world.drop_forward(self, self.inventory[0])
case _:
assert 0, f'no case {action}'
self.move()
class Machine:
def __init__(self, knowledge):
assert isinstance(knowledge, Knowledge)
self._knowledge = knowledge
self.tired = 10
self._update = self.walking_update
self._action = self.walking_action
self._state = None
def state(self, knowledge):
assert isinstance(knowledge, Knowledge)
self._knowledge = knowledge
self.tired -= 1
if self._state:
self._state.update()
else:
info = self._update()
self._update, self._action, self._state = info
if self._state:
self._state.action()
else:
return self._action()
def walking_states(self):
return self.walking_update, self.walking_action, None
def looking_states(self):
return self.looking_update, self.looking_action, None
def laden_states(self):
return self.laden_update, self.laden_action, None
def set_states(self, states):
self._update, self._action, self._state = states
def walking_update(self):
if self.tired <= 0:
if self._knowledge.has_block:
return self.laden_states()
else:
return self.looking_states()
return self.walking_states()
def walking_action(self):
return []
def looking_update(self):
if self._knowledge.has_block:
self.tired = 5
return self.walking_states()
else:
return self.looking_states()
def looking_action(self):
if self._knowledge.can_take:
return ['take']
return []
def laden_update(self):
if not self._knowledge.has_block:
self.tired = 5
return self.walking_states()
else:
return self.laden_states()
def laden_action(self):
if self.tired <= 0:
if self._knowledge.can_drop:
return ['drop']
return []
You should note that this version is 85 lines, compared to the class per state version of 53 lines. I find this single multi-method class much harder to understand, because it is harder to pick up the pattern of things, amid the clutter of necessary support methods.
Original In-Line Machine
This is where we started on the journey through Method Object to State Per Class:
class Bot:
def __init__(self, x, y, direction=Direction.EAST):
...
self.state = self.walking
def do_something(self):
self.update()
self.state()
self.move()
def update(self):
pass
def walking(self):
if self.tired <= 0:
self.state = self.looking
def looking(self):
if self.inventory:
self.tired = 5
self.state = self.laden
return
if self.can_take():
self.take()
def laden(self):
if self.has_no_block():
self.tired = 5
self.state = self.walking
return
if self.tired <= 0:
if self.can_drop():
block = self.inventory[0]
self.world.drop_forward(self, block)
This scheme is far less code than either of the other two! It is only 35 lines as shown, vs 85 for Method Object and 52 for State Per Class.
One has to ask why we did this at all. Isn’t this version better than the other two? Well, looked at in that small snapshot, it might be. But let’s look at some of the support code, which is also in Bot:
def has_block(self):
return self.has_inventory('B')
def has_no_block(self):
return not self.has_block()
def has_inventory(self, entity_name):
for entity in self.inventory:
if entity.name == entity_name:
return True
return False
def can_take(self):
return self.forward_name() == 'B' and (self.forward_left_name() == '_' or self.forward_right_name() == '_')
def forward_name(self):
forward = self.location.forward(self.direction)
return self.vision.name_at(forward)
def forward_left_name(self):
forward_left = self.location.forward_left(self.direction)
return self.vision.name_at(forward_left)
def forward_right_name(self):
forward_right = self.location.forward_right(self.direction)
return self.vision.name_at(forward_right)
def can_drop(self):
return self.forward_name() == '_' and (self.forward_left_name() == 'B' or self.forward_right_name() == 'B')
def take(self):
self.world.take_forward(self)
There’s another 33 lines, bringing the total to nearly 70, larger than we are showing for State Per Class, though still smaller than the Method Object.
Comparing the Styles
I hope there is no one out there who would argue that the Method Object is better than the State Per Class, but I am sure there are people who would argue that the in-line plain old simple procedural version is better than the other two. They would likely say things like “it’s all here where I can see it, it’s easier to understand that way”. I have some sympathy for that feeling, because when faced with code much like we often see today, it’s easier to understand a big blob than it is to skip around trying to reassemble the big blob in our mind.
But the State Per Class, and even the Method Object, are not quite like that. We don’t really have to look around to see what they do, and if they need to change, the changes are most likely to be right there, not off somewhere else. They have, as I said earlier, separated the concerns of the state machine off by themselves.
It comes down to change. The state machine for our Bot will surely change as the program evolves. We want it to continue to have “ridiculously simple” rules of behavior, but we want it to do more things using a small set of simple rules. We’ll probably demonstrate that in the near future. I believe and I am sure that GeePaw believes, that the isolation will pay off as we change things.
Perhaps more to the point, we believe that separation of concerns usually pays off, and that we should therefore work toward it always.
What about YAGNI?
Doesn’t the You Aren’t Gonna Need It rule say that we shouldn’t have done this change yet, because we do not need new state behavior yet? It does say that, but it is more a guideline than an actual rule. If we had gone from the procedural version to the state per class version in two easy sessions, using a morning or a day, I’d be totally sanguine about it. As it turned out, working mostly alone, it took me eight sessions to get the job done, and for much of that time, I didn’t feel good about what was happening.
For a morning or a day, I think getting to State Per Class was almost certainly worth it, even if we don’t count the pedagogical value of showing how nicely it turns out. At a cost of two full days and an elapsed week, I think there was waste in what I did, and we’ll explore that in the next article.
As a result, I much prefer the Class Per State structure, and while it is not perfect (surely we’ll come back to that), it’s much better, with much better isolation of concepts than we had when we started.
Next time, we’ll talk about how I worked, and what I’ve learned. (Again.) See you then!