Today, the Visitor pattern in Java is no longer needed – it is better to use pattern switches

In modern Java, the Visitor pattern is no longer needed. It is well compensated by the use of sealed types and switches using pattern matching, which achieve the same goals more easily and with less code.

Every time you find yourself in a situation where you could apply pattern Visitor, consider using the more modern features of the Java language instead. Of course, these features can be used in other circumstances, but in this article we will discuss a relatively narrow topic: how to replace the Visitor pattern. To do this, I’ll start with as brief an introduction as possible and give an example, and then I’ll explain how to achieve the same goals with simpler (and concise) code.

▚ Pattern Visitor

Wikipedia says:

The Visitor design pattern allows you to decouple an algorithm from the structure of the object it operates on. The practical result of this detachment is the ability to add new operations to existing object structures without modifying those structures.

The main motivation here is precisely not to change the structure. If there are many operations performed on an object, or the operations performed on it are very different, then implementing them on already involved types can easily overload the types with a mass of functions that are not related to each other. Of course, you can change these types only if they are not burdened with dependencies.

Primary Motivation: Don’t Change Types

With the Visitor pattern, each operation is implemented in the Visitor so that it is then passed to an object structure, which then passes the objects it consists of to the Visitor. The structure does not know anything about any particular Visitor, so it can be freely created whenever this or that operation is needed.

Here’s an example from Wikipedia (slightly shortened):

public class VisitorDemo {

    public static void main(final String[] args) {
        Car car = new Car();
        car.accept(new CarElementPrintVisitor());
    }

}

// Супертип всех объектов в структуре
interface CarElement {

    void accept(CarElementVisitor visitor);

}

// Супертип всех операций
interface CarElementVisitor {

    void visit(Body body);
    void visit(Car car);
    void visit(Engine engine);

}

class Body implements CarElement {

  @Override
  public void accept(CarElementVisitor visitor) {
      visitor.visit(this);
  }

}

class Engine implements CarElement {

  @Override
  public void accept(CarElementVisitor visitor) {
      visitor.visit(this);
  }

}

class Car implements CarElement {

    private final List<CarElement> elements;

    public Car() {
        this.elements = List.of(new Body(), new Engine());
    }

    @Override
    public void accept(CarElementVisitor visitor) {
        for (CarElement element : elements) {
            element.accept(visitor);
        }
        visitor.visit(this);
    }

}

class CarElementPrintVisitor implements CarElementVisitor {

    @Override
    public void visit(Body body) {
        System.out.println("Visiting body");
    }

    @Override
    public void visit(Car car) {
        System.out.println("Visiting car");
    }

    @Override
    public void visit(Engine engine) {
        System.out.println("Visiting engine");
    }

}

There are a number of things that I would have done differently (Car inherits CarElement? Seriously?!), but to keep the comparison simple, I decided to stick to the original as closely as possible.

A lot has already been written about the Visitor pattern (use cases, prerequisites, implementation, limitations, etc.), so there is no need to repeat it all here. Let’s just assume that we find ourselves in a situation where it really makes sense to use this pattern. So here’s what we would use instead.

▚ Language options

In the modern Java language, there are more convenient ways to achieve the purposes for which the Visitor pattern is intended – so it becomes redundant.

▚ Definition of additional operations

The main task of the Visitor pattern is to provide the implementation of new functionality, one that is closely related to the collection of types, but:

  • Without changing these types (other than setting up a new configuration once)

  • At the same time, providing the convenience of maintaining the code that will result

This is achieved like this:

  • You can create a separate implementation of the Visitor for each operation (without touching the types on which it is performed)

  • Each Visitor is required to be able to process all relevant classes (otherwise they won’t compile)

Here is the part of the pattern

// При добавлении нового посещенного типа заставим его реализовать
// этот интерфейс. Единственная приемлемая реализация
// `accept` - это `visitor.visit(this)`, которая (пока)
// не компилируется
// ~> проследить ошибку
interface CarElement {

	void accept(CarElementVisitor visitor);

}

