Visitor - Pattern Clarity #15
Visitor - Behavioral Pattern

Visitor - Pattern Clarity #15

Visitor is a behavioral design pattern that lets you define new operations on objects without changing their classes. It separates the algorithm from the objects it works on, making it easier to extend functionality without modifying existing code.


❗ Problem

In complex systems, you often deal with a variety of object types - and sometimes, you need to add new operations that apply to all of them. But how can you do this without modifying each class individually?

Adding methods directly to each class to support a new operation violates the Open/Closed Principle: classes should be open for extension, but closed for modification. This becomes especially risky when the code is already well-tested, widely used, or hard to change.

Take, for example, an expression tree (AST) with node types like Constant, Addition, and Multiplication. If you want to implement different operations - like evaluation, pretty-printing, or optimization - you’d need to inject logic for each operation into every node class. This leads to code duplication, bloated classes, and violates Single Responsibility Principle.

The same issue appears in a file system model with File and Directory classes. Adding a new feature like calculating total size or exporting to XML would typically require modifying these core classes, risking regressions and mixing unrelated logic into them.

A common workaround - using if/else chains or switch statements to handle each type - quickly becomes unmanageable as the number of operations or object types grows. This also defeats the purpose of polymorphism.

Article content

✅ Solution

The Visitor pattern solves the problem by cleanly separating operations from the object structure on which they act. Instead of modifying existing classes to add new behavior, you define a separate Visitor object that can "visit" each element and perform the operation externally.

Each element in the object structure doesn’t handle the operation itself. Instead, it accepts a visitor, delegating the operation to it. This allows you to introduce new functionality by simply adding new Visitor classes, keeping the original element classes unchanged - a direct application of the Open/Closed Principle.

Key Roles

  1. Element (Visitable): declares Accept(IVisitor) and is implemented by every object that can be visited. It delegates operation logic to the visitor.
  2. Concrete Elements: specific classes like NumberNode, AddNode, File, or Directory. Each implements Accept() by calling visitor.Visit(this).
  3. Visitor: an interface or abstract class that declares Visit() methods for each element type (e.g., VisitNumber(NumberNode n)).
  4. Concrete Visitors: implement the visitor interface with the actual operation logic. Each represents a distinct behavior - such as evaluating an expression, pretty-printing it, calculating file sizes, or exporting to XML.
  5. Object Structure: a container (e.g., a tree or directory) that holds the elements and provides traversal to apply visitors to all components. Though not a formal part of the pattern, it's essential in practice.

Article content

How it works

  • Each visitable object (called an Element) implements an Accept(IVisitor visitor) method.
  • The Visitor object defines a separate Visit(ElementType) method for each concrete element.
  • When an element's Accept is called, it invokes the corresponding Visit() method on the visitor, passing itself (this) as an argument.
  • This results in double dispatch: the correct method is selected based on the runtime types of both the element and the visitor - no type checks or casting required.

This approach lets you add what are essentially new "virtual" functions across a class hierarchy without modifying the hierarchy itself.


🔧 How to Implement

Implementing the Visitor pattern in C# involves creating the infrastructure for elements to accept visitors and defining visitors with the appropriate visit methods. Here's a step-by-step guide:

1. Define the Element interface with an accept method. This interface (or abstract class) declares a method like Accept(IVisitor visitor). All elements will implement this. For example:

// Element interface
public interface IElement 
{
    void Accept(IVisitor visitor);
}        

2. Define the Visitor interface. The visitor interface declares a Visit method overload for each concrete element type in your object structure. In C#, this means methods with distinct signatures. For instance, if you have two element types ElementA and ElementB, your visitor interface might be:

// Visitor interface
public interface IVisitor 
{
    void VisitElementA(ElementA element);
    void VisitElementB(ElementB element);
    // ...one visit method per concrete Element type
}        

Each VisitX(Y element) method in IVisitor is intended to encapsulate an operation for that specific element type Y. The names typically reflect the element type for clarity (e.g. VisitParagraph(Paragraph p) in a document visitor).

3. Implement Concrete Element classes. Each concrete element class implements the element interface. The key is the Accept method: it should call back into the visitor, passing itself (this) as an argument. This is where the double dispatch happens - the element directs the visitor to execute the method for its own type. For example:

public class ElementA : IElement 
{
    // element state and constructor(s) here
    public void Accept(IVisitor visitor)
    {
        visitor.VisitElementA(this);  // call the visitor's method for ElementA
    }
}

public class ElementB : IElement 
{
    public void Accept(IVisitor visitor)
    {
        visitor.VisitElementB(this);  // call the visitor's method for ElementB
    }
}        

Both ElementA and ElementB have no knowledge of what the visitor actually does; they just forward the call. If there are data fields or properties in the element needed by the operation, the visitor will get them via the passed-in object (this).

