Łukasz Makuch

Łukasz Makuch

A comparison of dispatch management solutions: if, switch, object, automaton, multimethod

Conditions are hard to implement. Fortunately, nowadays we have multiple ways of telling the computer which piece of code should be used. Some of these techniques are more appealing than the others, because they reflect the way we think about a particular problem.

In this post, I'd like to talk a bit about my favorite ways of picking the proper piece of code. These are the techniques that turned out to be extremely helpful over the course of my career. I'll focus on runtime polymorphism, that is the decisions made during the execution of a program, as opposed to the compile time polymorphism, that is the decisions made by the compiler or any other tool before the program is run. Because the thing I'd mainly like to focus on is the mental model and not the details, for each dispatch management solution I'll use the language, library or framework that I think illustrates it best.

IF

IF

IFs are about picking one of two predefined paths: the one for true or the one for false.

We can define what's at the end of each path, but we can neither define more paths nor rename them.

An expression evaluated to a boolean value

The decision regarding the path to follow is made based on a Boolean value.

In an editor, it may look like this:

const a = 3;
const myBool = a > 3;
if (myBool) {
  // Executed if myBool is truthy.
} else {
  // Executed if myBool is falsy.
}

As we can see, the developer can associate some behavior with true and a different behavior with false, but they can't associate different behaviors with, let's say, 4, 2, and 5 using just one IF.

There are multiple ways to avoid IFs. Some of them are covered in previous blog posts like Programming without boolean values: To-Do App and Unnecessary IF statements.

Switch

Switch

I believe that the difference between IF and Switch is so subtle, yet important, that it deserves a separate blog post - If vs. Switch. Long story short, Switch is about matching the control value against multiple programmer-supplied values, as opposed to always matching against just true and false.

This is how it looks in an editor:

switch (state) {
  case "loading":
    // ...
    break;
  case "error":
    // ...
    break;
  case "loaded":
    // ...
    break;
}

Instead of casting state to either true or false, we match it against a set of possible options: loading, error, and loaded.

We can add and remove custom branches with ease.

A Switch may be simulated using multiple if else constructions, like this:

if (state === "loading") {
  // ...
} else if (state === "error") {
  // ...
} else if (state === "loaded") {
  // ...
}

A Switch is degraded when it can be replaced by just one IF:

switch (myValue) {
  case "a":
    // ...
    break;
  default:
    // ...
}
if (myValue === "a") {
  // ...
} else {
  // ...
}

Object-oriented dispatch

Object-oriented dispatch

Even though languages like Python or JavaScript prove that classes and interfaces are not necessary to benefit from object-oriented programming, the illustration visible above depicts a language featuring both classes and interfaces in order to explain the benefits of inversion of control. I strongly believe it's going to make the example easier to understand, as the concepts of interfaces and concrete implementations always exist in our heads, regardless of whether our language of choice requires their explicit declaration.

An interface is a type of contract the user of a black-box object may relay on in order to know what are the available methods and what are they supposed to do.

One interface may have multiple implementations.

The caller, that is the client of the object, calls a method through the interface. It doesn't really know what exact implementation is going to handle the method call. It relays on the contract that such a method is going to do such a thing.

What's going to happen (assuming that it's a language with dynamic dispatch), is that the method call is going to be redirected to a concrete implementation hidden behind this interface. The caller will get the result of calling the concrete method. What's very important is that in order to benefit from this indirection the client is allowed to relay on nothing but the interface. It must not make any assumptions about the concrete implementation that's going to handle the method calls. Otherwise, it becomes tightly coupled to the callee.

This indirection is so powerful, because it allows to easily write code encapsulating high-level ideas that is not dependent on details. The place where a concrete implementation is defined may be completely separated from the place where it's used through an interface.

For example, a service may need to use a cache. Instead of using a particular implementation like this:

class MyService {
  private cache;
  constructor() {
    this.cache = new RedisCache();
  }
  // ...
}

It may use it through an interface:

class MyService {
  constructor(private cache: MyCacheInterface) { }
  // ...
}

