And Finally ...
Hello, loves!
In a few more steps, let’s provide some class methods, and the one-time-thru version of Sayer. Added P.S. Tout le monde déteste l’IA.
Here’s Sayer now:
class Sayer:
def __init__(self, messages, indexing=''):
self.update_index = self.define_indexing(indexing)
self.messages = messages
self.index = 0
def __iter__(self):
return self
def __next__(self):
m = self.messages[self.index]
self.index = self.update_index(self.index)
return m
def define_indexing(self, indexing):
indexing_types = dict(
cycle=lambda index: (self.index + 1) % len(self.messages),
random=lambda index: randrange(len(self.messages)),
sequence=lambda index: min(self.index + 1, len(self.messages)-1),
)
return indexing_types.get(indexing, lambda index: index)
With some class methods, we can get rid of the dictionary thing, I believe.
First, let’s get rid of the self references in the lambdas:
def define_indexing(self, indexing):
indexing_types = dict(
cycle=lambda index, messages: (index + 1) % len(messages),
random=lambda index, messages: randrange(len(messages)),
sequence=lambda index, messages: min(index + 1, len(messages)-1),
)
return indexing_types.get(indexing, lambda index, messages: index)
Making those pure functions should help.
def __next__(self):
m = self.messages[self.index]
self.index = self.update_index(self.index, self.messages)
return m
def define_indexing(self, indexing):
indexing_types = dict(
cycle=lambda index, messages: (index + 1) % len(messages),
random=lambda index, messages: randrange(len(messages)),
sequence=lambda index, messages: min(index + 1, len(messages)-1),
)
return indexing_types.get(indexing, lambda index, messages: index)
Green. Commit: lambdas no longer refer to self.
Now some class methods, which I’ll test by changing how the tests make Sayers. I think I need to change them all, let them break, then fix them back up. Change them to assume new class methods, like this:
class TestSaying:
def test_say_sequence(self):
seq = Sayer.sequence(['a', 'b', 'c'])
assert next(seq) == 'a'
assert next(seq) == 'b'
assert next(seq) == 'c'
assert next(seq) == 'c'
def test_cycle(self):
seq = Sayer.cycle(['a', 'b', 'c'])
assert next(seq) == 'a'
assert next(seq) == 'b'
assert next(seq) == 'c'
assert next(seq) == 'a'
And so on. Now in Sayer, we’ll require that the lambda be sent in at construction time.
class Sayer:
@classmethod
def cycle(cls, sayings):
return cls(sayings, lambda index, messages: (index + 1) % len(messages))
@classmethod
def random(cls, sayings):
return cls(sayings, lambda index, messages: randrange(0, len(messages)))
@classmethod
def sequence(cls, sayings):
return cls(sayings, lambda index, messages: min(index + 1, len(messages)-1))
def __init__(self, messages, indexing):
if indexing is None:
indexing = lambda index, messages: index
self.update_index = indexing
self.messages = messages
self.index = 0
Now the class methods on NameSayer are in need of fixing, from:
class NameSayer:
@classmethod
def cycle(cls, name, sayings):
return cls(name, Sayer(sayings, 'cycle'))
@classmethod
def random(cls, name, sayings):
return cls(name, Sayer(sayings, 'random'))
@classmethod
def sequence(cls, name, sayings):
return cls(name, Sayer(sayings, 'sequence'))
To:
class NameSayer:
@classmethod
def cycle(cls, name, sayings):
return cls(name, Sayer.cycle(sayings))
@classmethod
def random(cls, name, sayings):
return cls(name, Sayer.random(sayings))
@classmethod
def sequence(cls, name, sayings):
return cls(name, Sayer.sequence(sayings))
All green. Commit: change to class methods eliminates lookup of indexing methods.
Now there’s still the thing we need, in Denizen, when Dot arrives with the flower:
def seeking(self, interactor):
if interactor.has('a flower'):
if self.gift:
interactor.announce(next(self.gift_sayings))
interactor.announce(next(self.gift_sayings))
interactor.receive_content(self.gift)
return self.satisfied
else:
interactor.announce(next(self.initial_sayings))
return self.seeking
We don’t want to know how many messages there are: we’d just like to loop over them, like this:
def seeking(self, interactor):
if interactor.has('a flower'):
if self.gift:
for saying in self.gift_sayings:
interactor.announce(saying)
interactor.receive_content(self.gift)
return self.satisfied
else:
interactor.announce(next(self.initial_sayings))
return self.seeking
However, this loops forever. We need to terminate the iteration. I think we might do it this way:
def __next__(self):
if self.index >= len(self.messages):
raise StopIteration
m = self.messages[self.index]
self.index = self.update_index(self.index, self.messages)
return m
And provide a new Sayer, once:
class Sayer:
@classmethod
def once(cls, sayings):
return cls(sayings, lambda index, messages: index + 1)
class NameSayer:
@classmethod
def once(cls, name, sayings):
return cls(name, Sayer.once(sayings))
And use that for the gift sayings:
class Denizen:
def __init__(self, *, name,
initial_sayings=None,
gift=None):
self.name = name
if initial_sayings is None:
initial_sayings = ['Hmm...']
self.initial_sayings = NameSayer.cycle(self.name, initial_sayings)
gift_sentences = [
'Oh, thank you!',
'Here\'s something you may need.',
]
self.gift_sayings = NameSayer.once(self.name, gift_sentences)
...
And we are green. Commit: once-style sayer iterates just once per saying. all others will iterate forever (not recommended)
Are we happy? We are not. I think that if any Sayer is called with for, it should iterate only once, but that using next should cycle forever, since in the game we want the NPCs to drop into a loop of repetition, probably random.
I wonder how we could do that?
How about this: we’ll use a separate counter, not index to terminate the loop, and we’ll implement next to clear that counter and call next directly. Might work, let’s try that.
Ah no, we can’t do that because we say next(sayings) not sayings.next(). Belay that idea.
Hmm, what if we did this:
def __iter__(self):
return self.__class__.once(self.messages)
We create a once-style class and iterate that. Now this test should pass:
def test_no_infinite_loop(self):
sayings = ['a', 'b', 'c']
sayer = Sayer.sequence(sayings)
count = 0
for s in sayer:
count += 1
assert count == 3
Commit for on any Sayer only loops as many times as there are messages.
Reflection
OK, that’s a bit arcane, with two odd aspects: Here:
def __next__(self):
if self.index >= len(self.messages):
raise StopIteration
m = self.messages[self.index]
self.index = self.update_index(self.index, self.messages)
return m
The next really only ever raises StopIteration if we are in a once Sayer, since all the others constrain index never to reach len(messages). True but not obvious. Add a comment?
def __iter__(self):
# ensure `for` only iterates once,
# while `next` can repeat without limit
return self.__class__.once(self.messages)
def __next__(self):
if self.index >= len(self.messages):
# can only happen if we are a `once` Sayer
raise StopIteration
m = self.messages[self.index]
self.index = self.update_index(self.index, self.messages)
return m
Kent Beck used to say “A comment is the code’s way of asking to be made more clear”, and I assure you that I would do that if I knew how. Let’s see if I know how. How about some explaining method names?
def __iter__(self):
return self._only_loop_once()
def _only_loop_once(self):
return self.__class__.once(self.messages)
That’s not perfect, but it should catch the eye and make us think if we ever stop back here. What about the other method?
def __next__(self):
if self._once_and_done():
raise StopIteration
m = self.messages[self.index]
self.index = self.update_index(self.index, self.messages)
return m
def _once_and_done(self):
return self.index >= len(self.messages)
Again, imperfect but should catch the eye and make us think. Commit.
Here’s a final look at the Sayers as they now stand:
class Sayer:
@classmethod
def cycle(cls, sayings):
return cls(sayings, lambda index, messages: (index + 1) % len(messages))
@classmethod
def once(cls, sayings):
return cls(sayings, lambda index, messages: index + 1)
@classmethod
def random(cls, sayings):
return cls(sayings, lambda index, messages: randrange(0, len(messages)))
@classmethod
def sequence(cls, sayings):
return cls(sayings, lambda index, messages: min(index + 1, len(messages)-1))
def __init__(self, messages, indexing):
if indexing is None:
indexing = lambda index, messages: index
self.update_index = indexing
self.messages = messages
self.index = 0
def __iter__(self):
return self._only_loop_once()
def _only_loop_once(self):
return self.__class__.once(self.messages)
def __next__(self):
if self._once_and_done():
raise StopIteration
m = self.messages[self.index]
self.index = self.update_index(self.index, self.messages)
return m
def _once_and_done(self):
return self.index >= len(self.messages)
class NameSayer:
@classmethod
def cycle(cls, name, sayings):
return cls(name, Sayer.cycle(sayings))
@classmethod
def once(cls, name, sayings):
return cls(name, Sayer.once(sayings))
@classmethod
def random(cls, name, sayings):
return cls(name, Sayer.random(sayings))
@classmethod
def sequence(cls, name, sayings):
return cls(name, Sayer.sequence(sayings))
def __init__(self, name, sequence):
self.name = name
self.sequence = sequence
def __iter__(self):
return self
def __next__(self):
return f'{self.name}: "{next(self.sequence)}"'
64 lines, not exactly tiny, but only two classes, down from what would be five and an abstract class. In that state it would be a bit larger, about 70 lines, but one would have to know a bit about five classes. Now, in use, you really only need to know about the class methods on NameSayer, because no one uses the Sayers independently, at least not yet.
Right now, in the heat of the afternoon, I think I prefer this. We’ll see what I think tomorrow or the next time I take a look. If you have an opinion, feel free to toot me up.
See you next time!
- Post Script
- Wait! One more little thing! Change this:
class Sayer:
def __init__(self, messages, indexing):
if indexing is None:
indexing = lambda index, messages: index
self.update_index = indexing
self.messages = messages
self.index = 0
def __next__(self):
if self._once_and_done():
raise StopIteration
m = self.messages[self.index]
self.index = self.update_index(self.index, self.messages)
return m
def _once_and_done(self):
return self.index >= len(self.messages)
To this:
class Sayer:
def __init__(self, messages, indexing):
if indexing is None:
indexing = lambda index, messages: index
self.update_index = indexing
self.messages = messages
self.index = -1
def __iter__(self):
return self._only_loop_once()
def _only_loop_once(self):
return self.__class__.once(self.messages)
def __next__(self):
self.index = self.update_index(self.index, self.messages)
if self._once_and_done():
raise StopIteration
return self.messages[self.index]
Just a bit shorter. Arguably makes more sense to check the index after incrementing. Green. Commit: tidying.
Every little bit helps. Every little bit is fun. Wait, you know what? I don’t think that _once_and_done is helping. Inline it. We’ll see if we prefer it that way.
def __next__(self):
self.index = self.update_index(self.index, self.messages)
if self.index >= len(self.messages):
raise StopIteration
return self.messages[self.index]
Commit. See you next time!