¿Cuál es el punto del método accept () en el patrón de visitante?

87

Se habla mucho de desvincular los algoritmos de las clases. Pero, una cosa queda de lado y no se explica.

Usan visitante como este

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

En lugar de llamar a visit (elemento) directamente, Visitor le pide al elemento que llame a su método de visita. Contradice la idea declarada de desconocimiento de clase sobre los visitantes.

PS1 Explique con sus propias palabras o señale una explicación exacta. Porque dos respuestas que obtuve se refieren a algo general e incierto.

PS2 Mi conjetura: dado que getLeft()devuelve el básico Expression, la llamada visit(getLeft())daría como resultado visit(Expression), mientras que la getLeft()llamada visit(this)resultará en otra invocación de visita más apropiada. Entonces, accept()realiza la conversión de tipo (también conocido como casting).

Coincidencia de patrones de PS3 Scala = Patrón de visitante en esteroides muestra cuánto más simple es el patrón de visitante sin el método de aceptación. Wikipedia agrega a esta afirmación : al vincular un artículo que muestra "que los accept()métodos son innecesarios cuando la reflexión está disponible; introduce el término 'Paseo' para la técnica".

Val
fuente
Dice "cuando el visitante acepta las llamadas, la llamada se envía en función del tipo de la persona que llama. Luego, la persona que llama vuelve a llamar al método de visita específico del tipo de visitante, y esta llamada se envía en función del tipo real de visitante". En otras palabras, dice lo que me confunde. Por este motivo, ¿puede ser más específico?
Val

Respuestas:

154

