Pull to refresh

Сегодня паттерн Посетитель в Java уже не нужен – лучше использовать переключатели паттернов

Reading time9 min
Views11K
Original author: Nicolai Parlog

В современном языке Java паттерн Посетитель (Visitor) уже не нужен. Он отлично компенсируется использованием запечатанных типов и переключателей, использующих сопоставление с шаблоном – в таком случае те же цели достигаются проще и меньшим объемом кода.

Всякий раз, оказываясь в ситуации, где мог бы применяться паттерн Посетитель, подумайте, не воспользоваться ли вместо него более современными возможностями языка   Java. Разумеется, эти возможности могут использоваться и в других обстоятельствах, но в этой статье мы обсудим сравнительно узкую тему: чем заменить паттерн Посетитель. Для этого я начну с максимально краткого введения и приведу пример, а затем объясню, как достичь тех же целей более простым (и кратким) кодом.

▚ Паттерн Посетитель

Википедия сообщает:

Паттерн проектирования «Посетитель» позволяет открепить алгоритм от структуры того объекта, которым он оперирует. Практический результат такого открепления – способность добавлять новые операции к имеющимся структурам объектов, не модифицируя эти структуры.

Основная мотивация здесь – именно в том, чтобы не изменять структуру. Если над объектом производится много операций, либо производимые над ним операции сильно отличаются, то реализация их на уже вовлеченных типах может легко перегрузить типы массой функций, не связанных друг с другом. Разумеется, изменить эти типы получится лишь в том случае, если они не обременены зависимостями.

Основная мотивация: не изменять типы.

При использовании паттерна «Посетитель» каждая операция реализуется в Посетителе так, что затем она передается структуре объектов, а та передает Посетителю те объекты, из которых состоит. Структура ничего не знает ни об одном конкретном Посетителе, поэтому может свободно создаваться всякий раз, когда понадобится та или иная операция.

Вот пример из Википедии (немного сокращенный):

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");
    }

}

Тут есть целый ряд вещей, которые я сделал бы иначе (Car наследует CarElement? Серьезно?!), но, чтобы не усложнять сравнения, я решил как можно ближе придерживаться оригинала.

Про паттерн Посетитель написано уже очень много (варианты использования, предпосылки, реализация, ограничения, т.д.), поэтому нет необходимости все это здесь повторять. Давайте просто предположим, что оказались в ситуации, где использовать этот паттерн действительно целесообразно. Так вот, что мы применили бы вместо него.

▚ Языковые возможности

В современном языке Java есть более удобные способы достичь тех целей, для которых предназначается паттерн Посетитель – поэтому он становится избыточен.

▚ Определение дополнительных операций

Основная задача паттерна «Посетитель» - обеспечить реализацию нового функционала, такого, который тесно связан с коллекцией типов, но:

  • Не изменяя этих типов (кроме одноразовой настройки новой конфигурации).

  • При этом обеспечивая удобство поддержки того кода, который получится в результате.

Это достигается так:

  • Вы можете создавать отдельную реализацию Посетителя для каждой операции (не трогая тех типов, над которыми она производится).

  • От каждого Посетителя требуется, чтобы он мог обрабатывать все релевантные классы (в противном случае они не скомпилируются).

Вот эта часть паттерна:

// При добавлении нового посещенного типа заставим его реализовать
// этот интерфейс. Единственная приемлемая реализация
// `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");
	}

}

Благодаря нововведениям языка Java, теперь эти цели достигаются гораздо проще:

  1. Создаем запечатанный интерфейс для всех типов, участвующих в этих операциях.

  2. Всякий раз, когда требуется новая операция, используем паттерны типов в режиме переключения, чтобы реализовать эту возможность (существует в виде превью в Java 17).

Запечатанный интерфейс, переключатель и сопоставление с шаблоном:

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);

Разберем по порядку:

  • switch(element) переключает код с элемента на элемент.

  • На каждом case проверяется, относится ли данный экземпляр к указанному типу

    • Если так – то под новым именем создается переменная этого типа.

    • Затем переключение результирует в строку, расположенную справа от стрелки.

  • Переключение switch обязательно должно результировать в result, который затем присваивается message.

Вот это "обязательно результировать в result" работает и без ветки по умолчанию (default), так как CarElement запечатан, что и дает знать компилятору (а также вашим коллегам), что только перечисленные типы напрямую его реализуют. Компилятор может применить это знание при переключении паттернов и определить, что перечисленные случаи являются исчерпывающими, то есть, что код был проверен для всех возможных реализаций.

