smirking teapot

“I’m not an expert, I’m just a dude.” -Scott Schurr, CppCon 2015.

Filling the Gaps With Visitor Pattern

Posted at — Jan 21, 2024

Design patterns can be confusing. Sometimes the implementations are similar, other times the line between their intent becomes unclear. I try to make sense of some of the closely related Object-Oriented Behavioural Patterns in this post by attempting to define a Visitor and compare it with other patterns – Strategy, Command and Type Erasure Idiom in C++(not same as void* in C or Type Erasure in Java).

Outline

Attempt #1 - (vs Strategy)

Visitor lets you decouple behaviour from the type.

Wojak: But, so does Strategy?

Teapot: Well, in a Strategy, the customisation point is added for multiple implementations of a single behaviour. For example, for traversing a graph, the exact implementation of either BreadthFirstSearch or DepthFirstSearch can be provided via a customisation point of TraversalStrategy. To add a new Strategy, the type(Graph in this case) needs to be changed.

Whereas, in a Visitor, ANY behavior can be defined on the type and the type need not be aware of it. For example, Given a Document, I can add both Renderer and PDFConverter without modifying the the Document hierarchy. In other words, a Visitor adds a customisation point for multiple(new) behaviours.

Wojak: hmm… so why not just use Visitor instead of strategy? more flexibility, right?

Teapot: The intent of both is very different. Strategy allows to decouple implementations of a behaviour which is the responsibility of the type itself while Visitor allows to decouple any new behaviour which is NOT the responsibility of the type itself while(think Single Responsibility Principle in SOLID).

Strategy allows decoupling multiple implementations of a known behaviour from a type.

Attempt #2 - (vs Command)

Visitor allows decoupling of new behaviours from a type, without modifying the type itself.

Wojak: But, doesn’t Command also lets you do that?

Teapot: yes, the difference is that a Command encapsulates a ‘request’. Meaning, it decouples the invoker of the ‘behaviour’ and the receiver of it(type on which the ‘behaviour’ is to be applied) by encapsulating the receiver in the command itself.

In other words, Command is essentially a Receiver-Behaviour pair. This enables the Command to be stored, queued, logged, batched, made undoable, sent over a network etc. Think of command as “function objects” or “capturing lambdas” in C++. This is very useful in Event-Driven Systems. Where is the customisation point you may ask? Well, it’s where the invocation of the command happens(see Invoker class), that’s where the dynamic dispatch works its magic on the polymorphic Command type.

On the other hand, in a Visitor, a polymorphic type can accept any new behavior that’s implementing the the Visitor interface. It’s often achieved via double-dispatch and this enables two points of customisation(type & behaviour) as compared to only one(behaviour) in a Command. In this sense, Visitor is a more powerful Command.

Wojak: Interesting, then why would we ever need a command if visitor is so powerful?

Teapot: Well, you gain some, you lose some. The additional customisation point for the receiver type in the Visitor also means that the receiver can’t be encapsulated with the behaviour anymore. This means you lose the storage ability of the behaviour. It can’t be used used as a callback anymore i.e. the freedom along the time axis is lost.

Teapot: In Command, behaviour encapsulates a type and in Visitor a polymorphic type accepts a behavior.

Command decouples invoker of an action and the receiver of it by encapsulating the receiver within the action itself.

Attempt #3 - (vs Type Erasure)

Visitor allows decoupling of new behaviours from a set of similar(polymorphic) types, without modifying the type itself.

Wojak: doesn’t that sound a lot like the Type Erasure idiom in C++?

Teapot: Not really. Even though Type Erasure lets you decouple behaviours from a set of types, the difference is that Type Erasure does this by trying to mimic Structural Subtyping while a Visitor sticks to the usual Nominal Subtyping.

Wojak: that just adds to the confusion. what’s Structural and Nominal subtyping now?

Teapot: In Structural Subtyping, if type A can handle all the messages as type B, i.e., both type A and type B implement the same set of methods, type A can said to be a subtype of type B, regardless of the fact whether type A inherits type B or not. C++ template functions(also Concepts) and Golang interfaces behave like this.

On the other hand, in Nominal Subtyping, for type A to be considered a subtype of type B, it mandatorily needs to inherit type B. C++ runtime polymorphism and Java behave like this.

Wojak: what difference does it makes whether a subtype inherits or not? Because at the end of the day, the goal of both is to use types polymorphically, which they achieve.

