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.
✅ 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
How it works
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:
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.
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.
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.
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.
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
You can add new operations without modifying element classes - just create a new visitor. This aligns with the Open/Closed Principle.
Recommended by LinkedIn
Keeps operational logic in the visitor, not scattered across elements. This simplifies maintenance and supports Single Responsibility.
Uses polymorphism and double dispatch instead of if / switch statements. Clean and scalable.
Visitors can hold state during traversal - perfect for things like summing values, collecting info, or building strings.
You can create mirrored visitors (e.g., ExportVisitor and ImportVisitor) with consistent structure for related operations.
❌ Cons
Every new element requires changes to the visitor interface and all existing visitors - a pain in fast-evolving systems.
Visitors often need internal data. This may lead to exposing fields via getters, weakening encapsulation.
Requires extra interfaces and Accept() methods. For small systems or few operations, this overhead may not be worth it.
Understanding how element.Accept(visitor) leads to visitor.Visit(element) takes some getting used to.
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:
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:
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:
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:
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!
CTO at TRTech Enterprise System
2wIvan Vydrin thanks for sharing such a well-written and clear article. 💯👍