Так что, когда вы добавляете новый тип к запечатанному интерфейсу, все переключения паттернов без ветки default вдруг станут неисчерпывающими и станут приводить к ошибкам компиляции. Подобно тому, как если бы мы добавили новый метод visit к интерфейсу Посетителя, в данном случае хорошо, что у нас стали возникать эти ошибки компиляции – ведь они приведут вас туда, где вам понадобится изменить ваш код, чтобы обработать новый случай. Поэтому, вероятно, нее стоит добавлять ветку default к таким переключателям. Если есть такие типы, оперировать которыми вы заведомо не хотите, то перечислите их явно:

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

(Если вы очень пристально следили за добавлением новых возможностей, то, возможно, считаете, что все это превосходно – но, на самом деле, это подходит только к переключателям, поскольку для инструкций (statements) не проверяется, являются ли они исчерпывающими. К счастью, в соответствии с предложением  JEP 406, для всех операций переключения паттернов должна проверяться их полнота, независимо от того, как именно они используются – в виде инструкции или в виде выражения.

▚ Многоразовое использование логики итераций

Паттерн «Посетитель» реализует внутреннюю итерацию. Значит, вместо того, чтобы каждый пользователь структуры данных реализовывал с ней собственный вариант перебора (в коде, который напишет сам, вне этой структуры данных – следовательно, это была бы внешняя итерация), это действие передается на выполнение самой структуре данных, которая затем перебирает сама себя (этот код находится в пределах структуры данных, следовательно, является внутренним) и применяет действие:

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);

Здесь мы пользуемся многоразовым применением итерационной логики, что особенно интересно в случаях, чуть менее тривиальных, чем прямой цикл. Недостаток в том, что такому коду приходится покрывать множество конкретных случаев использования перебора: находить результат, вычислять новые значения и собирать из них список, сокращать значения до единственного результата, т.д. Думаю, вы понимаете, к чему я клоню: потоки Java уже делают все это и не только! Поэтому, чтобы не реализовывать импровизированного варианта Stream::forEach, почему бы не взять такой дельный вариант?

Использование потоков для внутреннего перебора:

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()
	// тут работают потоки

Так переиспользуется более мощный и хорошо понятный API, значительно упрощающий любые операции, которые не сводятся к простому Stream::forEach!

▚ Решение на современном Java

Теперь давайте целиком соберем получившееся у нас решение:

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));
	}

}

Функционал все тот же, но количество строк кода уменьшилось вдвое, и не осталось никакой косвенности. Неплохо, правда?

▚ Достоинства

На мой взгляд, этот подход обладает целым рядом достоинств по сравнению с паттерном «Посетитель».

▚ Он проще

В целом все решение получилось гораздо проще. Никакого интерфейса посетителя, не требуются классы посетителя, нет двойной диспетчеризации, и никаких криво поименованных методов, разбросанных по всему коду.

Такой код становится не только проще закладывать и расширять; в данном случае разработчикам еще и не приходится учить конкретный паттерн, чтобы понять, что происходит. Значительно сократилась косвенность, поэтому такой код вы можете просто читать с листа. Переделывать его тоже проще: всего лишь сделайте общий запечатанный интерфейс – и в путь.

▚ Легче получить результат

Паттерн «Посетитель» требует реализовать внутренний механизм итерации, который, как я уже указал, прост только в самых простых случаях. Если работать с Потоками, то найдется масса уже готового функционала, при помощи которого удобно вычислять результат. И, чего не скажешь о паттерне «Посетитель», это можно сделать, не создавая экземпляр, не изменяя состояния (большой кусок работы) – и не делая запроса в конце:

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

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

Да, трюк немного дешевый, но мою мысль вы поняли.

▚ Больше гибкости

В настоящее время у нас есть только паттерны типов, но скоро появится и много новых, и их можно будет использовать для реализации более детальной обработки посещенного элемента прямо на месте:

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);
	// другие случаи ...
}

Так у нас появляется возможность связать всю или почти всю логику диспетчеризации в одном месте, а не рассеивать ее по множеству методов, как пришлось бы поступить в случае с «Посетителем». Еще интереснее, что можно разделить диспетчеризацию не только по типу, но и по совершенно другим свойствам:

switch (shape) {
	case ColoredPoint(Point p, Color c && c == RED) cp
		-> handleRedShape(p);
	case ColoredCircle(Circle ci, Color c && c == RED) cc
		-> handleRedShape(ci);
	// другие случаи ...
}

▚ Итог

Вместо использования паттерна «Посетитель»:

  • Делаем запечатанный интерфейс для типов, содержащихся в структуре.

  • При операциях используем переключение паттернов – так легко сможем определить в коде путь для каждого типа.

  • Избегаем использования веток default, чтобы при каждой операции у нас возникали ошибки компиляции там, где следует добавить новый тип.

Современный Java – выбор победителей!

Tags:
Hubs:
Total votes 16: ↑8 and ↓8+2
Comments8

Articles

Information

Website
piter.com
Registered
Founded
Employees
201–500 employees
Location
Россия