Your Class Hierarchy Is Not a Dumping Ground — Use Visitor Design Pattern
In our previous article Work with Object Trees like a Pro: The Composite Pattern Explained, we saw how the Composite pattern lets us treat an object hierarchy as a single unit. The Visitor pattern takes a different tack: it lets you add new operations to classes without modifying the classes themselves. In other words, instead of scattering logic across your object types or cluttering them with many new methods, Visitor encapsulates these operations in a separate object. In practical terms, you define a Visitor that implements behavior for each concrete element type; each element simply “accepts” the visitor, which then runs the right code for that element. This leverages double dispatch (objects delegate method calls based on both the visitor and element types) and keeps the data classes untouched.
By decoupling algorithms from the data structures they operate on, the Visitor pattern can make your code more maintainable and flexible. For example, you might be dealing with a tree of objects where new requirements keep piling on — maybe you need export, validation, or calculation routines that you can’t add directly into those classes. The Visitor makes it possible to “plug in” these new operations by writing new visitor classes, without altering the existing code. In this article, we’ll explore how Visitor works, when to use it (and when not to), its pros and cons, and how it compares to other patterns.
How the Visitor Pattern Works
At its core, the Visitor pattern involves two main participants:
- Element (Visitable) classes: These are the classes making up your object structure. Each class implements an
accept(visitor)
method (or similarly named) that takes a visitor. - Visitor interface and ConcreteVisitors: You define a visitor interface (or abstract class) with a “visit” method for each concrete element type. For example, if you have
Cat
andDog
elements, the visitor interface has methods likevisitCat(_:)
andvisitDog(_:)
. Each concrete visitor then implements these methods with the operation’s logic.
The clever bit is double dispatch. When the client wants to perform an operation on an element, it calls element.accept(visitor)
. The element’s accept
method is implemented to call the visitor with itself as argument:
class Dog: Animal {
func accept(_ visitor: AnimalVisitor) {
visitor.visitDog(self)
}
}
This means the call is first dispatched on the element (accept
), then on the visitor’s overloaded visitXxx(...)
method. The runtime will bind to the specific visitDog(_:)
or visitCat(_:)
based on the object types. This avoids any if
/switch
or instanceof
checks in client code – the object itself knows which type-specific visitor method to invoke.
For example, without Visitor you might have something like:
for animal in zoo {
if animal is Dog:
doDogStuff(animal)
else if animal is Cat:
doCatStuff(animal)
// and so on...
}
This is tedious and error-prone. With Visitor, you instead write:
for animal in zoo {
animal.accept(visitor)
}
And each animal
calls exactly the right method on the visitor. A classic source describes the alternative (with instanceof
and casts) as “a nightmare” due to the proliferation of type checks. The Visitor pattern cleanly eliminates that problem with its double-dispatch mechanism.
Internally, the Visitor pattern is usually implemented with an interface (protocol) like:
protocol RideVisitor {
func visitRollerCoaster(_ coaster: RollerCoaster)
func visitFerrisWheel(_ wheel: FerrisWheel)
// ... one method per Ride subtype
}
And each Ride
element class (e.g. RollerCoaster
, FerrisWheel
) implements:
protocol Ride {
func accept(_ visitor: RideVisitor)
}
class RollerCoaster: Ride {
func accept(_ visitor: RideVisitor) {
visitor.visitRollerCoaster(self)
}
}
class FerrisWheel: Ride {
func accept(_ visitor: RideVisitor) {
visitor.visitFerrisWheel(self)
}
}
A concrete visitor, such as SafetyInspector
, implements all the visitXxx
methods to perform the desired operation on each ride. The client just iterates over rides and calls ride.accept(inspector)
, trusting the double-dispatch to invoke the correct check for that ride type.
This structure lets you add new behaviors (new visitor classes) without touching the Ride
classes themselves. Each ride’s accept
knows how to hand control to the visitor’s appropriate method, and that visitor contains all the logic for that operation. The data classes remain unchanged, satisfying the open/closed principle.
Real-World Analogy
Imagine a theme park safety inspector who must perform a special safety check on every attraction in the park. The park has different kinds of rides — a roller coaster, a ferris wheel, a bumper car arena, etc. Each ride knows how to accept an inspector (perhaps by allowing a brief test run or showing its control panel). Each ride also knows its own type (roller coaster vs. ferris wheel), and each type requires a different set of checks.
Instead of writing one giant procedure with lots of if (ride.type == .rollerCoaster) { ... }
blocks, we let each ride handle the dispatch. The inspector carries a checklist of tests for each ride type. When the inspector visits a ride, the ride calls the right checklist method for itself. For example, the RollerCoaster
calls inspector.checkRollerCoaster(self)
, whereas the FerrisWheel
calls inspector.checkFerrisWheel(self)
. Each of those methods knows how to test that specific ride.
With this setup, if tomorrow the park wants to add a new type of ride (say, a sky coaster), or if they want a completely new type of inspection (say, a performance evaluation instead of safety), they can do so without rewriting all the existing ride classes. The inspector’s new checklist can be added in its own class. Each ride already has the accept(inspector)
mechanism in place, so everything plugs in smoothly. This mirrors the Visitor pattern: rides are elements, the inspector is a visitor, and the type-specific checks are the visitor’s visitXxx
methods.
The key takeaway from this analogy is that the inspector’s operations (the “visitor” behavior) live outside the ride classes. Rides just know how to call a visitor with themselves as context. You can hire a new inspector (new visitor) or add a new type of ride (new element) independently, with minimal changes to the other side. This keeps the design clean and flexible.
Benefits of the Visitor Pattern
- Easy to Add New Operations: You can introduce a new feature by making a new Visitor class, without modifying the existing element classes.
- Separation of Concerns: Operations are kept separate from the object structure. This makes both the data classes and the algorithms easier to manage.
- Centralized Logic: All related operations live in the visitor. This helps you see at a glance how a given algorithm interacts with all element types.
- Maintains Open/Closed Principle: Since element classes aren’t being changed when you add behaviors, the system conforms to open/closed — closed to modification, open to extension.
- Compile-Time Type Safety (in static languages): Each
visit
method is specific to an element type, so calls are checked at compile time. You’re less likely to mistype a case than you would be with manual type-switching.
Limitations of the Visitor Pattern
- Adding New Element Types is Hard: Whenever you introduce a new element class, you must update every visitor to handle it. Each visitor interface gets a new
visitNewType
method, and all concrete visitors must implement it. This violates the open/closed principle for the object type hierarchy. - Tight Coupling to Element Classes: Visitor classes must “know” all the concrete element types. This creates coupling: the visitor interface lists every element type, and each new element type ripples through all visitors. This can make the system brittle if classes change.
- Many Classes and Interfaces: Simple problems can become cluttered with extra code (visitor interface, concrete visitors, accept methods in elements). This can feel like boilerplate, especially if there aren’t many operations to separate.
- Encapsulation Concerns: Visitors often need access to an element’s internals to perform work. This can force you to expose getters or make the visitor a friend/inner class, reducing encapsulation. It also means the element classes must include the
accept
method even if it’s only used for visitors. - Not Ideal for Frequent Changes in Data Structure: If your element classes change often (say, you keep adding new subclasses or altering the class hierarchy), Visitor becomes a burden. Each change would require touching all visitors, increasing maintenance work.
In summary, Visitor is best suited for situations where the set of element types is stable and unlikely to change frequently. If your design calls for frequently adding new types, consider other solutions.
Example in Swift
Below is an original Swift example (not drawn from typical sources) demonstrating the Visitor pattern in a theme park setting. We define a RideVisitor
protocol and Ride
elements. A concrete visitor SafetyInspector
performs safety checks on each ride without the rides themselves implementing the check logic.
// Element protocol
protocol Ride {
var name: String { get }
func accept(_ visitor: RideVisitor)
}
// Concrete elements
class RollerCoaster: Ride {
let name: String
init(name: String) { self.name = name }
func accept(_ visitor: RideVisitor) {
visitor.visitRollerCoaster(self)
}
}
class FerrisWheel: Ride {
let name: String
init(name: String) { self.name = name }
func accept(_ visitor: RideVisitor) {
visitor.visitFerrisWheel(self)
}
}
// Visitor interface
protocol RideVisitor {
func visitRollerCoaster(_ coaster: RollerCoaster)
func visitFerrisWheel(_ wheel: FerrisWheel)
}
// Concrete Visitor
class SafetyInspector: RideVisitor {
func visitRollerCoaster(_ coaster: RollerCoaster) {
print("Inspecting roller coaster '\\(coaster.name)': Checking track integrity and safety harnesses.")
}
func visitFerrisWheel(_ wheel: FerrisWheel) {
print("Inspecting ferris wheel '\\(wheel.name)': Checking electrical system and gondola locks.")
}
}
// Client code
let parkRides: [Ride] = [
RollerCoaster(name: "Thunder Mountain"),
FerrisWheel(name: "Skyview Wheel")
]
let inspector = SafetyInspector()
for ride in parkRides {
ride.accept(inspector)
}
// Output:
// Inspecting roller coaster 'Thunder Mountain': Checking track integrity and safety harnesses.
// Inspecting ferris wheel 'Skyview Wheel': Checking electrical system and gondola locks.
In this code, note how SafetyInspector
(the visitor) implements type-specific methods for each ride. The Ride
classes only have an accept
method that passes control to the visitor. This exemplifies how Visitor separates the inspection logic (visitor) from the ride data classes. If tomorrow we need a different operation (say, an PerformanceInspector
with its own visitRollerCoaster
and visitFerrisWheel
), we simply add a new visitor class. No changes are required in RollerCoaster
or FerrisWheel
beyond the existing accept
methods.
When to Use Visitor vs. Type Checks
A common code smell is to see lots of if (obj is TypeA) { ... } else if (obj is TypeB) { ... }
or a switch
on an object’s type, scattering logic across client code. Visitor provides a cleaner alternative. Instead of manually selecting the right action for each type (which as one example notes is a “nightmare” of conditionals), you delegate that decision to the objects themselves via accept
.
Use Visitor when:
- You have multiple operations you want to perform on a set of related object types, and these operations belong outside the core data classes.
- You want to avoid instance-of or switch logic scattered around your code. Visitor centralizes that branching in its
visitXxx
methods. - You’re working with an object structure (often a tree or composite) where you can easily traverse the elements and call
accept
on each.
For example, suppose you found yourself writing:
for ride in parkRides {
if let coaster = ride as? RollerCoaster {
// special logic for roller coaster
} else if let wheel = ride as? FerrisWheel {
// special logic for ferris wheel
}
// ... more cases ...
}
This approach requires editing that loop every time you add a new type or a new operation. With Visitor, you’d instead write ride.accept(visitor)
and add a new visitor method for the new type or operation. In short, Visitor lets you replace explicit type-checking with polymorphic calls.
Separating Algorithms from Data
One of Visitor’s main values is that it separates algorithms from the object structures they operate on. The data classes (elements) contain the data and simple routines, while the complex operations live in visitor classes. This clean separation adheres to the single-responsibility principle: element classes are responsible for data structure, and visitor classes handle algorithms.
As a result, you can add a new operation just by writing a new visitor class, without touching the existing classes. For instance, in our theme park example you could create an EfficiencyMonitor
visitor to log ride uptime, or an EmergencyDrillInstructor
visitor, and none of the Ride
classes need to change. The accept
methods already call the correct visitor hooks. This flexibility stems from Visitor’s design: “moving operations into visitor classes is beneficial when… new operations need to be added frequently”.
By keeping the logic outside the objects, maintenance often becomes easier. As one source notes, the Visitor pattern provides centralized logic and makes it easy to add features by “creating new visitor classes without changing the existing objects”. It also preserves type safety: each visitor method is statically bound to a specific element type, so you get compile-time checks that you’re handling each type correctly.
When to Use (and Not to Use) Visitor
Use Visitor when:
- You have a stable class hierarchy of elements, and you expect to add new operations on these elements frequently. The Visitor lets you plug in those operations easily.
- The object classes are somewhat “closed” for modification (perhaps they come from a library or are already tested), so you really can’t or don’t want to modify them to add behavior.
- You need many different unrelated operations on the same set of objects. Visitor centralizes these operations instead of scattering them.
- Your primary task is to perform algorithms that span across many classes. For instance, evaluating an abstract syntax tree (AST) is a common use-case: each node type is an element, and each compiler pass (type-check, codegen, optimization) can be a separate visitor.
Avoid Visitor when:
- You expect to add new element types often. Each new type forces updates in all visitors, which is tedious and error-prone.
- Your operations could be more simply handled by the elements themselves or by another pattern. For example, if each element could just perform its own behavior polymorphically, or if you can use a simple function or strategy, you might not need the full Visitor structure.
- You’re working in a dynamic language and can achieve the same goals with simpler dynamic dispatch (or pattern matching) without explicit visitors.
- The extra complexity of Visitor (multiple classes, interfaces, and dispatch code) is not justified by the problem’s size. If there are only one or two operations, it might be easier just to put methods in the classes.
In short, favor Visitor when the set of element types is fixed but the set of operations is growing. If, instead, the operations are fixed but the types change a lot, consider other approaches.
Conclusion
The Visitor design pattern is a powerful tool for extending object structures with new behavior without touching their source code. By defining a visitor object with type-specific methods, you can add operations cleanly via double dispatch. We saw that this approach separates algorithms from data, making it easier to maintain and expand your code. The Visitor shines when you have a stable set of classes but many varied operations to perform on them.
However, it comes with trade-offs: adding new classes can be painful, and in some languages (especially dynamic ones), it may be overkill. As with any pattern, use it judiciously.
Some developers swear by Visitor. Others avoid it like the plague. Both camps think they’re right. If you’ve ever used (or refused to use) Visitor in a real-world project, tell us why. Did it make the code cleaner — or just more ceremony for nothing?
Drop a comment with your take. And while you’re at it, feel free to share this article with your team, clap if it was worth your time, and subscribe if you want more pattern breakdowns. Happy coding and designing!