Today I think we’ll see about using Flood erdirectly, perhaps removing the convenience method on Cell, but adding some to Flooder.

By taking this approach, we need to be aware that we are making a pretty firm commitment to Flood. Before we remove the flood method on Cell, all our tests and code will have to be using Flood directly. In a large application, we might never do this, and certainly if we had exported a Cell library to the universe, we’d have to preserve Cell.flood indefinitely, perhaps forever.

Here chez Ron we have a different kind of purpose. We make changes to our code all the time, because as brother Hill puts it, we are in the business of changing code. Here, we change code and see what happens, what we learn from the various ways we change and change again. And—let me be clear about this—I enjoy smushing the code around to see what happens, to see which moves I like and which ones I do not. I’m here for the fun, and with a hope to be entertaining if not useful.

PLanning Some Improvements

Our two hours of experience with Flooder, and the few days with Cell.flood, leave me with a very distinct sensation that goes like this:

The function parameter next_cell(cell, value) seems likely to be used less often than the function parameter select. But select is quite commonly used, with two very common selectors, in_a_room and =is_available. We also see one complex select, which selects cells that are either available or occur in a list of rooms. This latter one is used to permit a path to be generated between cells in a room and otherwise available. It is only used with two rooms, but I believe the code allows for any number of rooms, in a list.

So as things stand, most of our calls to Cell.flood are required to pass in one of the same two lambda expressions, which is inconvenient, causes duplication, and is error-prone.

So after we have converted our existing users of Cell.flood to use Flooder class, I think we’ll add a couple of convenience methods. That will happen very soon in article-reading time, I think, because I expect not to include much detail about the conversion, except for a single example, unless something goes interestingly wrong.

A Typical Conversion

I picked this because it was the first one found outside Cell itself:

class RoundCellCollector:
    def build(self, *, radius: int, origin: Cell):
        cells: list[Cell] = []
        enough = radius * 1.5 # 1.4 seems to work but tight?
        for c, _ in origin.flood(select=lambda c: c.is_available):
            if (d := c.distance(origin)) < radius:
                cells.append(c)
                c.room = self
            if d > enough:
                return cells
        return cells

Curiously, I think this could be done with a more complex select, which kind of argues against my plan but for now we’ll just convert it. This will have to be done by hand, but I think it’s pretty much rote. I’ll try it, to find out how rote it is. I’ll cut myself a break, though, because rote is about doing something many times and I’ve not done this very many times yet.

class RoundCellCollector:
    def build(self, *, radius: int, origin: Cell):
        cells: list[Cell] = []
        enough = radius * 1.5 # 1.4 seems to work but tight?
        flooder = Flooder(origin).select(lambda c: c.is_available)
        for c, _ in flooder.flood():
            if (d := c.distance(origin)) < radius:
                cells.append(c)
                c.room = self
            if d > enough:
                return cells
        return cells

That runs green. Commit: converting to use Flooder

In a small change of plans, I propose to do the convenience method right now, because the opportunity is here and we can start getting a feel for whether it’s useful enough.

We want a new method on Flooder, available(), like this:

class Flooder:
    def available(self):
        self.select(lambda cell: cell.is_available)
        return self

And used like this:

class RoundCellCollector:
    def build(self, *, radius: int, origin: Cell):
        cells: list[Cell] = []
        enough = radius * 1.5 # 1.4 seems to work but tight?
        flooder = Flooder(origin).available()
        for c, _ in flooder.flood():
            if (d := c.distance(origin)) < radius:
                cells.append(c)
                c.room = self
            if d > enough:
                return cells
        return cells

We could inline flooder there. Let’s see if we like it:

class RoundCellCollector:
    def build(self, *, radius: int, origin: Cell):
        cells: list[Cell] = []
        enough = radius * 1.5 # 1.4 seems to work but tight?
        for c, _ in Flooder(origin).available().flood():
            if (d := c.distance(origin)) < radius:
                cells.append(c)
                c.room = self
            if d > enough:
                return cells
        return cells

I like it. Depending on how wide the text window in the article is, you might not. Either form will work, of course.

Let’s do one more, in the spirit of “rote”:

class DiamondCellCollector:
    def build(self, *, number_of_cells: int, origin: Cell):
        cells:list[Cell] = []
        count = 0
        for cell, _ in origin.flood(select=lambda c: c.is_available):
            cells.append(cell)
            cell.room = self
            count += 1
            if count == number_of_cells:
                break
        return cells

This one is in fact pretty rote:

class DiamondCellCollector:
    def build(self, *, number_of_cells: int, origin: Cell):
        cells:list[Cell] = []
        count = 0
        for cell, _ in Flooder(origin).available().flood():
            cells.append(cell)
            cell.room = self
            count += 1
            if count == number_of_cells:
                break
        return cells

Green. Commit.

I’ll do the rest. If anything interesting happens, I’ll mention it.

After not much effort, I think I’ve got them all. I’ll check that by removing Cell.flood.

