DEV Community

Cover image for Visitor Pattern in C#: acyclic, type-safe, no downcasts, no runtime checks with Visitor.NET
StepOne
StepOne

Posted on

Visitor Pattern in C#: acyclic, type-safe, no downcasts, no runtime checks with Visitor.NET

Visitor becomes painful when a design gains type checks, downcasts, and boilerplate faster than it gains useful behavior. Those costs are especially visible in compilers, static analyzers, and other systems that repeatedly traverse object hierarchies.

This article starts with the classic and acyclic Visitor variants, shows where their dispatch mechanics break down in C#, and then derives a type-safe alternative from covariance and contravariance. The result became Visitor.NET, a library I use in HydraScript for static analysis and intermediate-representation generation.

When to Use the Visitor Pattern in C

Where is this pattern useful?

  • The most obvious application is compiler construction, because Visitor is one of the best ways to process an Abstract Syntax Tree.
  • The Expressions and Roslyn APIs also require direct processing of syntax object models.
  • The Composite pattern represents a recursive tree-shaped data structure.

DSL development deserves a separate mention because it gives me an excuse to point to an interesting talk about the practical use of DSLs in messy enterprise systems.

I first encountered them at university. My department, IU-9 at Bauman Moscow State Technical University, specializes in compiler construction. That determined my thesis topic: creating an interpreted programming language.

I parsed source code into an AST, traversed the tree, and generated an instruction list for a virtual machine—roughly the following transformation:

ast example

In this domain, an AST is as fundamental as JSON is in commercial software development. Many core tasks revolve around it:

  • Initializing scopes and symbol tables
  • Static analysis
  • Preliminary optimization
  • Code generation
  • And much more

That makes convenient, efficient processing of these data structures important.

Putting Operations on the Element Types

This is why Visitor was created. The flawed intrusive approach extends a recursive data structure through inheritance and subtype polymorphism:

public abstract class AbstractSyntaxTreeNode
{
    public virtual List<Instruction> ToInstructions(int start) => new();
}
Enter fullscreen mode Exit fullscreen mode

This shortsighted design leads to bugs that are difficult to fix and debug. It also limits the system's evolution: implementing new features becomes exponentially harder because component responsibilities are distributed incorrectly.

In my case, intrusive code generation assigned incorrect addresses to instructions, causing the virtual machine either to enter an infinite loop or throw a runtime exception:

three address code example

The code that generated some instructions before Visitor shows just how bad it was:

public override List<Instruction> ToInstructions(int start, string temp)
{
    var instructions = new List<Instruction>();
    (IValue left, IValue right) right = (null, null);
    if (_expression.Primary())
    {
        right.right = ((PrimaryExpression)_expression).ToValue();
    }
    else
    {
        instructions.AddRange(_expression.ToInstructions(start, temp));
        if (_expression is MemberExpression member && member.Any())
        {
            var i = start + instructions.Count;
            var dest = "_t" + i;
            var src = instructions.Any()
                ? instructions.OfType<Simple>().Last().Left
                : member.Id;
            var instruction = member.AccessChain.Tail switch
            {
                DotAccess dot => new Simple(dest, (new Name(src), new Constant(dot.Id, dot.Id)), ".", i),
                IndexAccess index => new Simple(dest, (new Name(src), index.Expression.ToValue()), "[]", i),
                _ => throw new NotImplementedException()
            };
            instructions.Add(instruction);
        }
        right.right = new Name(instructions.OfType<Simple>().Last().Left);
    }
    var number = instructions.Any() ? instructions.Last().Number + 1 : start;
    instructions.Add(new Simple(
        temp + number, right, _operator, number
    ));
    return instructions;
}
Enter fullscreen mode Exit fullscreen mode

The next snippet shows how much better it became after applying the pattern. The code reads almost like plain English:

public AddressedInstructions Visit(UnaryExpression visitable)
{
    if (visitable.Expression is PrimaryExpression primary)
        return [new Simple(visitable.Operator, _valueDtoConverter.Convert(primary.ToValueDto()))];
    var result = visitable.Expression.Accept(This);
    var last = new Name(result.OfType<Simple>().Last().Left!);
    result.Add(new Simple(visitable.Operator, last));
    return result;
}
Enter fullscreen mode Exit fullscreen mode

What is the magic behind the new architecture, and why does it work? Let us return to the beginning and refresh our memory.

Classic Visitor and Double Dispatch

Pictures make the idea easier to illustrate because we can place visitable elements opposite their visitors. Suppose we have an element hierarchy that needs to be processed. Each member of the hierarchy gets a virtual processing method. That method accepts a processor with one overloaded method for every element type:

classic visitor example

The pattern's key properties are:

  • It solves the intrusive-approach problem by separating operations from data through the Visitor class.
  • It uses double dispatch: subtype polymorphism plus method overloading, or ad-hoc polymorphism. First, the correct override is chosen on the abstract element; then the concrete implementation selects the concrete overload.
  • Perhaps less obviously, a visitor can return values. Nothing prevents you from replacing void with string, for example.

Drawbacks of the Classic Visitor Pattern

We immediately introduce a cyclic dependency: Visitor knows everything about the Element subtype hierarchy.

classic visitor drawback example

As a result, we constantly need access to the source of both hierarchies: any change to the element hierarchy forces changes to the visitor hierarchy. Why do I say visitor hierarchy when the diagram shows one Visitor class?

abstract class Element
{
    abstract void Accept(IVisitor visitor);
}

interface IVisitor
{
    void Visit(ElementA elementA);

    void Visit(ElementB elementB);
}

class VisitorOne : IVisitor
{
    public void Visit(ElementA elementA)
    {
    }

    public void Visit(ElementB elementB)
    {
    }
}

class VisitorTwo : IVisitor
{
    public void Visit(ElementA elementA)
    {
    }

    public void Visit(ElementB elementB)
    {
    }
}
Enter fullscreen mode Exit fullscreen mode

The Acyclic Visitor Pattern

The acyclic version removes the cyclic dependency of the classic implementation. It introduces two visitor abstractions that form an architectural boundary between contract and implementation. One is used on the receiving side; the other assembles a custom visitor. This declarative approach uses the type system to describe exactly which elements a visitor can handle—like assembling a visitor from LEGO bricks.

Visitor.NET architectural boundary

This is much better. The element hierarchy knows nothing about processor implementations, while concrete processors select only the elements they need. But we have introduced another problem.

public class ElementA : Element
{
    public override void Accept(IVisitor visitor)
    {
        if (visitor is IVisitor<ElementA> typed)
            typed.Visit(this);
    }
}
Enter fullscreen mode Exit fullscreen mode

We have lost compile-time type safety and moved type checking to runtime.

The Downcasting Problem in Acyclic Visitor

When we ignore subtype polymorphism, we lose its main benefit: not having to think about an object's concrete type. Explicit casts often signal poor architecture—either insufficiently object-oriented or internally contradictory. They also provide an opening for elusive bugs that appear only when the application runs.

When should you consider downcasting?

  • You know the object's concrete runtime type with 100% certainty (no).
  • That knowledge gives you access to capabilities unavailable at compile time (partly).
  • Casting is dramatically faster and easier than refactoring the code to remove the explicit cast (no).

There is another problem: systems like this are difficult to unit-test. Suppose cross-cutting behavior is added to an element's Accept method. In this configuration, the mock setup simply will not compile:

compile-time error

The element processor cannot be converted to IVisitor, while IVisitor itself is a marker interface with no members:

unresolvable symbol error

That leaves us in a painful position.

Why Type Checks Undermine the Visitor Pattern

Why does everything break as soon as we step beyond textbook examples and generalize the existing approach? Let us fix it. The requirements are:

  • Preserve all advantages of the acyclic version: flexibility, selectivity, declarative design, OCP and LSP compliance, and so on.
  • Keep all type checks at compile time and use static typing to its full advantage.

Surprisingly, C# generic variance helps us solve the problem.

Contravariance

Contravariance lets us substitute a more general type for a generic type parameter used in function arguments.

interface IFooIn<in TInputType>
{
    void Bar(TInputType input);
}
Enter fullscreen mode Exit fullscreen mode

The standard library's IComparable is one example. Contravariance allows this:

IComparable<IEnumerable<char>> charEnumerableComparable = // ...;
// ...
IComparable<string> stringComparable = charEnumerableComparable;
Enter fullscreen mode Exit fullscreen mode

Covariance

Covariance lets us substitute a more derived type for a generic type parameter used in function return values.

interface IFooOut<out TOutputType>
{
    TOutputType Baz();
}
Enter fullscreen mode Exit fullscreen mode

The standard library's IEnumerable is one example. Covariance allows this:

IEnumerable<Task<object>> tasksWithResults = // ...;
// ...
IEnumerable<Task> tasks = tasksWithResults;
Enter fullscreen mode Exit fullscreen mode

Why Is This Useful?

First, covariance and contravariance can be combined within one type:

interface IFooInOut<in TInputType, out TOutputType>
{
    TOutputType Foo(TInputType input);
}
Enter fullscreen mode Exit fullscreen mode

Second, variance conversions like the standard-library examples are fast: they generate no additional IL instructions.

What kinds of type conversion do we have?

  • Direct: both expressions have the same type.
  • Implicit: one expression's type is equivalent to the other's.
  • Explicit: the (Type) cast operator; IL contains a castclass instruction.
  • Safe (as): a failed conversion returns null instead of throwing; IL contains an isinst instruction.
  • Dynamic: the dynamic keyword defers type determination until runtime, moving resolution to the IL/runtime layer.

I wondered how each scenario performed, so I wrote a benchmark. The code is below.

Benchmark code

using System.Diagnostics.CodeAnalysis;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<CastingBenchmarks>();

[SuppressMessage("ReSharper", "ReturnValueOfPureMethodIsNotUsed")]
[SuppressMessage("Performance", "CA1822:Пометьте члены как статические")]
#pragma warning disable CA1050
public class CastingBenchmarks
#pragma warning restore CA1050
{
    private static readonly ICovariant<Task<object>> SpecificCovariant = new Covariant<Task<object>>();
    private static readonly ICovariant<Task> GeneralCovariant = SpecificCovariant;
    private static readonly IContravariant<IEnumerable<char>> GeneralContravariant = new Contravariant<IEnumerable<char>>();
    private static readonly IContravariant<string> SpecificContravariant = GeneralContravariant;

    [Benchmark(Baseline = true)]
    public void Direct()
    {
        SpecificCovariantMethod(SpecificCovariant);
        GeneralContravariantMethod(GeneralContravariant);
    }

    [Benchmark]
    public void Implicit()
    {
        GeneralCovariantMethod(SpecificCovariant);
        SpecificContravariantMethod(GeneralContravariant);
    }

    [Benchmark]
    public void Explicit()
    {
        SpecificCovariantMethod((ICovariant<Task<object>>)GeneralCovariant);
        GeneralContravariantMethod((IContravariant<IEnumerable<char>>)SpecificContravariant);
    }

    [Benchmark]
    public void As()
    {
        SpecificCovariantMethod((GeneralCovariant as ICovariant<Task<object>>)!);
        GeneralContravariantMethod((SpecificContravariant as IContravariant<IEnumerable<char>>)!);
    }

    [Benchmark]
    public void Dynamic()
    {
        SpecificCovariantMethod((dynamic)GeneralCovariant);
        GeneralContravariantMethod((dynamic)SpecificContravariant);
    }
    // ReSharper disable once UnusedTypeParameter
    private interface ICovariant<out T>;

    private class Covariant<T> : ICovariant<T>;

    private static void SpecificCovariantMethod(ICovariant<Task<object>> input) =>
        input.ToString();

    private static void GeneralCovariantMethod(ICovariant<Task> input) =>
        input.ToString();
    // ReSharper disable once UnusedTypeParameter
    private interface IContravariant<in T>;

    private class Contravariant<T> : IContravariant<T>;

    private static void SpecificContravariantMethod(IContravariant<string> input) =>
        input.ToString();

    private static void GeneralContravariantMethod(IContravariant<IEnumerable<char>> input) =>
        input.ToString();
}
Enter fullscreen mode Exit fullscreen mode

My hypothesis was that Dynamic would be slowest, while Implicit and Direct would be slightly faster than Explicit and As. The results confirmed it:

BenchmarkDotNet v0.13.11, macOS Ventura 13.7.2 (22H313) [Darwin 22.6.0]
Apple M1 Pro, 1 CPU, 10 logical and 10 physical cores
.NET SDK 9.0.200
  [Host]   : .NET 9.0.2 (9.0.225.6610), Arm64 RyuJIT AdvSIMD
  ShortRun : .NET 9.0.2 (9.0.225.6610), Arm64 RyuJIT AdvSIMD

Job=ShortRun  IterationCount=3  LaunchCount=1  
WarmupCount=3
Enter fullscreen mode Exit fullscreen mode
Method Mean Ratio
Direct 5.226 ns 1.00
Implicit 5.234 ns 1.00
Explicit 5.266 ns 1.01
As 5.261 ns 1.01
Dynamic 14.278 ns 2.73

A Type-Safe Visitor Pattern with Visitor.NET

Combining contravariance and covariance opened new possibilities for abstraction design. I went beyond the original acyclic Visitor idea and introduced an abstraction for visitable elements: IVisitable. The resulting contracts are:

public interface IVisitable<out TVisitable>
    where TVisitable : IVisitable<TVisitable>
{
    TReturn Accept<TReturn>(IVisitor<TVisitable, TReturn> visitor);
}

public interface IVisitor<in TVisitable, out TReturn>
    where TVisitable : IVisitable<TVisitable>
{
    TReturn Visit(TVisitable visitable);
}
Enter fullscreen mode Exit fullscreen mode

First, an element can now accept only a processor capable of processing it. There is no more downcasting.

Second, contravariance lets us operate through public base contracts without knowing both concrete hierarchies.

The result is triple dispatch:

  1. The appropriate Accept override is selected polymorphically in the element hierarchy.
  2. A base visitor for the hierarchy root is passed into Accept. Contravariance lets it flow into the derived class's Accept overload, where it becomes a visitor for that concrete subtype.
  3. The concrete Accept overload calls the appropriate Visit overload for the element type.
public abstract class Element : IVisitable<Element>
{
    public abstract TReturn Accept<TReturn>(
        IVisitor<Element, TReturn> visitor);
}

public class ElementA : Element, IVisitable<ElementA>
{
    public override TReturn Accept<TReturn>(
        IVisitor<Element, TReturn> visitor) =>
        Accept(visitor);

    public TReturn Accept<TReturn>(
        IVisitor<ElementA, TReturn> visitor) =>
        visitor.Visit(this);
}
Enter fullscreen mode Exit fullscreen mode

I do not even need a visitor example to communicate the idea: the entire design rests on contracts and abstractions. From the visitor's perspective, nothing has changed; it remains acyclic.

public class ElementVisitor : VisitorNoReturnBase<Element>,
    IVisitor<ElementA>
{
    public VisitUnit Visit(ElementA visitable)
    {
        return default;
    }
}
Enter fullscreen mode Exit fullscreen mode

Visitor.NET + Source Generators

You may have noticed a drawback: every element in the hierarchy must implement two Accept overloads with fairly hard-to-read generic signatures. Source Generators already solve this problem.

Now you only need to annotate derived types with [AutoVisitable], supplying the hierarchy root in angle brackets:

[AutoVisitable<Element>]
public partial class ElementA : Element;

[AutoVisitable<Element>]
public partial class ElementB : Element;
Enter fullscreen mode Exit fullscreen mode

Can the root be omitted? No. That would make the generator much harder to implement because developers may introduce intermediate hierarchy levels that move shared behavior into abstract subclasses:

[AutoVisitable<Element>]
public partial class ElementA : ParticularElement;

public abstract class ParticularElement : Element;
Enter fullscreen mode Exit fullscreen mode

What About Multimethods?

When I described the library on my Telegram channel, a subscriber asked an interesting question:

Reader asking how Visitor.NET differs from multimethods

What is a multimethod? It is a mechanism that dynamically selects a function based on the types of the supplied values, extending subtype polymorphism.

C# has no such feature, although it can be emulated with dynamic. Suppose we have an abstraction over numbers. A multimethod could implement an abstract addition operation.

public interface INumber
{
    INumber Add(INumber number);
}

record MyInt(int Val) : INumber;

record MyRational(int Num, int Den) : INumber;
Enter fullscreen mode Exit fullscreen mode

We need to select an overload at runtime, which could be done like this:

record MyInt(int Val) : INumber
{
    public INumber Add(INumber number) => Add((dynamic)number);

    private INumber Add(MyInt myInt) => new MyInt(Val + myInt.Val);

    private INumber Add(MyRational myRational) =>
        myRational with
        {
            Num = Val * myRational.Den + myRational.Num
        };
}
Enter fullscreen mode Exit fullscreen mode

This approach does not satisfy our requirements and performs poorly because of dynamic type conversion, as the benchmark showed. I therefore do not consider it a viable option here.

Takeaways

  • Classic Visitor keeps dispatch type-safe but couples every visitor to the full element hierarchy.
  • Acyclic Visitor reduces that coupling but commonly pays for it with downcasts.
  • C# variance can preserve an acyclic design without runtime type conversion.
  • Source generation can absorb the repetitive Accept overloads without hiding the dispatch model.
  • The approach is most valuable for stable hierarchies with many independent operations; a simple virtual method is still the better tool when the behavior belongs to the element itself.

You can star the project on GitHub: github.com/stepami/visitor-net.

NuGet packages:

Related C# Guides

Follow StepOne on GitHub for open-source C#/.NET projects, compiler experiments, production-focused examples, and new releases.

Top comments (0)