Ответ 1
Конструкция шаблона посетителя visit
/accept
является необходимым злом из-за семантики 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
? О, я вижу. Это a TrainNode
. Посмотрим, есть ли какой-либо метод в MyVisitor
, который принимает параметр типа TrainNode
...". Компилятор во время компиляции определяет, какой метод будет вызываться. (Если Java действительно проверял динамические типы аргументов, производительность была бы довольно ужасной.)
Java дает нам один инструмент для учета типа времени выполнения (то есть динамического) объекта при вызове метода - отправка виртуального метода. Когда мы вызываем виртуальный метод, вызов фактически переходит к table в памяти, который состоит из указателей на функции. Каждый тип имеет таблицу. Если какой-либо конкретный метод переопределяется классом, в этой записи таблицы функций будет указан адрес переопределенной функции. Если класс не переопределяет метод, он будет содержать указатель на реализацию базового класса. Это по-прежнему несет накладные расходы на производительность (каждый вызов метода в основном будет разыменовывать два указателя: один указывает на таблицу функций типа и другую из самой функции), но он все же быстрее, чем проверка типов параметров.
Цель шаблона посетителя - выполнить двойная отправка - не только тип рассматриваемой цели вызова (MyVisitor
, через виртуальные методы), но также и тип параметра (какой тип Node
мы ищем)? Шаблон посетителя позволяет нам сделать это с помощью комбинации 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
). Таким образом, выполнение будет тем, что мы предполагали: 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()
, но они обычно включают отражение и, следовательно, могут повлечь за собой большие накладные расходы.