That way MyService doesn't depend on any particular implementation of the cache. It's less likely to change when the cache implementation changes. It may be reused, even with a totally different cache implementation. It's easier to unit test. It's simpler.

Depending on the programming language, objects may be simulated with first-class functions or pointers.

Automata-based dispatch

Automata-based dispatch

Automata-based dispatch is great at addressing cases where the concrete implementation we want to use changes during the program execution.

When compared to object-oriented programming, the biggest difference is that instead of always dispatching methods to the same concrete class, it allows to jump from one set of concrete implementations to another.

Different sets of implementations are connected by named arrows. They make a state machine.

For instance, in the illustration visible above, A returns both A's result and an arrow called x. Following this arrow changes the current state of the state machine from A to B, so the next time there's an action to handle, it's B that's going to handle it, not A.

Let's take a look at an example of a model of a cursed prince. Below we can see that as soon as he eats a pizza, he turns into a frog.

[
  {type: 'INTRODUCE_YOURSELF'}, // 'I am The Prince of Rosmaro!'
  {type: 'EAT', dish: 'yakisoba'}, // undefined
  {type: 'INTRODUCE_YOURSELF'}, // 'I am The Prince of Rosmaro!'
  {type: 'EAT', dish: 'pizza'}, // undefined
  {type: 'INTRODUCE_YOURSELF'} // 'Ribbit! Ribbit!'
].forEach(action => console.log(
  ({state} = model({state, action})).result.data
));

The high level part of the model, that is the state machine, looks like this when opened in the editor:

Automata-based programming editor

The concrete implementations, which are the details, are coded:

const Prince = handler({
  INTRODUCE_YOURSELF: () => "I am The Prince of Rosmaro!",
  EAT: ({action}) => action.dish === 'pizza'
    ? { arrow: 'ate a pizza' } 
    : undefined
});
const Frog = handler({
  INTRODUCE_YOURSELF: () => "Ribbit! Ribbit!",
});

As we can see, the current behavior is altered by following an arrow. Automata-based programming enables thinking in terms of possible states and their behaviors as opposed to toggling Boolean flags. In this example, there's an IF, so is there a Boolean value. The thing is that this Boolean is calculated solely based on the input value, without referring to any historical value set in the past. This plays an important role in writing decoupled code, because it makes the code independent from the past.

The very idea of automata-based programming is nothing new. Personally, as someone coming from the object-oriented background, I first stumbled upon this paradigm when reading about the State design pattern in the "Design Patterns: Elements of Reusable Object-Oriented Software" book. This object-oriented pattern may be summed up as a way to change an object's class on the fly, so that conditional behavior may be modeled in a cohesive manner without IFs and Switches being scattered all around the codebase just to check the value of previously set member variables.

Multiple dispatch

Single dispatch and multiple dispatch

So far, we've been talking about deciding how X does Y. The challenge was to select the proper Y depending on X. But what about X doing Y to Z? What if we can tell what's the correct Y only after considering the state or type of both X and Z? This time it's not just one argument that needs to be taken into account, but two. We call function dispatch based on multiple arguments multiple dispatch.

In order to illustrate the problem multimethods solve I decided to use Python due to its explicitly passed self argument. That way we can clearly see that self (or this in other languages) is nothing but a method argument. What the language does for us is automatically selecting the method implementation based on the class of self, so that we don't need to do it manually.

class Dog:
  def __init__(self, name):
      self.name = name

  def scareOff(self, anotherCreature):
    if isinstance(anotherCreature, Dog):
      print("Grrr!")
    elif isinstance(anotherCreature, Human):
      print("Woof! Woof!")

class Human:
  def __init__(self, name):
      self.name = name

  def scareOff(self, anotherCreature):
    if isinstance(anotherCreature, Dog):
      print(f"GO AWAY {anotherCreature.name}!")
    elif isinstance(anotherCreature, Human):
      print(f"I'm {self.name} and I'd like to tell you a programming joke!")

rex = Dog("Rex")
rocky = Dog("Rocky")
john = Human("John")
anna = Human("Anna")

