The Visitor pattern allows decoupling algorithms from the objects on which they operate. It allows adding and changing functionalities in a type-safe and SOLID manner.
This is one of the most complex classic design patterns. In this article, we’ll discuss how it’s structured, what value it brings, as well as what are the tradeoffs and alternatives. We are also going to mention several variations of the implementation of the pattern.
If you want to read more about design patterns in C#, you can inspect our C# Design Patterns page.
So, let’s start.
The Structure of the Visitor Pattern
The simplest way to explain the Visitor pattern structure is by explaining its main actors – the Visitor and the Visitable. The latter is often referred to as the Element, but calling it Visitable (or Visitee) shows its purpose better.
The Visitor is the class that adds the extra functionalities to the elements that it visits. The Visitable is the class that can accept a Visitor to become enriched with extra functionalities.
The act of visiting is the crucial point where these two meet and interact. Visiting assures type safety and extensibility, by the magic of double dispatch. In simple terms, double dispatch means that first, the Visitee accepts the Visitor, and only then does the Visitor visit the Visitee.
Let’s have a look into the structural view of the pattern. We can think of it as consisting of two areas:
The Pattern Structure Area
On the highest level, we have the classes and interfaces that form the “pattern structure area”. Together they declare the object structure and something that we can call its extensibility contract. The object structure is simply a set of classes for which we intend to add the possibility of non-disruptive extensibility. In other words, these would be our domain objects.
The extensibility contract is the IVisitableElement
interface and IVisitor
interface. These two interfaces are a way of saying “the domain objects allow to be extended (they accept the visitor) and they don’t care how (they don’t know the visitor type)“.
Notice that these elements are interdependent. All the concrete elements and the IVisitableElement
need to know about IVisitor
. On the other hand, the IVisitor
interface needs to know about all the concrete element types, because we want type safety. IVisitableElement
and IVisitor
are only not aware of the concrete implementations of the IVisitor, i.e. the elements from the ‘extensibility area’. Bear in mind that using IVisitableElement
in the Visit
method defeats the purpose of the pattern. This is because we would need to rely on casting to figure out what actual concrete element type are we dealing with at runtime.
What happens if we add a new domain class that implements the IVisitableElement
interface?
By convention, the implementation of the Accept
method in every class should just pass itself to the visitor by calling visitor.Visit(this)
. Therefore, the code will not compile until we add a new Visit(NewDomainType obj)
method to the IVisitor
interface so that this concrete type is supported.
This circular dependency means that these classes typically exist within a single codebase (a repository, solution, or maybe even assembly), owned by a single team or organization. Otherwise, coordinating rollouts of new elements would be burdensome.
The Extensibility Area
Next on the diagram is something that we can think of as the “extensibility area”. In other words, this is the implementation of the extensibility contract. These are the concrete implementations of the visitors. They all depend on the IVisitor
interface and have to be aware of all the concrete element types. However, this is a simple one-way dependency. None of the classes from what we called the “pattern structure area” are aware of these concrete visitors. Also, the concrete visitor types are fully decoupled from each other.
By creating new visitors we are extending the domain elements without changing them. That’s one of the ways of adhering to the Open/Closed Principle. This is one of the key focus points for the Visitor pattern, especially when the pattern is used to allow creating plugins and extensions by external developers.
Invoking the Visitors and Visitable Elements
So, how and where do those parts meet?
They meet when we call the Accept
method on a collection of IVisitableElements
:
public void UseTheVisitorPattern(IVisitableElement[] elements, IVisitor visitor) { foreach (IVisitableElement element in elements) { element.Accept(visitor); } }
We should have in mind that the type of the IVisitableElements
in the collection is only known at runtime. At the same time, we didn’t have to write any type checking or casting code. All the methods in the Visitor implementations know exactly what types are they operating on.
How Does the Pattern Achieve Type Safety?
We’ve mentioned type safety and double dispatch a few times before, so let’s have a deeper look into it.
Thanks to polymorphism we can store many types of IVisitableElements
in a single collection. This is very useful, as it allows us to treat them in the same way, provided we only need to access the common part of these types. Common part means the data and methods which are available in the interface/base class that we are referring to instead of the actual derived types.
Things get complicated when we need to access members declared on the derived types. In order to do that, we need to introduce casting:
foreach (IVisitableElement element in elements) { switch (element) { case ConcreteElementOne one: visitor.Visit(one); break; case ConcreteElementTwo two: visitor.Visit(two); break; } }
This creates a sort of a ‘single dispatch’ visitor (not a real thing!). There are several drawbacks to this. The switch block has to specify cases for all the types of visitable elements. The C# compiler will not warn us about a type that is not covered. That’s because it does not know about all the possible types derived from IVisitableElement
. Additionally, when we add a new type of visitable element, we have to remember both about adding a new method in the Visitor and about updating all the type-checking code.
Double Dispatch in the Visitor Pattern
This is where the double dispatch of the Visitor pattern comes into play. Let’s have a look at the flow of control step by step. The first dispatch happens when the IVisitableElement
accepts the visitor:
element.Accept(visitor);
The compiler does not know the exact type of element
here. It only knows it implements the IVisitableElement
interface and therefore it has the Accept
method that accepts some kind of IVisitor
. By convention, every derived type implementation of the Accept
method looks the same:
public void Accept(IVisitor visitor) => visitor.Visit(this);
The second dispatch happens when the visitor gets the instance of the concrete visitable element passed to its Visit
method.
We should note that this code is written in every concrete implementation of the IVisitableElement
. This means that the compiler knows whether the IVisitor
supports this type or not. If there wasn’t an overload of the Visit
method that would accept this concrete element type, the code would not compile. The compiler would issue a warning that it cannot convert the current type to any of the types supported by the IVisitor
interface.
Demo Implementation of the Visitor Pattern
Let’s have a look into how the visitor pattern fits in a project. To make the example interesting and not-too-trivial, we are going to assume we’re working on a next-generation automated medical analysis system. The system allows the analysis of a stream of data coming from various medical tests (blood tests, x-ray, ECG, etc.) to check for possibilities of various diseases.
Three teams work on this system.
Team A is responsible for gathering test results from external systems. It delivers our core domain objects – BloodSample
, XRayImage
, EcgReading
. These are the visitable elements that we want to extend. Team B is responsible for sickness detectors. It works on the visitor classes, which examine the test results and try to raise a potential sickness alert. Team C is responsible for the user application that allows monitoring of the system. This is where the visitors and visitable elements meet.
The Pattern Structure Area
First of all, let’s see how does our extensibility contract looks like. It has to be defined by Team A, as this is the team that allows adding functionality to their objects:
public interface ISicknessAlertVisitable { AlertReport Accept(ISicknessAlertVisitor visitor); }
We call the interfaces ISicknessAlertVisitable
and ISicknessAlertVisitor
. This clearly shows the intent of the visitor – raising sickness alerts (and returning AlertReports
). Bear in mind, there could be more types of visitors which could have completely different purposes and return types. For example IPopulationSicknessStatisticsVisitor
which gathers data for statistical analysis rather than for raising alerts. This pattern is all about being open for extensibility!
Consequently, the ISicknessAlertVisitor
interface declares methods for each of the ISicknessAlertVisitable
elements:
public interface ISicknessAlertVisitor { AlertReport Visit(BloodSample blood); AlertReport Visit(XRayImage rtg); AlertReport Visit(EcgReading sample); }
The concrete visitable elements have the simple job of accepting the visitor upon themselves:
public class BloodSample : ISicknessAlertVisitable { //blood sample-specific data and methods public AlertReport Accept(ISicknessAlertVisitor visitor) => visitor.Visit(this); } public class XRayImage : ISicknessAlertVisitable { //xray image-specific data and methods public AlertReport Accept(ISicknessAlertVisitor visitor) => visitor.Visit(this); } //and so on for other visitable data elements
This is where the responsibility of Team A ends. These guys only care about gathering test results and packing them into objects which expose necessary data. They don’t have to know anything about how to recognize a sickness based on this data.
The Extensibility Area
Now, Team B is responsible for creating sickness detectors operating on the ISicknessAlertVisitable
objects produced by Team A. Obviously, this means that they depend on the types created by Team A. The sickness detector team has a dynamically growing number of analysts and developers. Consequently, the number of diseases they detect grows rapidly. We want to add them easily without involving Team A and Team C in it.
Each sickness detector exists in its own class implementation of ISicknessAlertVisitor
:
public class HivDetector : ISicknessAlertVisitor { public AlertReport Visit(BloodSample blood) { Console.WriteLine($"{GetType().Name} - Checking blood sample"); //analyze the blood and return correct risk value return AlertReport.LowRisk; } public AlertReport Visit(XRayImage rtg) { Console.WriteLine($"{GetType().Name} - currently cannot detect HIV based on X-Ray"); return AlertReport.NotAnalyzable; } public AlertReport Visit(EcgReading sample) { Console.WriteLine($"{GetType().Name} - Checking heart rate abnormalities"); //analyze the heart beats and return correct risk value return AlertReport.HighRisk; } }
The team works on similar detectors for cancers, covid and other sicknesses. However, not every test result is useful to detect every disease. This actually visualizes one of the drawbacks of the Visitor pattern. A lot of methods need at least a formal implementation, even though they are not needed. In such cases, we usually return a default result or throw a NotImplementedException
, however, it’s far from being elegant.
We might notice that the visitor methods return an AlertReport
. The return types of the visitor are a minor detail, it is not enforced by the pattern. There is a lot of flexibility available here, for example:
- Visitors that return void could alter the state of a system in which they operate
- They could also alter the state of the elements they visit – however, they can only use the public members of the visitable elements
- Visitors can traverse a tree-like structure of objects and build an aggregated state based on the traversal
- Visitors can return concrete types, as per the example
- Or the visitor interface can be generic to allow extra flexibility for the implementors
Invoking the Visiting in the Client Code
Now, we have to write some code that will demonstrate how the visitors and visitable elements interact. In the case of our sample, this happens in the application maintained by Team C:
public class TestResultsMonitoringApp { private readonly List<ISicknessAlertVisitor> detectors; public TestResultsMonitoringApp(List<ISicknessAlertVisitor> _detectors) { _detectors = detectors; } public List<AlertReport> AnalyzeResultsBatch(IEnumerable<ISicknessAlertVisitable> testResults) { var alertReports = new List<AlertReport>(); foreach (var sample in testResults) { foreach (var detector in _detectors) { alertReports.Add(sample.Accept(detector)); } } return alertReports; } }
Our application instantiates all the possible instances of ISicknessAlertVisitors
somewhere in its composition root. We then access them all as homogeneous objects through dependency injection.
The application gets a batch of ISicknessAlertVisitable
test results and performs the analysis simply by accepting a detector on each of the samples.
The crucial part here is that Team C doesn’t have to change this code when Team A creates more types of tests. Similarly, nothing changes here when Team B creates more detectors or discovers a new way to analyze an existing test result against an existing disease.
Extending the System Which Uses the Visitor Pattern
We have covered the fact that adding or changing visitor implementations is nice and easy. However, let’s have a look at what happens when we need to add a new visitable element. There’s more complexity there.
Team A is now able to gather results from magnetic resonances. Therefore, they proceed to add a new class that implements the IVisitableElement
interface:
public class MrImage: ISicknessAlertVisitable { public AlertReport Accept(ISicknessAlertVisitor visitor) => visitor.Visit(this); //add the usual implementation of the Accept method }
Well, that does not compile. We get a CS1503 error – Argument 1: cannot convert from MrImage to BloodSample
.
Ok, so now we need to update the ISicknessAlertVisitor
interface:
public interface ISicknessAlertVisitor { AlertReport Visit(BloodSample blood); ... AlertReport Visit(MrImage sample); }
Now we’re good and we can release a new version of our package.
Team B updates the dependency and now their code does not compile. All the sickness detectors we have ever created require us to implement the new method:
public class HivDetector : ISicknessAlertVisitor { public AlertReport Visit(BloodSample blood) { //analyze the blood and return correct risk value } // all the other methods for all the other tests results public AlertReport Visit(MrImage sample) { //implement it or handle somehow if it's not supported yet } }
Luckily, Team C does not know nor care about any of this.
Additional Notes About the Visitor Pattern
As mentioned, this is one of the most complex patterns. It is not used too often, not only because of the complexity that it introduces in the code. This pattern comes with a couple of assumptions about the codebase into which we want to introduce it.
The codebase is very large, enterprise-scale. For smaller or simpler projects using this pattern may be overengineering.
There are multiple types of Visitable elements. A Visitor, which only visits one type does not bring the type safety benefit – but it does add all the unnecessary complexity.
The elements do not change often. Updating the Visitors when we change or add visitable elements is a lot of extra work.
There can be multiple implementations of Visitors. Otherwise, the work that Visitor does could better fit as an implementation detail of the Visitable classes. This may violate the Single Responsibility Principle, but is more in line with KISS and YAGNI.
There is a structure of nested objects which need to be traversed and the objects require specific handling. The visitor is then an elegant way of type-safe accessing the nodes.
Pros and Cons of the Visitor Pattern
The pattern brings several benefits. First of all, it allows extending the functionality of the objects without altering them. This is a huge benefit on its own because it helps to produce a more robust and flexible code. The responsibilities for functionalities controlled by the visitors can be then transferred to a separate team. That team does not require in-depth knowledge of the domain objects – or even access to them. This ability to divide work between many teams is a huge advantage in large enterprises.
Consequently, moving certain functionalities outside of a class increases its cohesion. We can simply remove from it any logic that does not truly belong there and keep it in a dedicated Visitor type. This is also a straight path towards compliance with the Single Responsibility Principle. And, in simplest terms – smaller classes are simpler to read and understand.
Another thing that the pattern delivers is the type safety without the conditional type checking and casting logic. On a large scale, this could be a burden to maintain and it could be brittle. The Visitor pattern can prevent us from hurting ourselves, but it comes at a cost.
Arguably, the amount of indirection, additional layers, and complexity that it adds does not outweigh the benefits. For that reason, it tends to find its place more often in enterprise projects than small-to-mid scale. This is not the most versatile tool in a C# developer’s toolbox, but it can find its use.
We should bear in mind that the classic design patterns are language-agnostic and not every pattern is equally useful in all languages. Specifics of the language type system and syntax can make the patterns more useful, or redundant. Consider for example an argument against the Visitor pattern in modern Java.
Conclusion
In this article, we have covered the visitor pattern. We had a look at its structure and how we can implement it in a project. We have also considered how to extend the code that uses this pattern. Summing up, let’s keep in mind that design patterns are not silver bullets. Every pattern has its use, and we as developers are responsible for picking the best solution for the problems we are solving.
Great