`None` of the Above
I’m not up for a lot today. Let’s review those methods that can optionally return
Noneand see what we can learn, maybe what we can do. Results: acceptable if not entirely satisfactory.
A key moment in the debugging thrash of this week was a None being returned where a Room was expected, with concomitant exceptions being raised, because, well, None really doesn’t understand much about being a Room. Mind you, returning None was not the bug, but it was caused by the bug, which was so subtle that I have to write paragraphs to explain it and still haven’t seen a practice improvement that would have prevented it.
Nonetheless, there are some other cases of returning None in the code, and whenever we do that, there’s the near certainty that we’ll raise an exception soon, as soon as someone (YT) forgets to check for the case. Even if the fool remembered to check, it’s still messy having to check all the time.
Yesterday I listed these ideas around the topic of doing better with None:
- Use the
addrather thanreturnidea, and just don’t add if you can’t; - Use type hinting to get better advice from the IDE and compiler;
-
Perhaps there is some way to use Python's `with` statement to advantage; - Make more broad use of
try-except; - Make use of Missing Object pattern, if we can remember it.
- Ask permission before doing something;
Commentary:
-
This refers to putting the decision deeper in the calling sequence, right where we first discover that we can’t do our job. If we know what the job is … we can just not do it. Perhaps we’ll come up with an example.
-
I’ve seen PyCharm make some good use of type hinting, where it might warn you that what you’re calling requires an X and you might have a
None. It’s probably worth doing, although I find that I tend to wind up with recursive imports a lot. Probably time for me to learn how to avoid that. -
I’ve looked at
withand it’s not really suitable for what I had in mind. Belay that. -
I really don’t like the
try-exceptpattern as it tends to proliferate, so as a matter of personal taste we’ll try to avoid this one. -
I’ll want to think more about this, as injecting MissingObject is trickier than it looks and in y experience tends to proliferate. We might do an experiment sometime. We might not
-
We explore this one right below.
Let’s get started
Here’s one in CellSpace:
class CellSpace:
def random_available_cell(self):
available = [cell for cell in self.cells if cell.is_available]
if available:
return random.choice(available)
else:
return None
The random_available_cell can only return None if the entire dungeon layout is full, has used every Cell in the entire rectangle. This is extremely unlikely. But let’s imagine that we really need dungeon creation to be free of exceptions. There is only one direct user of this, in Cell:
class Cell:
@classmethod
def random_available_cell(cls):
return cls.space.random_available_cell()
And there are quite a few users of the class method, for example:
main.py
def make_a_round_room(dungeon: DungeonLayout, maker: RoomMaker):
origin = Cell.random_available_cell()
rad = random.randint(5, 6)
room = maker.round(radius=rad, origin=origin)
dungeon.add_room(room)
class RoomMaker:
def round(self, *, radius: int, origin: Cell, name='round'):
cells = RoundCellCollector().build(radius=radius, origin=origin)
return Room(cells, name)
If we get no Cell back, we’ll crash somewhere under round.
OK, this requires more thinking than I had supposed. Presumably the contract with round is that given a cell, it will return a Room. What concerns me is that in make_a_round_room origin is not necessarily a Cell, PyCharm knows it, and is not warning me about it.
We could code like this, which is what I was thinking about in number 6 above:
main.py
def make_a_round_room(dungeon: DungeonLayout, maker: RoomMaker):
if origin := Cell.random_available_cell():
rad = random.randint(5, 6)
room = maker.round(radius=rad, origin=origin)
dungeon.add_room(room)
| This isn’t entirely unreasonable, but I’d much prefer getting a warning of some kind from PyCharm, for passing a possible Any (really Cell | None) to origin in maker.round. PyCharm knows that origin is Any, but does not mention it. So far, I’ve not managed a combination of hints that produce any interesting warnings. Even running the code inspection thingie doesn’t warn me about the possibility of passing a None into that method that explicitly says it requires a Cell. |
Commentary:
I am comfortable with duck-typing languages, and generally prefer them. However, a gradual typing language, which I have never really used, has some appeal, because we could optionally become more strict about typing. And in the present case, at least a warning would have been nice, but no matter how many hints I put in, PyCharm seems not to notice the discrepancy.
We could add assertions, but those will just raise exceptions, which we cannot abide.
We could give random_available_cell a more dangerous-sounding name, which would remind people (YT) to use the if construction or something similar. We might be able to produce a Room with no cells, but it seems to me that that just leads to more madness.
I suppose we could make the CellSpace potentially infinite, so that it can always find a new empty cell. I suppose we could make running out of available cells a fatal error but then we crash.
The fact is, this is really very very unlikely to happen, because a programmer building a layout is very unlikely to accidentally build so many rooms as to fill the space.
I am inclined to put in the if trick above and then ignore the issue, but let’s try to think of a name that will remind us to do that.
How about random_available_cell_or_none? random_unused_cell_if_any? It’s a method on Cell, so let’s try this:
class Cell:
@classmethod
def unused_cell_or_none(cls):
return cls.space.random_available_cell()
That gives use calls like this one:
def make_a_cave_room(dungeon: DungeonLayout, maker: RoomMaker):
origin = Cell.unused_cell_or_none()
size = random.randint(50, 70)
room = maker.cave(number_of_cells=size, origin=origin)
dungeon.add_room(room)
And that surely invites our soon-to-be-standard style:
def make_a_cave_room(dungeon: DungeonLayout, maker: RoomMaker):
if origin := Cell.unused_cell_or_none():
size = random.randint(50, 70)
room = maker.cave(number_of_cells=size, origin=origin)
dungeon.add_room(room)
I make similar changes throughout. And since I already said I wanted a light day, let’s sum up.
Summary
For this case, a danger-signalling name seems like a decent idea for now, since it will serve as a reminder when we use this method. I don’t love it, but I love it better than a crash, and I don’t hate it as much as a try-except everywhere, which would really require a warning name anyway, or I’d just forget the try-except. I suppose what we’d really do if we raised exceptions is something like this:
def make_a_cave_room(dungeon: DungeonLayout, maker: RoomMaker):
try:
origin = Cell.unused_cell_or_none()
else:
size = random.randint(50, 70)
room = maker.cave(number_of_cells=size, origin=origin)
dungeon.add_room(room)
Frankly that looks weird to me. If they had called else: something like when_ok: I might like it better, Anyway, no.
As a wild idea, just to finish on something weird, what about:
class Cell:
@class_method
def with_unused_cell(aFunction, default):
if (self.space.random_available_cell()):
return aFunction(cell)
else:
return default
Meh. In a language with better blocks of lambdas, maybe. In Python probably not so much.
There might be an idea lurking in there. Possibly. Perhaps not.
Anyway, we made a very slight improvement today, but an improvement it was, and the new name, odd as it is, will surely help us remember to expect a None back from that method.
Acceptable, I think, if not entirely satisfactory. See you next time!