I missed a couple of tests and the path_map method in Cell, somehow. The changes are similar to what we’ve done above. All definitely “rote”, no surprises.

Commit: remove Cell.flood

One More Thing …

We have a Cell method flood_in_rooms. It uses Flooder but it makes little sense to leave it here, given that we’re going to have everyone use Flooder directly. The changes are straightforward. Commit: remove Cell.flood_in_room

No, Really Just One More Thing …

I think I’ve noticed something interesting about the use of next_value. There are five, only one of which is not a test. They all apply the same function, either as lambda of long-hand function:

def distance_to_neighbor(neighbor, d):
    return d + 1

The only use of our next_value function so far is to provide the distance between cells as we flood. Most uses of the Flooder do not even look at the flood() result’s second component. (Of course, the ones that want distance do look at it.)

This suggests a couple of ideas:

  1. Maybe flood should always return the distance of the returned cell from the origin cell, since that’s all anyone ever wants, removing the next_value capability;
  2. Maybe flood should default to that distance but continue to allow other functions to be passed in;

Given that we have it, and given that I still think there may be a use for it in creating the path map, I am reluctant to remove the ability to change the function. But having it default to distance would remove all our uses of the next_value except for one test of an alternative function. Since we are defaulting the select to select all cells, the existence of a default function with an override capability doesn’t add a lot of complexity.

I think what we’ll do is set the default, which should be easy:

class Flooder:
    def __init__(self, origin):
        self._delivered = None
        self._to_be_delivered = None
        self._origin = origin
        self._select = lambda cell: True
        self._next_value = lambda cell, value: 0
        self._initial_value = 0
        self._randomness = 0.0

With this change:

class Flooder:
    def __init__(self, origin):
        self._delivered = None
        self._to_be_delivered = None
        self._origin = origin
        self._select = lambda cell: True
        self._next_value = lambda cell, value: value + 1
        self._initial_value = 0
        self._randomness = 0.0

One test breaks, presumably one checking the default value for 0:

    def test_cell_function_default(self):
        Cell.create_space(10, 10)
        cells = [Cell.at(x,0) for x in range(10)]
        room = Room(cells)
        result = dict()
        cond = lambda cell: cell.is_in_a_room
        for cell, value in (Flooder(Cell.at(0, 0))
                .in_a_room()
                .flood()):
            result[cell.xy] = value
        assert len(result) == 10
        assert all(value==0 for value in result.values())

I think we can change that easily:

    def test_cell_function_default(self):
        Cell.create_space(10, 10)
        cells = [Cell.at(x,0) for x in range(10)]
        room = Room(cells)
        result = dict()
        cond = lambda cell: cell.is_in_a_room
        origin = Cell.at(0, 0)
        for cell, value in (Flooder(origin)
                .in_a_room()
                .flood()):
            result[cell] = value
        assert len(result) == 10
        assert all(value==origin.manhattan_distance(c)
                   for c,value in result.items())

Right. Commit: flood next_value defaults to number of steps through allowed calls from origin (d + 1). Needs better explanation in the manual if we have one.

Also perhaps a more interesting test would be helpful, one that results in a distance result that is NOT just the manhattan distance. (Think of a path that curves around and comes close to origin.) We’ll leave that for another day. I’ve made a note to do it, it might be kind of fun.

For now, let’s sum up:

Summary

We have made Flooder the only way to call for a flood search of the space. It is serving all our current needs and removes some complex code from Cell and moves it into Flooder, resulting in two somewhat simpler classes instead of one more complex one. We have convenience methods for common cases, removing the need for users to generate lambdas or selection functions for those cases, while allowing more complex selection if needed.

We have defaulted the next value function to the most common case, distance along the shortest available path between the generated points and the origin point. (There’s a start at the simpler definition for the manual.) More complex next value functions are available if needed, though so far we aren’t really using that ability. Some existing uses can be simplified, though that work remains to be done.

The syntax for Flooder is fluent, which is not something we’ve done much of, so it looks a bit different from most of our code, but the idea may well gain in popularity as we work with it. The actual syntactic complexity is no worse, and possibly a bit simpler than the former flood method with its keyword arguments and lambda expressions.

One possibility that comes to mind would be to break out the creation parameter, the flood origin as another fluent method, changing this:

for cell, value in (Flooder(origin)
        .in_a_room()
        .flood()):

To this:

for cell, value in (Flooder()
        .origin(origin)
        .in_a_room()
        .flood()):

I think that in_a_room should be in_any_room. Agreed? Yes, thanks. Change that. Done, green, committed. So now it’s

for cell, value in (Flooder(origin)
        .in_any_room()
        .flood()):

That’s rather nice and offers the possibility of in_these_rooms(list(Room)) in the future, if we ever want such a thing.

Bottom Line

We did what we set out to do. I don’t hate the result. It seems clearly better than it was, and we have simplified Cell a bit along the way. I am not in love with the result either, but we’ll see how we feel moving forward.

As for moving forward, I’m not sure what’s next. I’ll check the note cards: there are a few ideas there. See you next time!