Lecture 16: Visitors
16.1 Introduction
In Lecture 3: Representing more complex forms of data we defined a specific object structure: union types. This manifested itself in simpler union types (shapes), recursive union types (lists in Lecture 4: Recursive data structures: Lists (& Abstract Classes Review), hierarchies in Lecture 17: Hierarchical structures). In each of these object structures we defined certain operations by declaring that operation as a method in the top-level interface (Shape, IListOfBooks, GenericListADTNode and Employee), and then implemented it in each class that implemented the interface. But what happens when we wish to define new operations on the same objects?
As an example, consider the organization hierarchy from Lecture 17: Hierarchical structures. The Employee interface declares the operations on this organization hierarchy:
/** * This interface represents a single employee in the * organization. Every employee is guaranteed to have a * name, gender and pay on record */ public interface Employee { ... /** * Count the number of employees in this hierarchy * who fulfill the given predicate * @return the number of employees in this hierarchy * that fulfill the given predicate */ int count(Predicate<Employee> condition); /** * Convert the employee hierarchy into a list. * @return the resulting list */ List<Employee> toList(); }
The above operations are implemented differently on each specific type of Employee. Consider the following new operations on this organization:
Determine the maximum salary in an organization.
Determine if the organization has a valid pay structure. A valid pay structure is defined as one where each employee makes at least as much as all of the employee’s direct and indirect subordinates.
Find an employee with the specified name in the organization
Create a textual description of the organization (e.g. in XML format)
We can think of many more operations on the same hierarchy. All of these operations may not even be known at the time of designing this hierarchy.
Each of these operations can be implemented by defining them on the specific types of employees in the hierarchy. We have the following options to implement them:
For each operation, we can add a new method in the Employee interface and then implement the method in various implementing classes.
The main drawback of this design is that each new operation will cause a change in the Employee interface. If one strictly adheres to the Open for extension, closed for modification principle, this will create several interfaces and classes for hierarchies with different capabilities.
We can implement all these operations by traversing the hierarchy (i.e. using the iterator defined in (part ("lecture14" "hierarchy-iterator"))). However some of these operations may require processing each type of node differently depending on type, and the iterator does not implicitly identify the type of the node. We may have to resort to explicit type-checking in such cases.
As both of the above options have drawbacks, we resort to a third technique. We design these operations to live outside the hierarchy altogether, while still operating on it. We do so by encapsulating such an operation in a single class, that uses the data of the hierarchy from outside it.
16.2 The Visitor Design Pattern
The above problem can be generalized as follows: facilitate defining new operations on an object structure without changing the objects in the structure. Often the object structure is defined as a union type, but this is not required. One solution to this problem is provided by the visitor design pattern.
The visitor pattern has three elements:
Each operation is encapsulated in an object. This is termed the visitor object.
Each object in the object structure provides ways to access its data from outside it, so that visitor objects may operate on them.
Each object in the object structure must open itself up to be “visited”.
We define an interface that represents a visitor on the object structure. The visitor offers methods for each specific type of object to be visited. The result of a visitor can be any data (e.g. visiting to generate XML returns a String, visiting to find an employee returns a boolean, etc.). The visitor may not even return anything (i.e. the visitor is merely a set of instructions that do not compute a specific result). One way to design for such flexibility is to generalize the return type of each method in the visitor.
For example, a visitor for our organization hierarchy is of the form:
/** * This is an interface for the visitor on employees * This interface offers a chance to visit every kind of * concrete employee in the hierarchy. Some visitors may * want this level of granularity, others may not. * @param <R> the type of the return parameter for the * visit */ public interface EmployeeVisitor<R> { R visitContractEmployee(ContractEmployee e); R visitInternalEmployee(InternalEmployee e); R visitNonManagerEmployee(NonManagerEmployee e); R visitSupervisor(Supervisor e); }
Some visitors may require special processing for all types of different employees, others may not.
We now add a method to the Employee interface that makes it “visitable”.
public interface Employee { ... <R> R accept(EmployeeVisitor<R> visitor); }
Each specific employee implements the object by calling the appropriate method in the visitor and passing itself. The visitor then “operates” on this object. This method returns whatever the visitor returned.
//NonManagerEmployee @Override public <R> R accept(EmployeeVisitor<R> visitor) { return visitor.visitNonManagerEmployee(this); } //InternalEmployee @Override public <R> R accept(EmployeeVisitor<R> visitor) { return visitor.visitInternalEmployee(this); } //ContractEmployee @Override public <R> R accept(EmployeeVisitor<R> visitor) { return visitor.visitContractEmployee(this); } //Supervisor @Override public <R> R accept(EmployeeVisitor<R> visitor) { return visitor.visitSupervisor(this); }
We can now work with visitors as follows:
EmployeeVisitor<X> visitor = new ...; //some visitor Employee node = ...; //some node X result = node.accept(visitor); //visitor: visit this node
Note that this results in a double-dispatch: the node-type will call the appropriate implementation of the accept method, and the visitor-type will call the visitXXX method of the specific visitor passed to it.
16.2.1 Computing the maximum salary in an organization
The maximum salary in an organization can be defined as follows: “the maximum salary in a hierarchy rooted at employee R is defined as the maximum of the salary of R, and the maximum salary of each of the hierarchies of its supervisees. If R is not a manager then the maximum is R’s salary.”
16.2.1.1 Design and tests
We define a visitor for this purpose:
/** * This visitor computes the maximum salary in an * organization. */ public class MaxSalaryVisitor implements EmployeeVisitor<Double> { @Override public Double visitContractEmployee(ContractEmployee e) { ... } @Override public Double visitInternalEmployee(InternalEmployee e) { ... } @Override public Double visitNonManagerEmployee(NonManagerEmployee e) { ... } @Override public Double visitSupervisor(Supervisor e) { ... } }
Given this design, we can now write tests for this visitor.
public class EmployeeTest { Employee ccis; Employee startup; @Before public void setup() { //set up ccis and startup: see accompanying code } @Test public void testMaximumSalary() { assertEquals(new Double(400000),ccis.accept(new MaxSalaryVisitor())); assertEquals(new Double(50000),startup.accept(new MaxSalaryVisitor())); } }
16.2.1.2 Implementation
We note that this operation distinguishes between two categories of employees: those that have supervisees and those that do not. Thus all non-manager employees can be processed uniformly. Accordingly, we define two helper methods: one for any manager-type employee and another for any other type. We then call them appropriately in the various visitXXX methods.
public class MaxSalaryVisitor implements EmployeeVisitor<Double> { @Override public Double visitContractEmployee(ContractEmployee e) { return maxSalary(e); } @Override public Double visitInternalEmployee(InternalEmployee e) { return maxSalary(e); } @Override public Double visitNonManagerEmployee(NonManagerEmployee e) { return maxSalary(e); } @Override public Double visitSupervisor(Supervisor e) { return maxSalary(e); } private Double maxSalary(NonManagerEmployee e) { ... } private Double maxSalary(Supervisor e) { ... } }
Finally we implement the above definition of this operation: the “manager” employee recurs into the hierarchy and the “non-manager” employee reports its own salary.
public class MaxSalaryVisitor implements EmployeeVisitor<Double> { ... private Double maxSalary(NonManagerEmployee e) { return e.getAnnualPay(); } private Double maxSalary(Supervisor e) { Double max = e.getAnnualPay(); for (Employee emp: e.getSupervisees()) { max = Math.max(max,emp.accept(this)); } return max; } }
Note that the visitor needs access to the salary of an employee. Thus it relies on the Employee interface providing a method to get the salary data of an employee.
16.2.2 Is the pay structure valid?
The pay structure of an organization is valid if every manager makes more than any of its subordinates (direct or indirect). In other words, the maximum salary of every hierarchy must be determined by the employee at the root of that hierarchy.
We define this operation as follows: “The pay structure of a hierarchy is valid if the salary of its root employee is greater than the maximum salary of each of its supervisee hierarchies. The pay structure is (trivially) valid for a hierarchy with one employee.”.
16.2.2.1 Design and tests
We define a visitor for this operation. This visitor should return a boolean value, so we use the Boolean type.
/** * This visitor implements the operation: "Does every * manager make more than all of the subordinates of that * manager?" The subordinate is anybody who works under * this supervisor, directly or indirectly */ public class ValidPayScaleVisitor implements EmployeeVisitor<Boolean> { ... }
We can test for it as follows:
@Test public void testValidPay() { assertFalse(startup.accept(new ValidPayScaleVisitor())); //insert an anomaly in startup assertTrue(ccis.accept(new ValidPayScaleVisitor())); }
16.2.2.2 Implementation
Like the first visitor, this operation also distinguishes only between manager and non-manager employees. So we design similarly, delegating to helper methods.
public class ValidPayScaleVisitor implements EmployeeVisitor<Boolean> { public ValidPayScaleVisitor() { } @Override public Boolean visitContractEmployee(ContractEmployee e) { return true; } @Override public Boolean visitInternalEmployee(InternalEmployee e) { return true; } @Override public Boolean visitNonManagerEmployee(NonManagerEmployee e) { return true; } @Override public Boolean visitSupervisor(Supervisor e) { Boolean result = true; for (Employee emp:e.getSupervisees()) { result = result && emp.accept(this) //sub-hierarchy is valid && e.getAnnualPay()>emp.accept(new MaxSalaryVisitor()); } return result; } }
16.2.3 Visits with arguments
We defined a method to count the number of employees in a hierarchy that fulfilled a specific condition.
public interface Employee { ... /** * Count the number of employees in this hierarchy * who fulfill the given predicate * @return the number of employees in this hierarchy * that fulfill the given predicate */ int count(Predicate<Employee> condition); }
We had the following tests for this operation.
@Test public void testGetSize() { assertEquals(13,ccis.count(e->true)); assertEquals(6,ccis.count(e->e.getGender()==Gender.Female)); assertEquals(7,ccis.count(e->e.getGender()==Gender.Male)); assertEquals(8,startup.count(e->true)); assertEquals(1,startup.count(e->e.getGender()==Gender.Female)); assertEquals(5,startup.count(e->e.getGender()==Gender.Male)); assertEquals(2,startup.count(e->e.getGender()==Gender.UnDisclosed)); } @Test public void testEmployeePay() { assertEquals(5,ccis.count(b -> b.getAnnualPay()>150000)); assertEquals(1,ccis.count(b -> b.getAnnualPay()>300000)); }
We can express this operation as a visitor.
16.2.3.1 Design and testing
Unlike the count method, our EmployeeVisitor<R> interface does not allow passing arguments to its methods. We could change the visitor to add this functionality, but it is not scalable (we may have an unknown number of arguments, each of a unique type). We circumvent this limitation by passing the Predicate object as an argument to the constructor of the visitor.
/** * This visitor performs counting operations on the * hierarchy. It can work on an optional predicate to * filter certain nodes. */ public class CountingVisitor implements EmployeeVisitor<Integer> { private Predicate<Employee> condition; public CountingVisitor() { //default is that the predicate is a tautology condition = e->true; } public CountingVisitor(Predicate<Employee> c) { condition = c; } ... }
Now our tests change to:
@Test public void testGetSize() { assertEquals(new Integer(13), ccis.accept(new CountingVisitor(e -> true))); assertEquals(new Integer(6), ccis.accept(new CountingVisitor(e -> e.getGender() == Gender.Female))); assertEquals(new Integer(7), ccis.accept(new CountingVisitor(e -> e.getGender() == Gender.Male))); assertEquals(new Integer(8), startup.accept(new CountingVisitor(e -> true))); assertEquals(new Integer(1), startup.accept(new CountingVisitor(e -> e.getGender() == Gender.Female))); assertEquals(new Integer(5), startup.accept(new CountingVisitor(e -> e.getGender() == Gender.Male))); assertEquals(new Integer(2), startup.accept(new CountingVisitor(e -> e.getGender() == Gender.UnDisclosed))); } @Test public void testEmployeePay() { assertEquals(new Integer(5), ccis.accept(new CountingVisitor(b -> b.getAnnualPay() > 150000))); assertEquals(new Integer(2), ccis.accept(new CountingVisitor(b -> b.getAnnualPay() > 300000))); }
Do Now!
Implement the CountingVisitor visitor.
Do Now!
Implement the List<Employee> toList() operation on employees as a visitor.
16.2.4 Text representation of an organization
It may be desirable to convert the organization into text. This may be useful to display the contents of an organization, or to persist the organization to a file. Text representation may even capture the hierachical nature of our organization (e.g. using the XML format).
Expressing such operations as visitors have unique advantages:
If multiple text representations are to be supported, we do not have to clutter the employee classes with methods for each specific representation. This makes it possible to support multiple representations while keeping the employee classes more cohesive.
If the textual representation is used to display the organization in an MVC architecture, putting this operation in the organization violates the separation between model and view (a method in the model should not perform operations specific to a view)
We now express the organization hierarchy in XML format. We define tags for each specific kind of employee:
A general non-managerial employee: this is represented by <nonmanageremployee name=".."></nonmanageremployee>
Internal employee: this is represented by <internalemployee name=".."></internalemployee>.
Contract employee: this is represented by <contractemployee name=".." endDate=".." endMonth=".." endYear=".."></contractemployee>. Note that this type of employee has more data.
Supervisor employee: this representation captures the hierarchical nature of the organization. It is represented by <supervisor name=".."> XML representation of all its subordinates </supervisor>.
Note that unlike previous visitors, this visitor has unique implementations for each type of employee.
16.2.4.1 Implementation
We define the methods for various kinds of non-managerial employees.
public class XMLWriter implements EmployeeVisitor<String> { @Override public String visitContractEmployee(ContractEmployee e) { return "<contractemployee name=\"" +e.getName() +"\" endDate=\"" +e.getEmploymentEndDate().getDayOfMonth() +"\" endMonth=\"" +e.getEmploymentEndDate().getMonthValue() +"\" endYear=\"" +e.getEmploymentEndDate().getYear() +"\"></contractemployee>"; } @Override public String visitInternalEmployee(InternalEmployee e) { return "<internalemployee name=\"" +e.getName() +"\"></internalemployee>"; } @Override public String visitNonManagerEmployee(NonManagerEmployee e) { return "<nonmanager name=\"" +e.getName() +"\"></nonmanager>"; } }
Finally we implement the recursive implementation for the supervisor-type employee
@Override public String visitSupervisor(Supervisor e) { StringBuilder sb = new StringBuilder(); sb.append("<supervisor name=\"" +e.getName() +"\">"); for (Employee emp:e.getSupervisees()) { sb.append(emp.accept(this)); //append XML of supervisee } sb.append("</supervisor>"); return sb.toString(); }
16.2.4.2 Design and testing
We define a visitor to create the XML representation of a hierarchy.
/** * This visitor is used to create and return the XML * representation of the employee tree as a string */ public class XMLWriter implements EmployeeVisitor<String> { ... }
We can write tests for this visitor (please see accompanying code).
16.3 Visitors with state
The above example designed the visitor interface so that its methods return a single (generic-type) data. However we may have to write operations that result in more than one pieces of data, possibly of different types. In such cases we can design visitors to have state. The state can be used to store results, and also other data that is relevant to the operation being implemented. In this section we exemplify this design by defining visitors on the shape classes from Lecture 5.
16.3.1 The Shape classes: a recap
In Lecture 5 we represented simple shapes as union types. Operations on shapes were defined in a Shape interface.
public interface Shape extends Comparable<Shape> { /** * Returns the distance of this shape from the origin. The distance is * measured from whatever reference position a shape is * (e.g. a center for a circle) * * @return the distance from the origin */ double distToOrigin(); /** * Computes and returns the area of this shape. * * @return the area of the shape */ double area(); /** * Computes and returns the perimeter of this shape. * * @return the perimeter of the shape */ double perimeter(); /** * Create and return a shape of the same kind as this * one, resized in area by the provided factor * * @param factor factor of resizing * @return the resized Shape */ Shape resize(double factor); }
16.3.2 Visitable shapes
We now make the shapes visitable, thereby redefining many of the above operations with visitors. The modified interface is:
public interface Shape { /** * Returns the distance of this shape from the origin. * The distance is measured from whatever reference * position a shape is (e.g. a center for a circle) * * @return the distance from the origin */ double distToOrigin(); /** * NOTE: we removed area(), perimeter() and resize(...) * and implemented them as visitors. * * NOTE: this method does not return anything. */ void accept(ShapeVisitor visitor); }
We assume the same three types of shapes: Circle, Rectangle and Square. Each of them implement the accept method.
//Circle @Override public void accept(ShapeVisitor visitor) { visitor.visitCircle(this); } //Rectangle @Override public void accept(ShapeVisitor visitor) { visitor.visitRectangle(this); } //Square @Override public void accept(ShapeVisitor visitor) { visitor.visitSquare(this); }
Our original implementation of these classes did not have any operations specific to that shape (for example, return the radius of a circle, the width and height of a rectangle, etc.). We now define these shape-specific methods in their respective classes.
This design change makes the specific shape implementations more specific. We could have added all these methods in the Shape interface and then suppressed them in irrelevant classes by throwing exceptions. But we choose not to do so, and thus prefer type-safety over uniformity. This means that we need to know the specific type of shape before calling specific methods on it. Refer back to this discussion: (part ("lecture8" "composite-unique-methods"))
//In Circle public double getRadius() { return radius; } //In Rectangle public double getWidth() { return width; } public double getHeight() { return height; } //In Square public double getSide() { return getWidth(); }
Our shape visitor interface is as follows:
/** * This interface represents a visitor for shapes. It has * a visit method for each kind of shape. * * If the visitor computes a value and must return it, * then it is handled by a get method in the specific * visitor. */ public interface ShapeVisitor { /** * Visit a circle * @param c the Circle object to be visited */ void visitCircle(Circle c); /** * Visit a rectangle * @param r the Rectangle object to be visited */ void visitRectangle(Rectangle r); /** * Visit a square * @param s the Square object to be visited */ void visitSquare(Square s); }
Note that, as before, the visitor allows us to write implementation that are specific to a shape.
16.3.3 Calculating area as a visitor
We write a visitor that can be used to compute the area of a shape.
16.3.3.1 Design and testing
Our visitor computes the area, and remembers it as part of its state. It offers a method to get the area that it has computed.
public class AreaVisitor implements ShapeVisitor { private double area; public AreaVisitor() { area = -1; } public double getArea() { if (area<0) { throw new IllegalStateException("This visitor has not been used yet."); } return area; } ... }
Since the visitor has state, we must be careful to ensure that its state is always valid. The getArea() method will throw an exception if it is called before this visitor object is used to visit a shape. However once it visits a shape it will have an area to report. If we wish to use the same visitor object to compute the area of different shapes, we must be careful to ensure that this method behaves appropriately if called “in the middle of a visitation”.
We can write tests for it as follows:
@Test public void testArea() { //NOTE: see accompanying code for setup AreaVisitor aVisitor = new AreaVisitor(); circle1.accept(aVisitor); assertEquals(Math.PI*25,aVisitor.getArea(),0.001); rect1.accept(aVisitor); assertEquals(5,aVisitor.getArea(),0.001); square1.accept(aVisitor); assertEquals(100,aVisitor.getArea(),0.001); }
16.3.3.2 Implementation
The implementation is straightforward. It uses the shape-specific methods defined in each shape class.
@Override public void visitCircle(Circle c) { area = Math.PI * c.getRadius()*c.getRadius(); } @Override public void visitRectangle(Rectangle r) { area = r.getHeight()*r.getWidth(); } @Override public void visitSquare(Square s) { area = s.getSide()*s.getSide(); }
Do Now!
Implement a visitor to compute the perimeter of a shape.
16.3.4 Resizing a shape
The resizing operation requires a parameter: the resizing factor. Also this operation returns a shape of the type that was resized (resizing a rectangle returns a rectangle, etc.). We define a visitor whose state comprises of the resize factor and the resized shape.
16.3.4.1 Design and testing
We define our visitor as follows:
public class ResizeVisitor implements ShapeVisitor { private Shape resizedShape; private double factor; public ResizeVisitor(double factor) { resizedShape = null; this.factor = factor; } public Shape getResizedShape() { if (resizedShape == null) { throw new IllegalStateException("This visitor has not been used yet."); } return resizedShape; } ... }
We can write tests for it as follows:
@Test public void testResizes() { Shape resizedCircle1,resizedRect1,resizedSquare1; ResizeVisitor rVisitor; //use area to confirm that resize is correct AreaVisitor aVisitor = new AreaVisitor(); double before,after; //resize a circle rVisitor = new ResizeVisitor(2.5); circle1.accept(rVisitor); resizedCircle1 = rVisitor.getResizedShape(); circle1.accept(aVisitor); before = aVisitor.getArea(); resizedCircle1.accept(aVisitor); after = aVisitor.getArea(); assertEquals(2.5*before,after,0.001); //resize a rectangle rVisitor = new ResizeVisitor(12.5); rect1.accept(rVisitor); resizedRect1 = rVisitor.getResizedShape(); rect1.accept(aVisitor); before = aVisitor.getArea(); resizedRect1.accept(aVisitor); after = aVisitor.getArea(); assertEquals(12.5*before,after,0.001); //resize a square rVisitor = new ResizeVisitor(2); square1.accept(rVisitor); resizedSquare1 = rVisitor.getResizedShape(); square1.accept(aVisitor); before = aVisitor.getArea(); resizedSquare1.accept(aVisitor); after = aVisitor.getArea(); assertEquals(2*before,after,0.001); }
16.3.4.2 Implementation
The implementation is straightforward, and is unique to each shape type.
@Override public void visitCircle(Circle c) { resizedShape = new Circle(c.getX(), c.getY(), Math.sqrt(factor) * c.getRadius()); } @Override public void visitRectangle(Rectangle r) { resizedShape = new Rectangle(r.getX(), r.getY(), Math.sqrt(factor) * r.getWidth(), Math.sqrt(factor) * r.getHeight()); } @Override public void visitSquare(Square s) { resizedShape = new Square(s.getX(), s.getY(), Math.sqrt(factor) * s.getSide()); }
16.3.5 A Hidden Visitor
In (part ("lecture5" "equality-double-dispatch")) we defined the equality operation for shapes. We added shape-specific methods equalsCircle, equalsRectangle and equalsSquare to the abstract class and overrode them suitably in the subclasses. Although it worked correctly, these methods in the abstract class broke the Dependency Inversion principle (abstractions should not depend on concrete implementations). We now attempt to remove this drawback by using visitors.
Unlike all previous operations, equality is a binary operation (i.e. it works on a pair of Shape objects). A visitor can be used to define an operation on a single shape at a time. We keep one of the shapes as part of the state of the visitor, so that its methods have access to both shapes.
16.3.5.1 Design of binary operations using visitors
Consider the specific binary operation c.equals(s) where c is a Circle and s is any of Circle, Rectangle and Square. We define a visitor that implements equality correctly for a circle.
class CircleEqualityVisitor implements ShapeVisitor { private boolean result; private Circle circle; public CircleEqualityVisitor(Circle obj) { circle = obj; result = false; } public boolean getResult() { return result; } @Override public void visitCircle(Circle c) { //meaningful Circle-Circle equality result = c.reference.dist(circle.reference) < 0.001 && Math.abs(c.getRadius() - circle.getRadius()) < 0.001; } @Override public void visitRectangle(Rectangle r) { result = false; //circle not equal to rectangle } @Override public void visitSquare(Square s) { result = false; //circle not equal to square } }
If we visit s with the above visitor, then s would call one of the above methods depending on its type (due to dynamic dispatch). If s is also a Circle object, then the visitCircle method would be called and the two objects would be meaningfully compared. Otherwise either visitRectangle or visitSquare would be called, producing a result of false.
We implement this design as follows: each type of shape defines its own “Equality visitor” like the above. Then we override its equals method to instantiate its equality visitor and visit the other shape with it. For example, the Circle class implements its equals method as follows:
//Circle @Override public boolean equals(Object other) { if (this == other) { return true; } if (!(other instanceof Shape)) { return false; } CircleEqualityVisitor visitor = new CircleEqualityVisitor(this); //visitor stores this ((Shape)other).accept(visitor); //visit other shape with the circle's visitor return visitor.getResult(); //return visitor's result }
In order to make the Circle class self-contained, we make CircleVisitor its private inner class. Thus, contrary to our design in Lecture 5, the Circle class is self-contained. We include the implementation of the Rectangle class below (that of the Square class would be similar):
public class Rectangle extends AbstractShape { ... @Override public void accept(ShapeVisitor visitor) { visitor.visitRectangle(this); } @Override public boolean equals(Object other) { if (this == other) { return true; } if (!(other instanceof Shape)) { return false; } RectangleEqualityVisitor visitor = new RectangleEqualityVisitor(this); ((Shape)other).accept(visitor); return visitor.getResult(); } private class RectangleEqualityVisitor implements ShapeVisitor { private boolean result; private Rectangle rect; public RectangleEqualityVisitor(Rectangle rect) { this.rect = rect; result = false; } public boolean getResult() { return result; } @Override public void visitCircle(Circle c) { result = false; } @Override public void visitRectangle(Rectangle r) { result = r.reference.dist(rect.reference)<0.001 && Math.abs(r.getHeight()-rect.getHeight())<0.001 && Math.abs(r.getWidth()-rect.getWidth())<0.001; } @Override public void visitSquare(Square s) { result = false; } } }
The abstract class no longer contains methods are specific to subclasses, restoring the Dependency Inversion principle.
Exercise
Implement an operation that returns the smallest rectangle that encloses two given shapes.
16.4 Limitation of visitors
The visitor design pattern has some important limitations.
16.4.1 Breaking Encapsulation
The visitor design pattern is built on the premise of defining operations on (a set of) objects outside them, so that adding newer operations does not require changes to those objects. However this very characteristic breaks encapsulation: an object no longer contains an operation inside it that is relevant to it.
The visitor design pattern should be avoided when one does not wish to give clients the ability to define new operations in future on pre-designed objects.
16.4.2 Changing object structure
The visitor design pattern defines visitXXX methods on each specific object type. This makes it difficult to add new object types, because doing so would necessitate changing the visitor interface. This would cause cascading changes in all existing visitors.
This is not necessarily a negative aspect. When a designer makes a set of objects visitable, he/she explicitly signals that the set is complete and closed. This is what enables clients to safely write new visitors on the set of objects. If a new object is added to the set, then it must be made visitable as well. When the designer changes the visitor interface for this purpose, existing visitors no longer compile. This forces clients to acknowledge the change in design and take remedial action.
The Shape classes are admittedly not the best candidates for visitors. This is because in many applications, the set of supported shape types cannot be fixed.
This behavior adds rigor to the design, but it does present a practical software engineering challenge. What happens if a designer anticipates the set of objects to be complete and closed, defines a visiting hierarchy on them, but is forced later to add new objects to this set possibly to add new functionality? In (part ("lecture5" "equality-double-dispatch")) this took a simple form: what if we add a Triangle shape?
While there is no perfect solution to this, a compromise design is possible. We add a “none-of-the-above” option to our visitor interface. For example, the shape visitor interface would be:
public interface ShapeVisitor { /** * Visit a circle * @param c the Circle object to be visited */ void visitCircle(Circle c); /** * Visit a rectangle * @param r the Rectangle object to be visited */ void visitRectangle(Rectangle r); /** * Visit a square * @param s the Square object to be visited */ void visitSquare(Square s); /** * Visit a generic shape. This is a none-of-the-above * option. It may be useful if we wish to add support * for newer shapes in specific visitors. * @param shape the Shape object to be visited */ void visitShape(Shape shape); }
The abstract shape class now implements the accept method by calling the visitShape method on the visitor (this can be implemented as a default method in the Shape interface, guaranteeing a default accept implementation for all shapes). Existing shapes override this functionality as before, but a new shape would be visited using visitShape. If a visitor (existing or new) wishes to support visiting this new shape meaningfully, it may do so by identifying its type explicitly in visitShape and taking necessary action.
Although viable there are two drawbacks of this approach. Both drawbacks are emblematic of the misuse of the visitor pattern (using it on a set of objects that is not closed).
Any new shapes (objects) necessitate explicit type checking instead of leveraging dynamic dispatch.
As many more shapes (objects), this none-of-the-above visit method becomes longer and less cohesive. At some point, one must refactor it by adding new visitXXX methods in the visitor interface and making necessary changes to all existing visitors.
Due to this, this design is more of a stop-gap rather than a definitive solution to the above problem.