Advantages of Acyclic Visitor over Command with Switch On Type

Viewed 163

The visitor pattern is useful in situations where the element hierarchy is stable and the desired functionality for operating on those elements changes often.

In cases where the element hierarchy changes, the visitor pattern suffers from coupling that forces rebuilding all classes in both the element and functionality hierarchies.

To improve on upon this, the Acyclic Visitor uses an extra level of abstraction, with an empty Visitor interface at the top, and a specific interface for each class in the element hierarchy.

Assuming two concrete element types IntMessage and StringMessage, an acyclic visitor would look like this:

abstract class Message // parent for the model/element/data classes
{
    public abstract void Accept(Visitor visitor);
}
class IntMessage : Message  // concrete element type 1
{
    internal int data;

    public override void Accept(Visitor visitor)
    {
        // check if the concrete visitor knows how to work on IntMessage
        if (visitor is IntMessageVisitor)
            (visitor as IntMessageVisitor).Visit(this);   
    }
}
class StringMessage : Message  // concrete element type 2
{
    internal String msg;
    public override void Accept(Visitor visitor)
    {
        // check if the concrete visitor knows how to work on StringMessage
        if (visitor is StringMessageVisitor)
            (visitor as StringMessageVisitor).Visit(this);
    }
}


interface Visitor  // empty parent interface for acyclic visitor
{
}

interface IntMessageVisitor : Visitor
{
    void Visit(IntMessage message);
}
interface StringMessageVisitor : Visitor
{
    void Visit(StringMessage message);
}

A concrete visitor would inherit from all the specific visitor interfaces for the element types that it knows how to visit. The advantage of this is that in cases where new classes are added to the element hierarchy, only concrete visitors who need to visit the new element are forced to change.

class PrintVisitor : StringMessageVisitor, IntMessageVisitor
{
        public void Visit(IntMessage message)
        {
            Console.WriteLine("Int message with data = " + message.data);
        }
        public void Visit(StringMessage message)
        {
            Console.WriteLine("String message with data = " + message.msg);
        }
}

Enough setup, let's move on to the question.

The question is, given the complexity of the acyclic visitor pattern, does it have any real benefit over using a simple command with a switch-on-type?

For example, we could rewrite the PrintVisitor as the following print command:

class PrintCommand : Command
{
    public void Execute(Message message)
    {
        // switch on type
        if (message.GetType() == typeof(IntMessage))
        {
            Console.WriteLine("Int message with data = " + ((IntMessage)message).data);
        }
        else
        if (message.GetType() == typeof(StringMessage))
        {
            Console.WriteLine("String message with data = " + ((StringMessage)message).msg);
        }
    }
}

If new classes are added to the element hierarchy in the future ( for example DateMessage ), then still only the commands that want to work on the new element type will need to change. The resulting design would be much simpler, without multiple interface inheritance, and double dispatch, at the "cost" of using runtime type information instead of virtual functions.

It seems that as far as OCP and future maintenance there is no extra cost for the switch on type over the acyclic visitor.

Is there ever any reason to prefer the ACyclic Visitor over command with switch-on-type ?

2 Answers

There's no clear answer. The answer depends on your specific circumstances. Your example is relatively simple as it only involves alternatives to a single type, not alternative families of types. The typical example of a Visitor shows using different visitors to handle families of multiple different types in a tree. A good example would be visitors that render XML to HTML, PDF or word documents. Each of those visitors handles a family of elements.

In the simple example, a Visitor isn't that useful. It adds type safety at the expense of (significant) complexity.

Since C# 6 introduced patterns all this code can be reduced tremendously. In C# 9 Execute can be :

public static void Execute(Message message)
{
    var text=message switch { 
                 IntMessage intM=>$"blah {intM.data}", 
                 StringMessage stM=> $"blah {stM.msg}", 
                 _ => throw new ArgumentException($"Unknown type {message.GetType()}",
                                                  nameof(message))
    };
    Console.WriteLine(text);
}

First Generics, then dynamic and now functional features like pattern matching have reduced the need tremendously, while simplifying the code a lot. Functional languages rarely need visitors because the compilers can easily infer types and detect missed cases.

If C# had discriminated union, a feature eagerly anticipated but always postponed since C# 7, there wouldn't even be a need for the default case. The compiler itself would recognize missing cases.

In this example, changes can be localized to just this method.

In the renderer example though, the same well known set of elements has to be handled by each concrete visitor. The elements have well known structure. Is a visitor needed in this case?

Maybe, but it can be simplified a lot.

Functional features like pattern matching can easily handle multiple element types and multiple renderers. Any changes to the element structure can be restricted to the pattern matching code, forwarding renderer-specific calls to the concrete renderer visitors.

Except when it can't. In the simplest case, some kinds of renderers may require a different way of dealing with the elements. Some renderers may require visiting elements multiple times.

Or the size of the data may require different strategies when possible. When one wants to export 1M rows, the best option is to stream the rendered results instead of caching them in memory. Excel though is a ZIP package, so the results need to be collected before they can be exported. In this case, even though the structure and the renderers are the same, different data sizes require different implementations.

Bob Martin blogged about this very question, all the way back in 2005: http://butunclebob.com/ArticleS.UncleBob.VisitorVersusInstanceOf. Regarding the type-checking (non-visitor) approach, he says,

...there are some things I don't like about this. For one thing, the chain of if/else statement will grow without bound as more and more derivatives are added to the system. It takes time to execute the [type-checking] statement, and on average we will have to execute about half of them... This means that the runtime complexity is O(n), where n is the number of derivatives.

The derivatives he mentions are the "visitable" subclasses, which in the OP would be subclasses of Message. His argument boils down to performance. This does not mean an Acyclic Visitor is always preferable to plain old type-checking.

I am not trying to make the case that Acyclic Visitor should always be used. Indeed, each of the forms we have discussed could be appropriate in various situations. I use the following decision table:

Context Hierarchy Changes Visitor Hierarchy Changes Efficiency is important
if/else of instanceof no no no
Visitor no yes yes
Acyclic Visitor yes yes yes

Hiding the type-checking logic behind an abstraction as shown in the OP can address the issues around hierarchy changes, meaning the only remaining issue is performance. Acyclic Visitor requires only one type check versus many in an if/else chain.

Related