// Чтобы исправить ошибку здесь, добавим здесь новый метод,
// что приведет к ошибкам компиляции в каждом из имеющихся
// Посетителей.
// ~> это хорошо, так вы можете убедиться, что добавили новый тип везде, где нужно
interface CarElementVisitor {

	void visit(Body body);
	void visit(Car car);
	void visit(Engine engine);

}

// После добавления нового типа этот класс перестанет компилироваться
// до тех пор, пока вот здесь не будет добавлен соответствующий метод `visit`.
class CarElementPrintVisitor implements CarElementVisitor {

	@Override
	public void visit(Body body) {
		System.out.println("Visiting body");
	}

	@Override
	public void visit(Car car) {
		System.out.println("Visiting car");
	}

	@Override
	public void visit(Engine engine) {
		System.out.println("Visiting engine");
	}

}

Thanks to the innovations of the Java language, these goals are now much easier to achieve:

  1. We create sealed interface for all types involved in these operations

  2. Whenever a new operation is required, we use type patterns in switching modeto implement this feature (exists in preview in Java 17)

Sealed interface, switcher and pattern matching

sealed interface CarElement
	permits Body, Engine, Car { }

final class Body implements CarElement { }

final class Engine implements CarElement { }

final class Car implements CarElement {

	// ...

}

// во всех других местах, где у вас есть `CarElement`:
// один фрагмент кода на операцию – этот, например, выводит такой код
String message = switch (element) {
	case Body body -> "Visiting body";
	case Car car -> "Visiting car";
	case Engine engine -> "Visiting engine";
	// обратите внимание, что тут нет ветки `default` - это важно!
};
System.out.println(message);

Let’s take it in order:

  • switch(element) switches code from element to element

  • On each case, it is checked whether the given instance belongs to the specified type

    • If so, then a variable of this type is created under a new name

    • The switch then results in the line to the right of the arrow

  • The switch must necessarily result in result, which is then assigned to message

This “be sure to result in result” works even without the default branch, since CarElement is sealed, which lets the compiler (and also your colleagues) know that only the enumerated types directly implement it. The compiler can apply this knowledge when switching patterns and determine that the cases listed are exhaustive, that is, that the code has been checked for all possible implementations.

So when you add a new type to a sealed interface, all pattern switches without a default branch will suddenly become non-exhaustive and cause compilation errors. Just as if we added a new method visit to the Visitor interface, in this case it’s good that we’re starting to get these compilation errors – because they will lead you to where you need to change your code to handle the new case. Therefore, it is probably worth it to add a default branch to such switches. If there are types that you obviously do not want to operate on, then list them explicitly:

String message = switch (element) {
	case Body body -> // что-то делаем
	case Car car -> // делаем заданное по умолчанию
	case Engine engine -> // опять же, делаем заданное по умолчанию
};

