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.