4. Implement Concrete Visitor class(es). Create one or more classes that implement the IVisitor interface, providing the actual logic for each operation on each element type. Each concrete visitor represents one set of behaviors across all element types. For example, let's implement a visitor that performs some operation on ElementA and ElementB:

public class ConcreteVisitor1 : IVisitor 
{
    public void VisitElementA(ElementA element)
    {
        // Behavior for ElementA
        Console.WriteLine("ConcreteVisitor1 processing ElementA.");
    }
    public void VisitElementB(ElementB element)
    {
        // Behavior for ElementB
        Console.WriteLine("ConcreteVisitor1 processing ElementB.");
    }
}        

You could create another visitor, say ConcreteVisitor2, that implements IVisitor differently (for example, performing a different operation on ElementA and ElementB). Notice how all logic for a particular operation is collected inside one visitor class, rather than scattered in the element classes.

5. Use the Visitor with the object structure (Client code). The client (or some manager class) is responsible for traversing the object structure and applying the visitor to each element. This could be as simple as iterating over a list of elements, or something more complex like a recursive traversal of a tree/graph. For each element, call element.Accept(visitor). The element will then call back the appropriate Visit method on the visitor:

var elements = new List<IElement> { new ElementA(), new ElementB(), /*...*/ };
IVisitor visitor = new ConcreteVisitor1();
foreach (var element in elements)
{
    element.Accept(visitor);
}        

At runtime, this loop will cause each element to invoke the visitor’s method specialized for its type. The end result is that ConcreteVisitor1’s code runs for each element, handling each type appropriately without any if/else type checking in the client.

ℹ️ Following these steps sets up the Visitor pattern. You can add new operations later by creating new classes that implement IVisitor (with different logic in the visit methods), without touching the Element classes at all. The only time you would modify the elements or the IVisitor interface itself is if a new element type is added (in which case you update the interface and add a stub to each visitor). This trade-off is important: the pattern makes adding new operations easy, but adding new element types harder, so it's best applied when your set of element classes is stable.


🧭 Use Cases

The Visitor pattern is useful in scenarios where you have a fixed set of object types and you need to perform multiple, varying operations across those objects. Some real-world use cases include:

  • Compiler Abstract Syntax Trees (Expression Trees)

Compilers and interpreters often represent code as an AST composed of node types (literal, variable, operator, etc.). Visitors are used to implement passes such as semantic analysis, optimization, code generation, pretty printing, etc. Each pass is a separate visitor that knows how to handle each kind of AST node. This way, you can add new compiler phases without altering the node classes.

  • File System Traversal

For hierarchical structures like directories and files (perhaps modeled with a composite pattern), a visitor can encapsulate operations such as computing total size, indexing files, checking permissions, or generating a directory listing. The visitor is applied to each file/directory object in turn. For example, a SizeComputingVisitor might accumulate file sizes, and a VirusScanningVisitor might scan files for malware.

  • UI Component Trees and Rendering

GUI frameworks often have a tree of UI elements (windows, panels, buttons, etc.). A Visitor can be used to render the UI by visiting each element (e.g., a RenderVisitor drawing each component) or for operations like hit testing or UI traversal. Similarly, in graphics systems dealing with shapes or scene graphs, visitors can apply operations like transformations or collision detection to various shape objects.

  • Game Engines (Entity Systems)

In game development, you might have different game object types (enemies, players, obstacles, power-ups). A Visitor can be used to implement game logic that needs to operate on all game objects in a generalized way. For instance, a CollisionVisitor could be passed to each game object to handle interactions differently based on type, or a PhysicsUpdateVisitor updates positions, etc. This avoids putting all these system behaviors inside the game object classes themselves.

  • Financial Applications

You may have different financial instrument classes (stocks, bonds, derivatives) or e-commerce items (books, electronics, groceries). Visitors can encapsulate operations like calculating risk metrics, applying discounts or tax calculations for each type. For example, a tax computation visitor can apply different tax rules to different item types without cluttering the item classes with tax logic.

ℹ️ In summary, Visitor shines when you have a stable class hierarchy and need to perform lots of distinct operations across that hierarchy. It's common in frameworks or tools where new features mean "do X for every kind of object" - you implement X as a visitor. It pairs well with composite structures (e.g. trees) to traverse and operate on all elements. Each of the above scenario's benefits from the ability to add new operations easily: e.g., adding a new analysis pass to a compiler or a new action in a game engine is just adding a new visitor.


⚖️ Benefits & Drawbacks

✅ Pros

  • Easy Extensibility for New Operations

You can add new operations without modifying element classes - just create a new visitor. This aligns with the Open/Closed Principle.

  • Clear Separation of Concerns

Keeps operational logic in the visitor, not scattered across elements. This simplifies maintenance and supports Single Responsibility.

  • No Type Checks Needed