rex.scareOff(rocky) # "Grrr!"
rex.scareOff(john) # "Woof! Woof!"
john.scareOff(rex) # "GO AWAY Rex!"
john.scareOff(anna) # "I'm John and I'd like to tell you a programming joke!"

As we can see, the scareOff implementation is selected based upon the class of the object. So it's either Dog's scareOff or Human's scareOff. Vanilla Python doesn't give us a way to select the proper method implementation based on any other argument than self and that's why there are IFs checking the class of the second argument.

Below is the same example implemented in Clojure. It makes use of multimethods.

; A multimethod selecting the proper implementation
; based on the species of the creatures.
(defmulti scare-off (fn [self another-creature]
                      [(:species self) (:species another-creature)]))

; When a Dog wants to scare off another Dog.
(defmethod scare-off [:Dog :Dog]
  [self another-creature]
  "Grrr!")

; When a Dog wants to scare off a Human.
(defmethod scare-off [:Dog :Human]
  [self another-creature]
  "Woof! Woof!")

; When a Human wants to scare off a Dog.
(defmethod scare-off [:Human :Dog]
  [self another-creature]
  (str "GO AWAY " (:name another-creature) "!"))

; When a Human wants to scare off another Human.
(defmethod scare-off [:Human :Human]
  [self another-creature]
  (str "I'm " (:name self) " and I'd like to tell you a programming joke!"))

; Defining Rex, Rocky, John, and Anna.
(def rex {:species :Dog :name "Rex"})
(def rocky {:species :Dog :name "Rocky"})
(def john {:species :Human :name "John"})
(def anna {:species :Human :name "Anna"})

; Function calls.
(scare-off rex rocky) ; "Grrr!"
(scare-off rex john) ; "Woof! Woof!"
(scare-off john rex) ; "GO AWAY Rex!"
(scare-off john anna) ; "I'm John and I'd like to tell you a programming joke!"

This piece of code consists of two important parts crucial to understand multimethods. The first one is the dispatching function. It takes function arguments and returns a dispatching value. The proper method is picked based on this value. In this case, the dispatching value is [(:species self) (:species another-creature)]. The second important part is where a concrete function is associated with a dispatching value. For example, when a Dog wants to scare off a Human, it does it like this: (defmethod scare-off [:Dog :Human] [self another-creature] "Woof! Woof!"). scare-off is the function name, [:Dog :Human] is the dispatching value, [self another-creature] are the function arguments and "Woof! Woof!" is the body of the function.

There are multiple ways to implement multimethods in languages that don't support them out of the box. One of the most popular ones (for double dispatch, that is multiple dispatch based on just two arguments) is the Visitor design pattern. Another way is to use a library that encapsulates all the manual argument inspections. If you're interested in this topic, please take a look at the Multiple dispatch in Java post.

Multiple dispatch may seem exotic, but in fact, it's quite popular. We can find it with ease even in PHP, where it's used to build things like message buses. For example, the EventDispatcher Symfony component allows to define what a polymorphic dispatch method should do when called with a particular dispatcher and a particular event.

Summary

The described techniques differ in many ways. While some are about very simple choices, like yes or no (IF), some allow to define multiple paths (Switch). Both IFs and Switches require manual inspection of the arguments every time a function is called, while the other described constructions pick the proper implementation under the hood once they are declared (an object instance in Java, a complete Rosmaro model with bindings in JavaScript or a multimethod with methods installed via defmethod). An object doesn't change its class during the execution of a program, while a Rosmaro model is all about changing the implementation after consuming an action. Sometimes the concrete implementation is picked based on only one argument (objects in Java), other times multiple values are taken into account (multimethods in Clojure).

A real life application will usually make use of multiple techniques. For example, a web application may use double dispatch to route events to event handlers. Event handlers may be objects using other objects through their interfaces. Within these objects there may be Switch statements handling a finite set of states (automata-based programming is sometimes called "Switch technology"). While some mechanisms are implemented in the language, other paradigms are supported through libraries. After all, I believe that the decision how to select the piece of code to run shouldn't be based solely on the mechanisms natively supported by our language of choice. Instead, we should strive for our implementation to reflect the problem model we have in our heads.

Thank you for reading this post.