Las construcciones visit/ del patrón de visitante acceptson un mal necesario debido a la semántica de los lenguajes similares a C (C #, Java, etc.). El objetivo del patrón de visitante es utilizar el envío doble para enrutar su llamada como esperaría al leer el código.

Normalmente, cuando se utiliza el patrón de visitante, se involucra una jerarquía de objetos en la que todos los nodos se derivan de un Nodetipo base , al que en adelante nos referiremos como Node. Instintivamente, lo escribiríamos así:

Node root = GetTreeRoot();
new MyVisitor().visit(root);

Aquí radica el problema. Si nuestra MyVisitorclase se definió de la siguiente manera:

class MyVisitor implements IVisitor {
  void visit(CarNode node);
  void visit(TrainNode node);
  void visit(PlaneNode node);
  void visit(Node node);
}

Si, en tiempo de ejecución, independientemente del tipo real que rootsea, nuestra llamada entraría en sobrecarga visit(Node node). Esto sería cierto para todas las variables declaradas de tipo Node. ¿Por qué es esto? Porque Java y otros lenguajes similares a C solo consideran el tipo estático , o el tipo en el que se declara la variable, del parámetro al decidir a qué sobrecarga llamar. Java no da un paso adicional para preguntar, para cada llamada de método, en tiempo de ejecución, "Bien, ¿cuál es el tipo dinámico de root? Oh, ya veo. Es un TrainNode. Veamos si hay algún método en el MyVisitorque acepte un parámetro de tipoTrainNode... ". El compilador, en tiempo de compilación, determina cuál es el método que se llamará. (Si Java efectivamente inspeccionara los tipos dinámicos de los argumentos, el rendimiento sería bastante terrible).

Java nos proporciona una herramienta para tener en cuenta el tipo de tiempo de ejecución (es decir, dinámico) de un objeto cuando se llama a un método: el envío de métodos virtuales . Cuando llamamos a un método virtual, la llamada en realidad va a una tabla en la memoria que consta de punteros de función. Cada tipo tiene una mesa. Si un método en particular es anulado por una clase, la entrada de la tabla de funciones de esa clase contendrá la dirección de la función anulada. Si la clase no anula un método, contendrá un puntero a la implementación de la clase base. Esto todavía incurre en una sobrecarga de rendimiento (cada llamada de método básicamente eliminará la referencia a dos punteros: uno que apunta a la tabla de funciones del tipo y otro a la función en sí), pero aún es más rápido que tener que inspeccionar tipos de parámetros.

El objetivo del patrón de visitante es lograr un doble despacho : no solo se considera el tipo de destino de la llamada ( MyVisitor, a través de métodos virtuales), sino también el tipo de parámetro (¿qué tipo Nodeestamos viendo)? El patrón de visitante nos permite hacer esto mediante la combinación visit/ accept.

Cambiando nuestra línea a esto:

root.accept(new MyVisitor());

Podemos obtener lo que queremos: a través del envío del método virtual, ingresamos la llamada de accept () correcta tal como la implementa la subclase; en nuestro ejemplo con TrainElement, ingresaremos TrainElementla implementación de accept():

class TrainNode extends Node implements IVisitable {
  void accept(IVisitor v) {
    v.visit(this);
  }
}

Lo que hace el compilador de conocimientos en este punto, dentro del alcance de TrainNode's accept? Sabe que el tipo estático de thises unTrainNode . Este es un fragmento adicional importante de información que el compilador no conocía en el alcance de nuestro llamador: allí, todo lo que sabía rootera que era un archivo Node. Ahora el compilador sabe que this( root) no es solo un Node, sino que en realidad es un TrainNode. En consecuencia, la única línea que se encuentra dentro accept(): v.visit(this)significa algo completamente diferente. El compilador ahora buscará una sobrecarga de la visit()que requiere un TrainNode. Si no puede encontrar uno, compilará la llamada a una sobrecarga que requiere unNode. Si no existe ninguno, obtendrá un error de compilación (a menos que tenga una sobrecarga object). La ejecución entrará así en lo que habíamos pretendido todo el tiempo: MyVisitorla implementación de visit(TrainNode e). No se necesitaron yesos y, lo más importante, no se necesitó reflexión. Por lo tanto, la sobrecarga de este mecanismo es bastante baja: solo consta de referencias de puntero y nada más.

Tiene razón en su pregunta: podemos usar un yeso y obtener el comportamiento correcto. Sin embargo, a menudo, ni siquiera sabemos qué tipo de Node es. Tome el caso de la siguiente jerarquía:

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

Y estábamos escribiendo un compilador simple que analiza un archivo fuente y produce una jerarquía de objetos que se ajusta a la especificación anterior. Si estuviéramos escribiendo un intérprete para la jerarquía implementada como Visitante:

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

Fundición que no nos llegue muy lejos, ya que no sabemos los tipos de lefto righten los visit()métodos. Lo más probable es que nuestro analizador también devuelva un objeto de tipo Nodeque apunta a la raíz de la jerarquía, por lo que tampoco podemos convertirlo de forma segura. Entonces, nuestro intérprete simple puede verse así:

Node program = parse(args[0]);
int result = program.accept(new Interpreter());
System.out.println("Output: " + result);

El patrón de visitante nos permite hacer algo muy poderoso: dada una jerarquía de objetos, nos permite crear operaciones modulares que operan sobre la jerarquía sin necesidad de poner el código en la propia clase de la jerarquía. El patrón de visitante se usa ampliamente, por ejemplo, en la construcción de compiladores. Dado el árbol de sintaxis de un programa en particular, se escriben muchos visitantes que operan en ese árbol: la verificación de tipos, las optimizaciones y la emisión de código de máquina se implementan generalmente como visitantes diferentes. En el caso del visitante de optimización, incluso puede generar un nuevo árbol de sintaxis dado el árbol de entrada.

Tiene sus inconvenientes, por supuesto: si agregamos un nuevo tipo en la jerarquía, también necesitamos agregar un visit()método para ese nuevo tipo en la IVisitorinterfaz y crear implementaciones stub (o completas) en todos nuestros visitantes. También necesitamos agregar el accept()método también, por las razones descritas anteriormente. Si el rendimiento no significa mucho para usted, existen soluciones para escribir a los visitantes sin necesidad de accept(), pero normalmente implican reflexión y, por lo tanto, pueden generar una gran sobrecarga.

atanamir
fuente
5
El elemento 41 de Java efectivo incluye esta advertencia: " evite situaciones en las que el mismo conjunto de parámetros se pueda pasar a diferentes sobrecargas mediante la adición de conversiones " . El accept()método se vuelve necesario cuando se infringe esta advertencia en el visitante.
jaco0646
" Normalmente, cuando se usa el patrón de visitante, se involucra una jerarquía de objetos donde todos los nodos se derivan de un tipo de nodo base ", esto no es absolutamente necesario en C ++. Ver Boost.Variant, Eggs.Variant
Jean-Michaël Celerier
Me parece que en Java realmente no necesitamos el método accept porque en Java siempre llamamos al método de tipo más específico
Gilad Baruchian
1
Vaya, esta fue una explicación asombrosa. Es esclarecedor ver que todas las sombras del patrón se deben a las limitaciones del compilador, y ahora revela claramente gracias a ti.
Alfonso Nishikawa
@GiladBaruchian, el compilador genera una llamada al método de tipo más específico que el compilador puede determinar.
mmw
15

Por supuesto, sería una tontería si esa fuera la única forma en que se implementa Accept.

Pero no lo es.

Por ejemplo, los visitantes son realmente útiles cuando se trata de jerarquías, en cuyo caso la implementación de un nodo no terminal podría ser algo como esto

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

¿Lo ves? Lo que usted describe como estúpido es la solución para atravesar jerarquías.

Aquí hay un artículo mucho más largo y profundo que me hizo entender al visitante .

Editar: Para aclarar: el Visitmétodo del visitante contiene lógica que se aplicará a un nodo. El Acceptmétodo del nodo contiene lógica sobre cómo navegar a los nodos adyacentes. El caso en el que solo realiza un envío doble es un caso especial en el que simplemente no hay nodos adyacentes hacia los que navegar.

George Mauer
fuente
7
Su explicación no explica por qué debería ser responsabilidad del nodo en lugar del método visit () apropiado del visitante iterar a los niños. ¿Quiere decir que la idea principal es compartir el código transversal de la jerarquía cuando necesitamos los mismos patrones de visita para diferentes visitantes? No veo ninguna pista en el documento recomendado.
Val
1
Decir que aceptar es bueno para el recorrido de rutina es razonable y vale la pena para la población en general. Pero, tomé mi ejemplo del "No pude entender el patrón de visitante hasta que leí andymaleh.blogspot.com/2008/04/… " de alguien más . Ni este ejemplo ni Wikipedia ni otras respuestas mencionan la ventaja de la navegación. Sin embargo, todos exigen este estúpido aceptar (). Por eso hago mi pregunta: ¿Por qué?
Val
1
@Val - ¿qué quieres decir? No estoy seguro de lo que estás preguntando. No puedo hablar por otros artículos ya que esas personas tienen diferentes puntos de vista sobre este tema, pero dudo que estemos en desacuerdo. En general, en la computación se pueden asignar muchos problemas a las redes, por lo que un uso puede no tener nada que ver con los gráficos en la superficie, pero en realidad es un problema muy similar.
George Mauer
1
Proporcionar un ejemplo de dónde podría ser útil algún método no responde a la pregunta de por qué el método es obligatorio. Dado que la navegación no siempre es necesaria, el método accept () no siempre es bueno para visitar. Por lo tanto, deberíamos poder lograr nuestros objetivos sin él. Sin embargo, es obligatorio. Significa que hay una razón más fuerte para introducir accept () en cada patrón de visitante que "a veces es útil". ¿Qué no está claro en mi pregunta? Si no intenta comprender por qué Wikipedia busca formas de deshacerse de accept, no está interesado en comprender mi pregunta.
Val
1
@Val El artículo que enlazan con "La esencia del patrón del visitante" señala la misma separación de navegación y operación en su resumen que di. Simplemente están diciendo que la implementación de GOF (que es lo que está preguntando) tiene algunas limitaciones y molestias que se pueden eliminar con el uso de la reflexión, por lo que presentan el patrón Walkabout. Esto es ciertamente útil y puede hacer muchas de las mismas cosas que puede hacer el visitante, pero es mucho código bastante sofisticado y (en una lectura superficial) pierde algunos beneficios de la seguridad de tipos. Es una herramienta para la caja de herramientas, pero más pesada que el visitante
George Mauer
0

El propósito del patrón Visitante es asegurar que los objetos sepan cuando el visitante terminó con ellos y se fue, para que las clases puedan realizar cualquier limpieza necesaria después. También permite a las clases exponer sus componentes internos "temporalmente" como parámetros 'ref', y saber que los componentes internos ya no estarán expuestos una vez que el visitante se haya ido. En los casos en los que no es necesaria una limpieza, el patrón de visitantes no es muy útil. Las clases que no hacen ninguna de estas cosas pueden no beneficiarse del patrón de visitante, pero el código que está escrito para usar el patrón de visitante se podrá usar con clases futuras que pueden requerir limpieza después del acceso.

Por ejemplo, supongamos que uno tiene una estructura de datos que contiene muchas cadenas que deben actualizarse atómicamente, pero la clase que contiene la estructura de datos no sabe con precisión qué tipos de actualizaciones atómicas deben realizarse (por ejemplo, si un hilo quiere reemplazar todas las apariciones de " X ", mientras que otro hilo quiere reemplazar cualquier secuencia de dígitos con una secuencia numéricamente uno más alta, las operaciones de ambos hilos deberían tener éxito; si cada hilo simplemente lee una cadena, realiza sus actualizaciones y la vuelve a escribir, el segundo hilo escribir de nuevo su cadena sobrescribiría la primera). Una forma de lograr esto sería hacer que cada hilo adquiera un bloqueo, realice su operación y libere el bloqueo. Desafortunadamente, si las cerraduras se exponen de esa manera,

El patrón de visitante ofrece (al menos) tres enfoques para evitar ese problema:

  1. Puede bloquear un registro, llamar a la función suministrada y luego desbloquear el registro; el registro podría bloquearse para siempre si la función suministrada cae en un bucle sin fin, pero si la función suministrada devuelve o arroja una excepción, el registro se desbloqueará (puede ser razonable marcar el registro como inválido si la función arroja una excepción; dejando probablemente no sea una buena idea). Tenga en cuenta que es importante que si la función llamada intenta adquirir otros bloqueos, podría producirse un punto muerto.
  2. En algunas plataformas, puede pasar una ubicación de almacenamiento que contiene la cadena como parámetro 'ref'. Esa función podría luego copiar la cadena, calcular una nueva cadena basada en la cadena copiada, intentar CompareExchange la cadena antigua por la nueva y repetir todo el proceso si el CompareExchange falla.
  3. Puede hacer una copia de la cadena, llamar a la función proporcionada en la cadena, luego usar CompareExchange para intentar actualizar el original y repetir todo el proceso si el CompareExchange falla.

Sin el patrón de visitante, realizar actualizaciones atómicas requeriría exponer bloqueos y arriesgarse a fallar si el software de llamada no sigue un protocolo estricto de bloqueo / desbloqueo. Con el patrón de visitante, las actualizaciones atómicas se pueden realizar de forma relativamente segura.

Super gato
fuente
2
1. La visita implica que solo tiene acceso a los métodos públicos de visita, por lo que debe hacer que las cerraduras internas sean accesibles para que el público sea útil con el Visitante. 2 / Ninguno de los ejemplos que he visto antes implica que se suponga que Visitor se use para cambiar el estado de visitado. 3. "Con el VisitorPattern tradicional, solo se puede determinar cuándo estamos entrando en un nodo. No sabemos si hemos dejado el nodo anterior antes de entrar en el nodo actual". ¿Cómo se desbloquea con solo visitar en lugar de visitEnter y visitLeave? Finalmente, pregunté sobre las aplicaciones de accpet () en lugar de Visitor.
Val
Quizás no estoy completamente al día con la terminología de los patrones, pero el "patrón de visitante" parece parecerse a un enfoque que he usado en el que X le pasa a Y un delegado, al que Y puede luego pasar información que solo necesita ser válida como siempre que el delegado esté funcionando. ¿Quizás ese patrón tiene otro nombre?
supercat
2
Esta es una aplicación interesante del patrón de visitantes a un problema específico, pero no describe el patrón en sí ni responde a la pregunta original. "En los casos en los que no es necesaria una limpieza, el patrón de visitantes no es muy útil". Esta afirmación es definitivamente falsa y solo se relaciona con su problema específico y no con el patrón en general.
Tony O'Hagan
0

Todas las clases que requieren modificación deben implementar el método 'accept'. Los clientes llaman a este método de aceptación para realizar alguna acción nueva en esa familia de clases, extendiendo así su funcionalidad. Los clientes pueden utilizar este método de aceptación única para realizar una amplia gama de nuevas acciones pasando una clase de visitante diferente para cada acción específica. Una clase de visitante contiene varios métodos de visita anulados que definen cómo lograr esa misma acción específica para cada clase dentro de la familia. Estos métodos de visita pasan a una instancia en la que trabajar.

Los visitantes son útiles si con frecuencia agrega, modifica o quita funcionalidad a una familia estable de clases porque cada elemento de funcionalidad se define por separado en cada clase de visitante y las clases en sí no necesitan cambiarse. Si la familia de clases no es estable, entonces el patrón de visitantes puede ser de menor utilidad, porque muchos visitantes necesitan cambiar cada vez que se agrega o quita una clase.

Andrew Paté
fuente
-1

Un buen ejemplo es la compilación del código fuente:

interface CompilingVisitor {
   build(SourceFile source);
}

Los clientes pueden implementar una JavaBuilder, RubyBuilder, XMLValidator, etc, y la implantación de la recogida y visitar todos los archivos de origen en un proyecto no necesita cambio.

Este sería un mal patrón si tiene clases separadas para cada tipo de archivo fuente:

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

Todo se reduce al contexto y a qué partes del sistema desea que sean extensibles.

Garrett Hall
fuente
La ironía es que VisitorPattern nos ofrece utilizar el patrón incorrecto. Dice que debemos definir un método de visita para cada tipo de nodo que vaya a visitar. En segundo lugar, no está claro cuáles son sus ejemplos, buenos o malos. ¿Cómo se relacionan con mi pregunta?
Val