Visitor Pattern in C#: Add Operations Without Modifying Types

Adding Operations Without Changing Classes

If you've ever needed to add new operations to a stable class hierarchy without modifying those classes, you've hit the limits of traditional object-oriented design. Adding a method means touching every class in the hierarchy and recompiling everything. The Visitor pattern lets you define new operations externally.

Visitor separates operations from the objects they operate on. Elements accept a visitor that performs the operation. Each element type calls back to a type-specific method on the visitor through double dispatch. This keeps element classes focused on data while visitors handle varying behaviors.

You'll build an expression evaluator that calculates values and formats expressions as strings without adding methods to the expression classes. By the end, you'll recognize when visitors add flexibility and when they overcomplicate simple scenarios.

Defining the Element Hierarchy

Create a class hierarchy that represents your domain objects. Each class implements Accept to let visitors process it. The Accept method calls the visitor back with a reference to itself.

Expression.cs
namespace VisitorDemo;

public interface IExpression
{
    void Accept(IExpressionVisitor visitor);
}

public class NumberExpression : IExpression
{
    public int Value { get; }

    public NumberExpression(int value) => Value = value;

    public void Accept(IExpressionVisitor visitor) =>
        visitor.Visit(this);
}

public class AddExpression : IExpression
{
    public IExpression Left { get; }
    public IExpression Right { get; }

    public AddExpression(IExpression left, IExpression right)
    {
        Left = left;
        Right = right;
    }

    public void Accept(IExpressionVisitor visitor) =>
        visitor.Visit(this);
}

public class MultiplyExpression : IExpression
{
    public IExpression Left { get; }
    public IExpression Right { get; }

    public MultiplyExpression(IExpression left, IExpression right)
    {
        Left = left;
        Right = right;
    }

    public void Accept(IExpressionVisitor visitor) =>
        visitor.Visit(this);
}

Each expression type implements Accept identically but passes itself to a different overload of Visit. This is the double dispatch mechanism that routes to type-specific visitor methods.

Creating the Visitor Interface

The visitor interface defines an overloaded Visit method for each element type. Concrete visitors implement these methods with type-specific logic.

IExpressionVisitor.cs
namespace VisitorDemo;

public interface IExpressionVisitor
{
    void Visit(NumberExpression number);
    void Visit(AddExpression add);
    void Visit(MultiplyExpression multiply);
}

public class EvaluatorVisitor : IExpressionVisitor
{
    public int Result { get; private set; }

    public void Visit(NumberExpression number)
    {
        Result = number.Value;
    }

    public void Visit(AddExpression add)
    {
        add.Left.Accept(this);
        var leftResult = Result;

        add.Right.Accept(this);
        var rightResult = Result;

        Result = leftResult + rightResult;
    }

    public void Visit(MultiplyExpression multiply)
    {
        multiply.Left.Accept(this);
        var leftResult = Result;

        multiply.Right.Accept(this);
        var rightResult = Result;

        Result = leftResult * rightResult;
    }
}

The evaluator visitor calculates expression values by recursively visiting child nodes. Each Visit method knows how to process that specific expression type without casting or type checking.

Adding New Operations

Create additional visitors for new operations without touching the expression classes. This demonstrates the open-closed principle: open for extension through new visitors, closed for modification of elements.

PrintVisitor.cs
namespace VisitorDemo;

public class PrintVisitor : IExpressionVisitor
{
    private readonly StringBuilder _result = new();

    public string Result => _result.ToString();

    public void Visit(NumberExpression number)
    {
        _result.Append(number.Value);
    }

    public void Visit(AddExpression add)
    {
        _result.Append('(');
        add.Left.Accept(this);
        _result.Append(" + ");
        add.Right.Accept(this);
        _result.Append(')');
    }

    public void Visit(MultiplyExpression multiply)
    {
        _result.Append('(');
        multiply.Left.Accept(this);
        _result.Append(" * ");
        multiply.Right.Accept(this);
        _result.Append(')');
    }
}

The print visitor formats expressions as strings. You've added a completely new operation by creating a new visitor class without modifying any expression types. This is the visitor pattern's key benefit.

