Visitor Pattern in C#: Add Operations to Objects
The Visitor pattern is a behavioral design pattern that allows developers to add new operations to an existing object structure without altering the underlying classes. In C#, this pattern enables the separation of algorithms from the objects on which they operate by using a technique called double dispatch. This article explains the fundamentals of the Visitor pattern, the core problems it solves, and how to implement it cleanly in C# to adhere to the Open/Closed Principle.
What is the Visitor Pattern?
The Visitor pattern decouples an algorithm from the object structure it acts upon. Instead of adding new methods to multiple domain classes whenever business requirements change, you encapsulate the new behavior inside a separate "visitor" class.
This approach enforces the Open/Closed Principle: classes remain open for extension (by accepting new visitors) but closed for modification (their internal structure does not need to change when a new operation is introduced).
How the Visitor Pattern Works: Double Dispatch
In standard single-dispatch languages like C#, the method invoked depends solely on the runtime type of the object receiving the call. The Visitor pattern uses double dispatch to select the correct execution logic based on both:
- The type of the operation (the visitor).
- The type of the receiver (the element).
The workflow involves two distinct calls:
- The client calls
element.Accept(visitor). - The concrete element calls
visitor.Visit(this), passing its own concrete type into the visitor.
Implementing the Visitor Pattern in C
Consider an e-commerce application with different types of cart items
(Book and Electronics). If you need to
calculate shipping costs or export items to JSON without modifying the
product classes, you can apply the Visitor pattern.
1. Define the Visitor and Element Interfaces
public interface IVisitor
{
void Visit(Book book);
void Visit(Electronics electronics);
}
public interface ICartItem
{
void Accept(IVisitor visitor);
}2. Implement the Concrete Elements
Each concrete class implements the Accept method,
routing the call back to the visitor.
public class Book : ICartItem
{
public string Title { get; }
public decimal Price { get; }
public double WeightInKg { get; }
public Book(string title, decimal price, double weightInKg)
{
Title = title;
Price = price;
WeightInKg = weightInKg;
}
public void Accept(IVisitor visitor)
{
visitor.Visit(this);
}
}
public class Electronics : ICartItem
{
public string Name { get; }
public decimal Price { get; }
public bool RequiresSpecialHandling { get; }
public Electronics(string name, decimal price, bool requiresSpecialHandling)
{
Name = name;
Price = price;
RequiresSpecialHandling = requiresSpecialHandling;
}
public void Accept(IVisitor visitor)
{
visitor.Visit(this);
}
}3. Implement Concrete Visitors
Now, create operations independently without modifying
Book or Electronics.
Operation 1: Shipping Cost Calculator
public class ShippingCalculatorVisitor : IVisitor
{
public decimal TotalShippingCost { get; private set; }
public void Visit(Book book)
{
// Standard rate based on weight
TotalShippingCost += (decimal)book.WeightInKg * 2.5m;
}
public void Visit(Electronics electronics)
{
// Electronics have a flat fee, plus a surcharge for fragile handling
decimal fee = 10.0m;
if (electronics.RequiresSpecialHandling)
{
fee += 15.0m;
}
TotalShippingCost += fee;
}
}Operation 2: JSON Export
public class JsonExporterVisitor : IVisitor
{
public void Visit(Book book)
{
Console.WriteLine($"{{\"type\": \"book\", \"title\": \"{book.Title}\", \"price\": {book.Price}}}");
}
public void Visit(Electronics electronics)
{
Console.WriteLine($"{{\"type\": \"electronics\", \"name\": \"{electronics.Name}\", \"price\": {electronics.Price}}}");
}
}4. Executing Operations on the Object Structure
var cart = new List<ICartItem>
{
new Book("Design Patterns", 45.00m, 1.2),
new Electronics("Smartphone", 799.99m, true),
new Book("C# in Depth", 50.00m, 0.9)
};
// Execute Shipping Calculation
var shippingCalculator = new ShippingCalculatorVisitor();
foreach (var item in cart)
{
item.Accept(shippingCalculator);
}
Console.WriteLine($"Total Shipping: ${shippingCalculator.TotalShippingCost}");
// Execute JSON Export
var jsonExporter = new JsonExporterVisitor();
foreach (var item in cart)
{
item.Accept(jsonExporter);
}Advantages and Disadvantages
Advantages
- Clean Code Architecture: Group related behaviors into a single visitor class rather than scattering them across many element classes.
- Open/Closed Principle: Add new operations (such as
tax calculations, discounts, or serialization) simply by adding a new
IVisitorimplementation. - Accumulating State: Visitors can maintain internal state as they traverse diverse collections of objects (e.g., tallying costs or building reports).
Disadvantages
- Fragile to Schema Changes: Adding a new element
type (e.g.,
Clothing) requires updating theIVisitorinterface and every existing concrete visitor. - Encapsulation Risks: Visitors often require access to the internal data of the elements, potentially leading to public exposure of properties that would otherwise remain private or internal.