Какова точка метода accept() в шаблоне посетителя?

Существует много разговоров о развязывании алгоритмов из классов. Но одно остается в стороне, а не объяснено.

Они используют посетителя таким образом

abstract class Expr {
  public <T> T accept(Visitor<T> visitor) {visitor.visit(this);}
}

class ExprVisitor extends Visitor{
  public Integer visit(Num num) {
    return num.value;
  }

  public Integer visit(Sum sum) {
    return sum.getLeft().accept(this) + sum.getRight().accept(this);
  }

  public Integer visit(Prod prod) {
    return prod.getLeft().accept(this) * prod.getRight().accept(this);
  }

Вместо прямого вызова функции visit (element), посетитель запрашивает элемент для вызова метода посещения. Это противоречит заявленной идее классовой неосведомленности о посетителях.

PS1 Пожалуйста, объясните своими словами или укажите точное объяснение. Потому что два ответа, которые я получил, касаются чего-то общего и неопределенного.

PS2 Мое предположение: поскольку getLeft() возвращает базовый Expression, вызов visit(getLeft()) приведет к visit(Expression), тогда как getLeft() вызов visit(this) приведет к другому, более подходящему вызову посещения. Таким образом, accept() выполняет преобразование типа (aka casting).

PS3 Scala Pattern Matching = Visitor Pattern on Steroid показывает, насколько проще шаблон посетителя без метода accept. Wikipedia добавляет к этому утверждению: путем ссылки на документ, показывающий, что методы accept() не нужны, когда доступно отражение, вводит термин "Walkabout" для техника."

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

Ответ 2

Конечно, это было бы глупо, если бы это был единственный способ реализации Accept.

Но это не так.

Например, посетители действительно очень полезны при работе с иерархиями, и в этом случае реализация нетерминального node может быть чем-то вроде этого

interface IAcceptVisitor<T> {
  void Accept(IVisit<T> visitor);
}
class HierarchyNode : IAcceptVisitor<HierarchyNode> {
  public void Accept(IVisit<T> visitor) {
    visitor.visit(this);
    foreach(var n in this.children)
      n.Accept(visitor);
  }

  private IEnumerable<HierarchyNode> children;
  ....
}

Вы видите? То, что вы называете глупым, - это решение для прохождения иерархии.

Вот намного более длинная и глубокая статья, которая заставила меня понять посетителя.

Edit: Чтобы уточнить: метод посетителя Visit содержит логику, которая будет применяться к node. Метод node Accept содержит логику навигации по смежным узлам. Случай, когда вы отправляете только двойную рассылку, - это особый случай, когда для перехода к простому смежному узлу нет.

Ответ 3

Цель шаблона "Посетитель" заключается в том, чтобы убедиться, что объекты знают, когда посетитель закончил их и ушел, поэтому классы могут выполнить любую необходимую очистку после этого. Он также позволяет классам подвергать свои внутренние параметры "временно" как "ref", и знать, что внутреннее устройство больше не будет отображаться после того, как посетитель исчезнет. В случаях, когда очистка не требуется, шаблон посетителя не очень полезен. Классы, которые ни одна из этих вещей не могут извлечь выгоду из шаблона посетителя, но код, который написан для использования шаблона посетителя, будет использоваться с будущими классами, которые могут потребовать очистки после доступа.

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

Шаблон посетителя предлагает (по крайней мере) три подхода, чтобы избежать этой проблемы:

  1. Он может заблокировать запись, вызвать предоставленную функцию и затем разблокировать запись; запись может быть заблокирована навсегда, если поставляемая функция попадает в бесконечный цикл, но если предоставленная функция возвращает или генерирует исключение, запись будет разблокирована (может быть разумно пометить запись недействительной, если функция выдает исключение; он заблокирован, вероятно, не очень хорошая идея). Обратите внимание, что важно, что если вызываемая функция пытается получить другие блокировки, может возникнуть взаимоблокировка.
  2. На некоторых платформах он может передать место хранения, содержащее строку в качестве параметра 'ref'. Эта функция может затем скопировать строку, вычислить новую строку на основе скопированной строки, попытаться CompareExchange старой строки на новую, и повторить весь процесс, если CompareExchange терпит неудачу.
  3. Он может сделать копию строки, вызвать предоставленную функцию в строке, а затем использовать CompareExchange сам, чтобы попытаться обновить оригинал, и повторить весь процесс, если CompareExchange терпит неудачу.

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

Ответ 4

A хороший пример приведен в компиляции исходного кода:

interface CompilingVisitor {
   build(SourceFile source);
}

Клиенты могут реализовать JavaBuilder, RubyBuilder, XMLValidator и т.д., и реализация для сбора и просмотра всех исходных файлов в проекте не нуждается в изменении.

Это будет шаблон bad, если у вас есть отдельные классы для каждого типа исходного файла:

interface CompilingVisitor {
   build(JavaSourceFile source);
   build(RubySourceFile source);
   build(XMLSourceFile source);
}

Это сводится к контексту и тем частям системы, которые вы хотите расширять.