Шаблоны visit
/ accept
конструкции посетителя являются неизбежным злом из-за семантики C-подобных языков (C #, Java и т. Д.). Цель шаблона посетителя - использовать двойную отправку для маршрутизации вашего вызова, как и следовало ожидать от чтения кода.
Обычно, когда используется шаблон посетителя, задействуется иерархия объектов, в которой все узлы являются производными от базового Node
типа, далее именуемого Node
. Инстинктивно мы бы написали это так:
Node root = GetTreeRoot();
new MyVisitor().visit(root);
В этом и заключается проблема. Если бы наш MyVisitor
класс был определен следующим образом:
class MyVisitor implements IVisitor {
void visit(CarNode node);
void visit(TrainNode node);
void visit(PlaneNode node);
void visit(Node node);
}
Если во время выполнения, независимо от фактического типа root
, наш вызов перейдет в перегрузку visit(Node node)
. Это будет верно для всех переменных, объявленных типа Node
. Почему это? Поскольку Java и другие языки, подобные C, учитывают только статический тип или тип, в котором объявлена переменная, параметра при принятии решения, какую перегрузку вызывать. Java не делает лишнего шага, чтобы спрашивать при каждом вызове метода во время выполнения: «Хорошо, что это за динамический тип root
? О, понятно. Это а TrainNode
. Посмотрим, есть ли какой-нибудь метод, в MyVisitor
котором принимает параметр типаTrainNode
... ". Компилятор во время компиляции определяет, какой метод будет вызван (если бы Java действительно проверила динамические типы аргументов, производительность была бы довольно ужасной).
Java действительно дает нам один инструмент для учета рабочего (т.е. динамического) типа объекта при вызове метода - диспетчеризация виртуального метода . Когда мы вызываем виртуальный метод, вызов фактически переходит к таблице в памяти, которая состоит из указателей на функции. У каждого типа есть таблица. Если конкретный метод переопределяется классом, запись в таблице функций этого класса будет содержать адрес замещенной функции. Если класс не переопределяет метод, он будет содержать указатель на реализацию базового класса. Это по-прежнему влечет за собой накладные расходы (каждый вызов метода будет в основном разыменовывать два указателя: один указывает на таблицу функций типа, а другой - на саму функцию), но это все же быстрее, чем необходимость проверки типов параметров.
Цель шаблона посетителя - выполнить двойную отправку - учитывается не только тип цели вызова ( MyVisitor
через виртуальные методы), но также тип параметра (какой тип Node
мы смотрим)? Шаблон Visitor позволяет нам делать это с помощью комбинации visit
/ accept
.
Изменив нашу строку на эту:
root.accept(new MyVisitor());
Мы можем получить то, что хотим: с помощью диспетчеризации виртуального метода мы вводим правильный вызов accept (), реализованный в подклассе - в нашем примере с помощью TrainElement
мы вводим TrainElement
реализацию accept()
:
class TrainNode extends Node implements IVisitable {
void accept(IVisitor v) {
v.visit(this);
}
}
Что ноу компилятор на данный момент, внутри рамки TrainNode
-х accept
? Он знает, что статический тип this
- этоTrainNode
. Это важный дополнительный фрагмент информации, о которой компилятор не знал в области видимости нашего вызывающего объекта: все, о чем он знал root
, это то, что это был файл Node
. Теперь компилятор знает, что this
( root
) - это не просто Node
, а на самом деле TrainNode
. Следовательно, одна строка внутри accept()
: v.visit(this)
означает нечто совершенно иное. Теперь компилятор будет искать перегрузку visit()
, требующую TrainNode
. Если он не может его найти, он скомпилирует вызов перегрузки, которая требуетNode
. Если ни один из них не существует, вы получите ошибку компиляции (если у вас нет перегрузки, которая требует object
). Таким образом, Execution войдет в то, что мы планировали MyVisitor
с самого начала : реализация visit(TrainNode e)
. Никаких слепков и, самое главное, рефлексии не требовалось. Таким образом, накладные расходы этого механизма довольно низкие: он состоит только из ссылок на указатели и ничего больше.
Вы правы в своем вопросе - мы можем использовать приведение и добиться правильного поведения. Однако часто мы даже не знаем, что такое Node. Возьмем следующую иерархию:
abstract class Node { ... }
abstract class BinaryNode extends Node { Node left, right; }
abstract class AdditionNode extends BinaryNode { }
abstract class MultiplicationNode extends BinaryNode { }
abstract class LiteralNode { int value; }
И мы писали простой компилятор, который анализирует исходный файл и создает иерархию объектов, соответствующую приведенной выше спецификации. Если бы мы писали интерпретатор для иерархии, реализованной как Посетитель:
class Interpreter implements IVisitor<int> {
int visit(AdditionNode n) {
int left = n.left.accept(this);
int right = n.right.accept(this);
return left + right;
}
int visit(MultiplicationNode n) {
int left = n.left.accept(this);
int right = n.right.accept(this);
return left * right;
}
int visit(LiteralNode n) {
return n.value;
}
}
Кастинг не получили бы нас очень далеко, так как мы не знаем , типов left
или right
в visit()
методах. Наш синтаксический анализатор, скорее всего, также просто вернет объект типа, Node
который также указывает на корень иерархии, поэтому мы также не можем безопасно преобразовать его. Итак, наш простой интерпретатор может выглядеть так:
Node program = parse(args[0]);
int result = program.accept(new Interpreter());
System.out.println("Output: " + result);
Шаблон посетителя позволяет нам делать что-то очень мощное: учитывая иерархию объектов, он позволяет нам создавать модульные операции, которые работают над иерархией, не требуя размещения кода в самом классе иерархии. Шаблон посетителя широко используется, например, при построении компилятора. С учетом синтаксического дерева конкретной программы написано множество посетителей, которые работают с этим деревом: проверка типов, оптимизация, выпуск машинного кода обычно реализуются как разные посетители. В случае посетителя оптимизации он может даже вывести новое синтаксическое дерево с учетом входного дерева.
Конечно, у него есть свои недостатки: если мы добавляем новый тип в иерархию, нам нужно также добавить visit()
метод для этого нового типа в IVisitor
интерфейс и создать заглушки (или полные) реализации для всех наших посетителей. Нам также необходимо добавить accept()
метод по причинам, описанным выше. Если производительность не так много для вас значит, есть решения для написания посетителей без необходимости accept()
, но они обычно включают отражение и, следовательно, могут повлечь за собой довольно большие накладные расходы.