Teapot: A lot. When you inherit Crow and Swan from the Bird type with the flying behaviour in it, only to be later extended by Penguins creating Flying Penguins, it breaks the invariants of the subtype(Penguins).

Here’s another example, Square is-a Rectangle is a reasonable assumption(mathematically true btw). Given that the Liskov Substituion Principle(the ‘L’ in SOLID) mandates preserving all of the supertype invariants in its subtype, can you set the length and width of the Square to independent values like you can do it in a Rectangle? Not without breaking supertype(Rectangle) invariants.

Abstractions are hard.

Thus, removing the need for inheritance for subtyping allows any type implementing same set of methods to be used polymorphically without creating unnecessary abstractions.

Still not convinced? Maybe this talk – Inheritance Is The Base Class of Evil | Sean Parent | GoingNative 2013 will help.

Wojak: is Type Erasure in C++ same as Structural Subtyping then?

Teapot: No, it’s different. That’s because Structural Subtyping is not really supported by C++ during runtime. The pattern merely mimics its behaviour within the boundaries of Nominal Subtyping by moving the burden of inheritance from the client side(where types are being used polymorphically) to the implementation side(where the behaviour is defined). Sometimes this is also known as Runtime-Concept.

Wojak: this sounds very versatile though. what’s stopping it from world dominance?

Teapot: It’s the requirement of inheritance. Which means that it’s not composable. Say, if you have two types called ReaderConcept and WriterConcept, you can’t really compose both of them to make a ReaderWriterConcept, unlike Golang Interfaces, without resorting to multiple-inheritance, which is bad. So, to avoid this either – 1) you don’t compose and always use ReaderConcept and WriterConcept individually**(Recommended)** or, 2) modify an existing type thus violating Open-Closed Principle(the ‘O’ in SOLID).

Remember, when you add new behaviour in a Visitor, you don’t modify the polymorphic type but, you need to modify the Visitor hierarchy(also thanks to Nominal Subtyping).

Essentially, you’ll have to choose between open-types + closed-behaviours(Type Erasure) or closed-types + open-behaviours(Visitor). Seems like only one-degree of freedom is allowed out of the two axes.

Type Erasure allows unrelated types, supporting a fixed set of operations, to behave polymorphically.

Here’s a simple implementation of a Type-Erasure in C++:

#include <iostream>

class ReaderConcept {
public:
  // gives an inheritance-base for the types
  // to be used polymorphically via behaviours.
  virtual void read() = 0; // <------------- type erased read
  virtual ~ReaderConcept() = default;
};

template <typename T> class ReaderModel : ReaderConcept {
public:
  void read() override {
    // polymorphic behaviour really implemented in 'T'
    T{}.read(); // <------------ static polymorphism duck-typing
  }
};

class A {
public:
  // actual implementation
  void read() { std::cout << "A::read()" << std::endl; }
};

class B {
public:
  void read() { std::cout << "B::read()" << std::endl; }
};

int main() {

  // actual usage. note how A and B are related only via behaviour.
  ReaderModel<A>{}.read();
  ReaderModel<B>{}.read();

  return 0;
}

Attempt #4 - (and alternatives)

Visitor lets you add new(unknown) behaviors to a closed set of nominal subtypes without modifying the type.

Wojak: Visitor seems useful. How does C approach visitor like behaviour given that it is not object-oriented?

Teapot: Switch Cases. Also, a value-semantic way to implement Visitor since C++17 is the std::variant + std::visit combo.

Wojak: TL;DR?

Teapot: maybe this’ll help:

  • Use Strategy to create multiple implementations of same algorithm,
  • Use Command to encapsulate actions,
  • Use Type Erasure to enable unrelated types with a fixed set of operations behave polymorphically, and
  • Use Visitor to add new behaviours to a fixed set of polymorphic types without modifying them.

Comparison Matrix

Pattern Visitor TypeErasure Strategy Command
Visitor - V: closed set of polymorphic types and open set of behaviour V: Allow adding unknown behaviours to type V: Allows polymorphic types to accept behaviours
TypeErasure TE: open set of types, closed set of behaviours - TE: each type implements behaviour for itself. TE: Behaviour is unaware of the Type.
Strategy S: Allows multiple implementations of a known behaviour. S: Type chooses from one of the existing impl rather than implementing on its own. - S: Type semantically knows about the behaviour
Command C: Isolates invoker & receiver by creating receiver-action pair. C: Behaviour encapsulates Receiver(Type). C: Receiver(Type) is unaware about action. -

References