Day 14: Why the world is like this

The World interface I designed is already showing positive returns, insofar as it makes coming up with punny blog titles and headings much easier. Its actual implementation is making steady progress too.

Worldly design

When describing Séance’s planned architecture in the previous update, I didn’t fully elucidate on its benefits. As I mentioned, it is similar to the designs described in sled simulation guide (jepsen-proof engineering) and The plan-execute pattern. I’ve since also remembered another inspiration: a similar structure is described in Domain Modeling Made Functional. Unlike those designs, however, the central lifecycle method (receive) takes a mut World parameter. There are two reasons for this, some technical, and one philosophical.

Geoengineering

First, the technical reasons: taking the World parameter and calling act() means we can avoid requiring a dynamic memory allocation on every update. This is effectively Church encoding the list of actions, similar to an internal iterator. The most simple World implementation could contain an internal Vec that it clears between calls to receive, which would be sufficient to remove the dynamic allocation (eg. by setting an initial capacity sufficiently high). But the interface frees World to choose any number of other, more efficient designs: maybe it maintains multiple Vecs, each containing only a single action type, thus removing the need to store the enum discriminent and associated padding. Maybe any parameterless actions could be stored as counters. Unlike returning (or providing) a Vec, these designs are not the concern of the ExecutionState, allowing them to be designed independently.

Acting in the kingdom of the nouns

The philosophical reason for this design actually came to me unconsciously, and only later was I able to speak to it rationally.

While writing the initial post, there was a nagging thought in the back of my head, whispering from the corners of my psyche that still view the world through the ideology of object-oriented programming: Isn't this just an interface? Dependency inversion? World is just a God interface, in violation of the interface segregation principle. You should instead create a separate traits for each action, then dependency inject them in the constructor.

I’ve been down that road before, and it didn’t end well. You could break down any interface until it contains only one method, with a separate field for each, in a kind of sisyphean envy of dynamically typed programming languages (or maybe functional languages and closures), but what does it buy you, converting world.file_read() into self.file_reader.read_file()? The ability to change the concrete implementation of a single function dynamically, which you almost never actually need. My conspiracy theory, after trying to walk the pious object-oriented path for years, is that many of the taught best-practices are actually backwards reasoning from needing to work around the slow build times of Java and C++ projects in the 90s and 2000s. Patterns supposedly to decouple concepts actually arise from decoupling compilation units, or in Java’s case, allowing the use of XML as a de facto scripting language.

You could argue in this case that this is not a violation of the interface segregation principle because ExecutionState does actually use all exposed behaviour. I missed this initially because the common way I see following that principle end up is how it looks for method arguments, where you would seemingly need a separate interface for every subset of methods that any given implementation needs. There is a core logic here, which is trying to help someone design cohesive interfaces, but I don’t think the principle leads you there as well as other patterns of thought would.

More importantly, there is actually a benefit to having a single act(Action) method that makes it better than having a separate method for each action: it enforces that all actions have no return value. It would be easy, otherwise, to accidentally return a Result<()> or similar inline in a single method, breaking the abstraction. Furthermore, pulling from the mental architecture concept I described yesterday, having a single act() method with no return value is a simple conceptual tool. It guides how you think about building the software. The core is an isolated actor that sends out asynchronous actions, and must respond separately to inputs. In fact, I felt that this was a better reflection of my intent intuitively before I could spell out exactly why.

Early progress in world-building

Today’s code was quite small, due to time constraints, but it shows the shape that I expect later developments to follow.

impl ExecutionState {
    fn new() -> ExecutionState {
        ExecutionState {
            config: None,
            file_read_ids: FileReadIdCounter::new()
        }
    }

    fn init(&mut self, mut world: impl World) {
        world.act(Action::FileRead(self.file_read_ids.next(), AppPath::Config));
    }

    fn receive(&mut self, message: Message, mut world: impl World) {
        if let ConfigState::Pending(read_id) = self.config_state {
            // ...
        }
    }
}

enum ConfigState {
    Pending(FileReadId),
    Loaded(Config),
}

enum Message {
    FileReadComplete(FileReadId, io::Result<Vec<u8>>)
}

enum Action {
    FileRead(FileReadId, AppPath),
}

enum AppPath {
    Config
}

Briefly: Instead of having a ConfigLoad action, I am trying out having a general FileRead action. This ensures that as much logic as possible lives within ExecutionState, rather than the I/O executor. To keep track of which response goes with which request, I have added a series of auto-incrementing IDs for each. To prevent having to allocate memory for common file paths, I have added a AppPath enum, which will implement something like Into<Cow<'static, Path>>, meaning static string paths won’t need to allocate at all.

Another benefit of this design is to fully decouple the I/O executor from the code logic. This means I may be able to swap the execution library I use, replacing tokio with async-executor or even my own custom-made executor, if desired. But that will have to wait until I do my dependency-purge after the projects first stable version.