Конструкции 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
). Таким образом, выполнение будет соответствовать тому, что мы планировали: реализация 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()
, но они обычно включают рефлексию и, следовательно, могут повлечь за собой довольно большие накладные расходы.