|
Visitor is an established name of one of the behavioral design patterns. The last time I have heard it was during the heated discussion on one of the newsgroups in a thread that somehow evolved into the "how to recognize a competent C++ programmer" flame. Participants got quickly divided, and some have even expressed their opinion that patterns in general are completely useless; at the other side of the spectrum were those that presented various examples for the contrary. An interesting part of this discussion was that the Visitor pattern was for some reason perceived as a barrier that is a "litmus test" of programming literacy - up to the point that in the view of some participants an advanced programmer was supposed to actually enjoy this pattern.
I will try to debunk this Visitor myth here.
Consider the following example as a basis for later discussion. There is a class hierarchy and a simple Visitor pattern that does "something" with objects stored in a polymorphic collection:
class Hammer; class Drill; class Saw; class Visitor { public: void visit(Hammer & h) = 0; void visit(Drill & d) = 0; void visit(Saw & s) = 0; }; // root of the given hierarchy class Tool { public: virtual void accept(Visitor & v) = 0; // regular operations of Tool omitted }; class Hammer : public Tool { public: virtual void accept(Visitor & v) { v.visit(*this); } // regular operations of Hammer omitted }; class Drill : public Tool { public: virtual void accept(Visitor & v) { v.visit(*this); } // regular operations of Drill omitted }; class Saw : public Tool { public: virtual void accept(Visitor & v) { v.visit(*this); } // regular operations of Saw omitted }; class DoSomethingVisitor : public Visitor { public: void visit(Hammer & h) { // do something with the hammer } void visit(Drill & d) { // do something with the drill } void visit(Saw & s) { // do something with the saw } }; vector<Tool *> myToolBox; // filled with lots of tools void doSomethingWithAllTools() { DoSomethingVisitor v; for (size_t i = 0; i != myToolBox.size(); ++i) { Tool & t = *(myToolBox[i]); t.accept(v); } }
That's it - the Visitor in action. Explaining it in a single sentence might be a challenge, but here it goes: the operation to be performed on a polymorphic set of objects is encapsulated in an object of an associated class and the ping-pong involving a combination of virtual functions and overloading takes care of correctly directing each processed object to its relevant handler in that associated class.
What is really wrong with the above example?
First of all, the Visitor pattern is intrusive. It has a direct impact on the interface that needs to be integrated in the given hierarchy of classes. To better understand the significance of this impact, think of all the class hierarchies that you have seen so far. I/O classes, GUI widgets, database access utilities, collections,... How many of these different hierarchies had a dedicated accept
function? Applying the Visitor pattern to the existing class hierarchy in all practical cases requires modification of all the classes in that hierarchy. This is rarely possible, especially with standard or third-party libraries.
This seems not to be a problem with hierarchies developed in-house, but the reality is different - the classes must be aware of the fact that they take part in the pattern, which immediately introduces the coupling between two hierarchies (in other words, the hierarchy of "subjects" depends at least on the base visitor class, with all its design and physical consequences). The visitor hierarchy depends on the "subject" classes anyway, which leads to the mutual dependency between two sets of classes - turning the whole into what is actually a single software component.
Another problem with the Visitor pattern is that in a non-obvious way it breaks one of the major principles of the object-oriented design: The Open-Closed Principle. The main point of this principle is the assumption that the behavior of the system can be modified by adding new classes to the system and that this can be done without modifying any existing code. This principle is not met with Visitor, because adding a new class in the "subject" hierarchy (for example, by introducing the PneumaticHammer
class) involves the modification in the whole Visitor hierarchy in the sense that a new function would need to be added to each class in that hierarchy. This way one of the most important benefits of OO is simply unreachable.
Other popular lines of criticism involve the following arguments:
The above arguments enforce the perception that the Visitor pattern is generally confusing and does not play nice with the commonly understood and accepted concepts.
The alternative approach can involve the following helper entities:
enum TypeTag { Hammer_tag, Drill_tag, Saw_tag /*, PneumaticHammer_tag, ... */ }; TypeTag getTypeTag(const Tool & t);
It does not really matter how the getTypeTag
function can be implemented. The techniques can range from obvious but obscure series of if
+ dynamic_cast
branches to some smart use of hash maps. In any case, it is possible to efficiently provide some kind of type identifier that relates to the dynamic type of the given Tool
object.
With the above helpers in place, the original Visitor example can be rewritten in the following way:
// root of the given hierarchy class Hammer : public Tool { public: // regular operations of Hammer omitted }; class Drill : public Tool { public: // regular operations of Drill omitted }; class Saw : public Tool { public: // regular operations of Saw omitted }; vector<Tool *> myToolBox; // filled with lots of tools void doSomethingWithAllTools() { for (size_t i = 0; i != myToolBox.size(); ++i) { Tool & t = *(myToolBox[i]); TypeTag tag = getTypeTag(t); switch (tag) { case Hammer_tag: { Hammer & h = static_cast<Hammer &>(t); // do something with the hammer } break; case Drill_tag: { Drill & d = static_cast<Drill &>(t); // do something with the drill } break; case Saw_tag: { Saw & s = static_cast<Saw &>(t); // do something with the saw } break; } } }
Yes, this is the old-style, non-OO solution. The same that is criticized for being error-prone and the same that is a motivating example for explaining the benefits of OO in many books.
Why do I show this as a valid solution, then? The point is - the reasons for the above being bad are also preserved with the Visitor pattern. The easiest way to verify this is to see what happens when the new class is added to the original hierarchy.
With the above alternative scheme, the following have to be done:
switch
statement has to be added in the doSomethingWithAllTools
function (and every other function similar to this one) to provide the appropriate implementation of the given operation for the new type.Now let's compare it with the original Visitor pattern, where adding a new class to the given hierarchy implies the following:
visit
function needs to be added to the base Visitor
class.DoSomethingVisitor
(and in every other "operation" class).Sound similar?
It can be argued that the above two lists of tasks to do are conceptually equivalent. The modifications that are implied by adding new class to the original hierarchy have exactly the same character in both versions.
The advantage of this alternative scheme is that it is completely non-intrusive with regard to the original hierarchy. No accept
functions are needed and the hierarchy does not have to depend on any other component. This means that the solution can be applied to third-party hierarchies without any problem.
Another advantage of this solution is that the code is actually more compact and that the number of all involved program entities is smaller than with the original version.
The criticism of the above solution can be based on the fact that the original Visitor pattern provides stricter compile-time integrity checking. After adding a new pure-virtual function in the base Visitor
class, all classes from the Visitor hierarchy have to be completed accordingly and the compiler will simply refuse to accept any "forgotten" part of the program. This is not the case with the alternative solution, where the switch
statement does not provide the so-called full-coverage guarantee. This is true for C++ and Java, but also has some important counterarguments - the lack of appropriate guarantee from the switch
statement is only a deficiency of the particular language. There are languages that provide strict variants of this statement and they can provide the required guarantee at compile-time. For example, the Ada programming language has the case
/when
statement that will not compile if the set of alternative branches does not fully cover the type of its controlling expression, so that extending the enumeration type with new literal forces the programmer to fix all relevant case
/when
statements - everywhere. In this case, instead of stating that the Visitor pattern is good because C++ and Java have deficient switch
control structure, it would be better to state that it is a pity that the deficiencies in the language lead programmers to use poor patterns.
Note also that the switch
problem can be mitigated at least with some compilers - for example, use g++ -Wswitch
to enforce full enum coverage in switch
statements.
With the above issues in mind, the Visitor is not really a design pattern - rather just a language idiom. Most often over-emphasized.