Uses polymorphism and double dispatch instead of if / switch statements. Clean and scalable.

  • Can Accumulate State or Results

Visitors can hold state during traversal - perfect for things like summing values, collecting info, or building strings.

  • Uniform Handling of Related Tasks

You can create mirrored visitors (e.g., ExportVisitor and ImportVisitor) with consistent structure for related operations.

❌ Cons

  • Hard to Add New Element Types

Every new element requires changes to the visitor interface and all existing visitors - a pain in fast-evolving systems.

  • Risk of Breaking Encapsulation

Visitors often need internal data. This may lead to exposing fields via getters, weakening encapsulation.

  • Boilerplate and Indirection

Requires extra interfaces and Accept() methods. For small systems or few operations, this overhead may not be worth it.

  • Double Dispatch Can Be Confusing

Understanding how element.Accept(visitor) leads to visitor.Visit(element) takes some getting used to.

  • Requires a Stable Class Hierarchy

Best suited when element types are fixed and new operations are frequent. If element types change often, it causes ripple-effect refactoring.

Summary:

The Visitor pattern offers great flexibility when adding new behaviors - but comes at the cost of added complexity and stricter structure. Use it when operations change more than the object structure. Avoid it when the object model is still evolving.


💻 Code Example

Let's walk through a concrete example in C# to see the Visitor pattern in action. Suppose we have a simple graphics app with two shape classes: Circle and Rectangle. We want to perform multiple operations on these shapes, such as calculating their area and displaying their details. We'll use the Visitor pattern to implement these operations without adding methods to the shape classes themselves.

namespace PatternClarity.BehavioralPattern.Visitor;

// 1. Element interface
public interface IShape
{
    void Accept(IShapeVisitor visitor);
}

// 2. Concrete Element classes
public class Circle : IShape
{
    public double Radius { get; }
    public Circle(double radius) { Radius = radius; }
    public void Accept(IShapeVisitor visitor) => visitor.VisitCircle(this);
}

public class Rectangle : IShape
{
    public double Width { get; }
    public double Height { get; }
    public Rectangle(double width, double height)
    {
        Width = width; Height = height;
    }
    public void Accept(IShapeVisitor visitor) => visitor.VisitRectangle(this);
}

// 3. Visitor interface
public interface IShapeVisitor
{
    void VisitCircle(Circle circle);
    void VisitRectangle(Rectangle rectangle);
}

// 4. Concrete Visitors implementing different operations

// Visitor to calculate total area of shapes
public class AreaVisitor : IShapeVisitor
{
    public double TotalArea { get; private set; } = 0;
    public void VisitCircle(Circle circle)
    {
        // Area = π * r^2
        TotalArea += Math.PI * circle.Radius * circle.Radius;
    }
    public void VisitRectangle(Rectangle rectangle)
    {
        // Area = width * height
        TotalArea += rectangle.Width * rectangle.Height;
    }
}

// Visitor to print details of shapes
public class InfoVisitor : IShapeVisitor
{
    public void VisitCircle(Circle circle)
    {
        Console.WriteLine($"Circle with radius = {circle.Radius}");
    }
    public void VisitRectangle(Rectangle rectangle)
    {
        Console.WriteLine($"Rectangle [width = {rectangle.Width}, height = {rectangle.Height}]");
    }
}

// 5. Client code demonstrating the Visitor usage
public class Program
{
    public static void Main()
    {
        List<IShape> shapes = new List<IShape>
        {
            new Circle(3.0),
            new Rectangle(4.0, 5.0),
            new Circle(2.5)
        };

        // Use AreaVisitor to calculate total area of all shapes
        var areaVisitor = new AreaVisitor();
        foreach (var shape in shapes)
            shape.Accept(areaVisitor);
        Console.WriteLine("Total area = " + areaVisitor.TotalArea);

        // Use InfoVisitor to print details of each shape
        var infoVisitor = new InfoVisitor();
        foreach (var shape in shapes)
            shape.Accept(infoVisitor);
    }
}        

Output:

Total area = 67.90928796724435
Circle with radius = 3
Rectangle [width = 4, height = 5]
Circle with radius = 2.5        

In this example, Circle and Rectangle don't have any methods related to area or printing. Those behaviors are implemented in the visitors AreaVisitor and InfoVisitor. Each shape class simply knows how to accept a visitor. The AreaVisitor accumulates the area as it visits each shape, and the InfoVisitor prints information for each shape. If later we wanted to add a new operation, say, exporting shape data to JSON, we could create a new JsonExportVisitor implementing IShapeVisitor without touching the Circle or Rectangle classes at all. This demonstrates how the Visitor pattern achieves extensibility for operations.


⚠️ Additional Considerations

🌀 Double Dispatch & Language Support