(If you’ve been following the addition of new features very closely, you might think that this is all great – but in fact, this only applies to switches, since statements (statements) are not checked whether they are exhaustive. Fortunately , according to the proposal JEP 406for all pattern switching operations, their completeness must be checked, regardless of how they are used – in the form of a statement or in the form of an expression.

▚ Reusable iteration logic

The Visitor pattern implements internal iteration. This means that instead of each user of the data structure implementing its own iteration with it (in the code that he writes himself, outside this data structure – therefore, it would be external iteration), this action is passed to the execution of the data structure itself, which then iterates over itself (this code is within the data structure, therefore, is internal) and applies the action:

class Car implements CarElement {

	private final List<CarElement> elements;

	// ...

	@Override
	public void accept(CarElementVisitor visitor) {
		for (CarElement element : elements) {
			element.accept(visitor);
		}
		visitor.visit(this);
	}

}

// в другом месте
Car car = // ...
CarElementVisitor visitor = // ...
car.accept(visitor);

Here we take advantage of the reusable application of iterative logic, which is especially interesting in cases slightly less trivial than a direct loop. The disadvantage is that such code has to cover many specific use cases of iteration: finding a result, calculating new values ​​and compiling a list from them, reducing values ​​to a single result, etc. I think you understand what I’m getting at: Java threads already do all this and more! Therefore, in order not to implement an impromptu version of Stream::forEach, why not take such a sensible version?

Using streams for internal iteration

final class Car implements CarElement {

	private final List<CarElement> elements;

	// ...

	public Stream<CarElement> elements() {
		return Stream.concat(elements.stream(), Stream.of(this));
	}

}

// в другом месте
Car car = // ...
car.elements()
	// тут работают потоки

This reuses a more powerful and well-understood API that greatly simplifies any operation that goes beyond simple Stream::forEach!

▚ Modern Java solution

Now let’s put together the solution we’ve got in its entirety:

public class VisitorDemo {

    public static void main(final String[] args) {
        Car car = new Car();
        print(car);
    }

	private static void print(Car car) {
		car.elements()
			.map(element -> switch (element) {
				case Body body -> "Visiting body";
				case Car car_ -> "Visiting car";
				case Engine engine -> "Visiting engine";
			})
			.forEach(System.out::println);
	}

}

// supertype of all objects in the structure
sealed interface CarElement
		permits Body, Engine, Car { }

class Body implements CarElement { }

class Engine implements CarElement { }

class Car implements CarElement {

    private final List<CarElement> elements;

    public Car() {
        this.elements = List.of(new Body(), new Engine());
    }

	public Stream<CarElement> elements() {
		return Stream.concat(elements.stream(), Stream.of(this));
	}

}

The functionality is still the same, but the number of lines of code has been halved, and there is no indirection left. Not bad, right?

▚ Advantages

In my opinion, this approach has a number of advantages over the Visitor pattern.

▚ It’s easier

In general, the whole solution turned out to be much simpler. No visitor interface, no visitor classes required, no double dispatch, and no crookedly named methods scattered throughout the code.

Such code not only becomes easier to code and extend; in this case, developers don’t have to learn a specific pattern to understand what’s going on. Significantly reduced indirection, so you can just read such code from a sheet. Redesigning it is also easier: just make a common sealed interface – and go.

▚ Easier to get results

The Visitor pattern requires the implementation of an internal iteration mechanism, which, as I have already pointed out, is simple only in the simplest cases. If you work with Threads, then there is a lot of ready-made functionality, with which it is convenient to calculate the result. And, unlike the Visitor pattern, it can be done without creating an instance, without changing the state (a big piece of work) – and without making a request at the end:

// Посетитель
Car car = // ...
PartCountingVisitor countingVisitor = new PartCountingVisitor();
car.accept(countingVisitor);
int partCount = countingVisitor.count();

// Современный Java
int partCount = car.elements().count();

Yes, the trick is a bit cheap, but you get my point.

▚ More flexibility

We currently only have type patternsbut coming soon and many newand they can be used to implement more granular processing of the visited element in place:

switch (shape) {
	case Point(int x && x > 0, int y) p
		-> handleRightQuadrantsPoint(p);
	case Point(int x && x < 0, int y) p
		-> handleLeftQuadrantsPoint(p);
	case Point p -> handleYAxisPoint(p);
	// другие случаи ...
}

This gives us the opportunity to tie all or almost all of the dispatching logic in one place, rather than disperse it over many methods, as we would have to do in the case of the “Visitor”. Even more interesting is that it is possible to separate dispatching not only by type, but also by completely different properties:

switch (shape) {
	case ColoredPoint(Point p, Color c && c == RED) cp
		-> handleRedShape(p);
	case ColoredCircle(Circle ci, Color c && c == RED) cc
		-> handleRedShape(ci);
	// other cases ...
}

▚ Summary

Instead of using the Visitor pattern:

  • We make a sealed interface for the types contained in the structure

  • During operations, we use pattern switching – this way we can easily determine the path for each type in the code

  • We avoid using default branches so that with each operation we have compilation errors where a new type should be added

Modern Java is the winner’s choice!

Similar Posts

Leave a Reply