Las construcciones visit
/ del patrón de visitante accept
son 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 Node
tipo 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 MyVisitor
clase 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 root
sea, 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 MyVisitor
que 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 Node
estamos 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 TrainElement
la 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 this
es 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 root
era 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: MyVisitor
la 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 left
o right
en los visit()
métodos. Lo más probable es que nuestro analizador también devuelva un objeto de tipo Node
que 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 IVisitor
interfaz 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.