The Visitor pattern simulates double dispatch in languages like C# and Java, which only support single dispatch. It does this through a two-step call:

  1. element.Accept(visitor) resolves based on the element’s runtime type.
  2. Inside Accept, it calls visitor.Visit(this), invoking the visitorэs correct method.

This allows behavior to vary by both element and visitor types, avoiding explicit type checks. Some modern languages (e.g., Scala, C#, Kotlin) offer pattern matching or multiple dispatch, which may eliminate the need for Visitor in certain cases.

Note: Method overloading isn't enough - it's resolved at compile time based on static types, hence the need for the Accept() mechanism.

🔁 Adding Elements vs. Visitors

Visitor is ideal when your object structure is stable, and you expect to add new operations frequently. But if you often introduce new element types, it becomes a burden - every visitor must be updated, even if they don't use the new type. In such cases, simpler patterns like Strategy or adding methods directly to base classes may be more appropriate.

Visitor trades easy addition of operations for harder addition of types. Choose based on which changes more often.

🧪 Testability

The pattern encourages well-separated, testable logic. Each visitor can be tested independently, using real or mock elements. You may also want to verify that Accept() correctly delegates to the right visitor method, which can be done with mock visitors. However, adding a new element type means updating tests across all visitors.

🔄 Visitor vs. Other Patterns

Visitor overlaps with several other patterns:

  • Command: A visitor is like a reusable command across object types.
  • Composite: Often paired with Visitor for tree traversal.
  • Iterator: Helps traverse object structures to apply the visitor.
  • Strategy: Focuses on one object; Visitor operates across many.
  • Decorator: Adds behavior dynamically; Visitor adds behavior externally without modifying objects.

Use Visitor when you need to apply operations across a set of different types - not just alter behavior for one.

🚫 When to Avoid Visitor

Visitor isn't always the right tool. It adds complexity, boilerplate, and indirection. Avoid it when:

  • You have only a few operations.
  • The object hierarchy changes frequently.
  • Simpler patterns (like Strategy or polymorphism) suffice.

Use it when the benefits clearly outweigh the cost - for example, in interpreters, compilers, or when emulating multiple dispatch in static languages.


📌 Conclusion

The Visitor pattern offers a clean, extensible way to add new behavior to object structures without modifying their classes. It leverages double dispatch to delegate operations to external visitor objects, promoting the Open/Closed and Single Responsibility principles.

Visitor shines when working with a stable set of types and a growing number of operations - such as in compilers, GUI frameworks, or shape hierarchies (e.g., computing area or formatting output).

However, it comes with complexity:

  • For junior/mid engineers, understanding the control flow and boilerplate is essential.
  • For experienced developers, careful consideration is needed around encapsulation, hierarchy changes, and whether simpler alternatives (like pattern matching) would suffice.

Used in the right context, Visitor enables modular, maintainable, and easily extendable designs - especially where frequent new operations are expected.

Thank you for reading this article in the "Pattern Clarity" series! I'd love to hear your thoughts and experiences with the Visitor pattern - share your comments and suggestions!
Babak Golkar

CTO at TRTech Enterprise System

2w

Ivan Vydrin thanks for sharing such a well-written and clear article. 💯👍

To view or add a comment, sign in

More articles by Ivan Vydrin

  • Azure Cosmos DB: Introduction

    What is Azure Cosmos DB? Azure Cosmos DB is Microsoft's globally distributed, multi-model database service designed for…

    5 Comments
  • Composite - Pattern Clarity #17

    Composite is a structural design pattern that composes objects into tree structures, allowing a group of objects to be…

  • Your Essential Guide to Health Checks in .NET

    Health checks are automated diagnostics used to determine whether an application and its dependencies are functioning…

    9 Comments
  • Claude Code: Anthropic's AI Coding Assistant

    Claude Code is Anthropic's innovative command-line AI assistant designed to streamline software development. Whether…

    5 Comments
  • Flyweight - Pattern Clarity #16

    The Flyweight pattern is a structural design approach that optimizes memory usage by allowing multiple objects to share…

    2 Comments
  • Agile Planning Poker ♣️ - more than a Game for Estimates

    We've all been there: a sprint planning meeting where the team debates how many story points a "simple" feature really…

    4 Comments
  • Get started with the official Microsoft's Azure MCP Server

    Microsoft's Azure MCP Server - where MCP stands for Model Context Protocol - is a new tool in public preview that…

    3 Comments
  • Agentic AI implementation guidance

    Building on our exploration of Agentic AI and the transformative potential of autonomous agents, this article takes a…

    1 Comment
  • Memento - Pattern Clarity #14

    The Memento design pattern lets an object capture its internal state and save it externally so that it can be restored…

  • Microsoft Copilot Studio: a Low‑Code AI Assistant Builder

    Imagine having your own AI "copilot" that can answer questions, perform tasks, and streamline work for you - all…

    4 Comments

Insights from the community

Others also viewed

Explore topics