Using Visitors

Build an expression tree and pass different visitors to it. Each visitor performs its operation on the structure without the structure knowing what operation is being performed.

Program.cs
using VisitorDemo;

var expr = new AddExpression(
    new MultiplyExpression(new NumberExpression(2), new NumberExpression(3)),
    new NumberExpression(4)
);

var evaluator = new EvaluatorVisitor();
expr.Accept(evaluator);
Console.WriteLine($"Result: {evaluator.Result}");

var printer = new PrintVisitor();
expr.Accept(printer);
Console.WriteLine($"Expression: {printer.Result}");

The same expression tree works with both visitors. Adding a third visitor for optimization or validation requires no changes to existing code. Just implement the visitor interface and you're done.

Try It Yourself

Build a simple file system visitor that calculates total size and prints directory structure. This shows how visitors traverse composite structures.

Steps

  1. Create: dotnet new console -n VisitorLab
  2. Navigate: cd VisitorLab
  3. Replace Program.cs
  4. Update .csproj
  5. Run: dotnet run
Program.cs
var root = new Folder("root",
    new File("a.txt", 100),
    new File("b.txt", 200),
    new Folder("sub",
        new File("c.txt", 50)
    )
);

var sizeCalc = new SizeCalculator();
root.Accept(sizeCalc);
Console.WriteLine($"Total size: {sizeCalc.Total} bytes");

interface IVisitor
{
    void Visit(File file);
    void Visit(Folder folder);
}

class SizeCalculator : IVisitor
{
    public int Total { get; private set; }

    public void Visit(File file) => Total += file.Size;

    public void Visit(Folder folder)
    {
        foreach (var child in folder.Children)
            child.Accept(this);
    }
}

interface INode
{
    void Accept(IVisitor visitor);
}

class File : INode
{
    public string Name { get; }
    public int Size { get; }

    public File(string name, int size)
    {
        Name = name;
        Size = size;
    }

    public void Accept(IVisitor visitor) => visitor.Visit(this);
}

class Folder : INode
{
    public string Name { get; }
    public INode[] Children { get; }

    public Folder(string name, params INode[] children)
    {
        Name = name;
        Children = children;
    }

    public void Accept(IVisitor visitor) => visitor.Visit(this);
}
VisitorLab.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

Console

Total size: 350 bytes

Knowing the Limits

Skip Visitor when your class hierarchy changes frequently. Every new element type requires updating all existing visitors. If you're adding classes more often than operations, pattern matching or simple virtual methods work better. Visitor shines when the hierarchy is stable but operations vary.

Avoid Visitor for simple scenarios where a single method in each class suffices. If you only need one operation, adding a method directly is clearer than creating a visitor infrastructure. Visitor adds ceremony that's only justified when you have multiple operations.

Watch for visitors that accumulate state across visits. Stateful visitors can be hard to reason about and aren't thread-safe without synchronization. Prefer visitors that produce results through return values or collect data in thread-safe collections when processing nodes concurrently.

How do I...?

When should I use Visitor instead of adding methods to classes?

Use Visitor when your class hierarchy is stable but operations change frequently. If adding a new operation means modifying every class, visitors centralize the logic. Avoid visitors when the class hierarchy changes often because adding classes requires updating all visitors.

What is double dispatch and why does Visitor need it?

Double dispatch selects the method based on both the visitor type and the element type. The element calls Accept passing the visitor, then the visitor's Visit method for that specific element type runs. This gives you type-specific behavior without casting or type checking.

How does Visitor work with pattern matching in modern C#?

Pattern matching can replace Visitor for simple cases. Use switch expressions with type patterns when you control both the hierarchy and operations. Stick with Visitor when operations are in separate assemblies or need open-closed extensibility without modifying element classes.

Is it safe to use Visitor with async operations?

Yes, make Visit methods return Task and Accept methods return Task. The pattern works the same way with async. Just ensure all implementations properly await their work. Visitors that traverse structures can process nodes concurrently if order doesn't matter.

Back to Articles