Contexto: Supongamos que existe un sistema de estacionamientos. Supongamos que en este sistema existe una clase Vehículo
, con subclases Auto
, Autobús
y Motocicleta
. Estas clases se utilizan para almacenar información sobre los vehículos estacionados en el aparcamiento. Supongamos también que todos estos vehículos están almacenados en una lista. Decimos que esta lista es una estructura de datos polimórfica, ya que puede almacenar objetos de diferentes clases, siempre que sean subclases de Vehículo
.
Problema: Con frecuencia, en el sistema de estacionamientos, necesitamos realizar una operación en todos los vehículos estacionados. Como ejemplo, podemos mencionar: imprimir información sobre los vehículos estacionados, persistir los datos de los vehículos o enviar un mensaje a los dueños de los vehículos.
Sin embargo, el objetivo es implementar estas operaciones fuera de las clases de Vehículo
a través de un código como el siguiente:
interface Visitor { void visit(Auto c); void visit(Autobus o); void visit(Motocicleta m); } class PrintVisitor implements Visitor { public void visit(Auto c) { "imprime datos de auto" } public void visit(Autobus o) { "imprime datos de autobus" } public void visit(Motocicleta m) {"imprime datos de moto"} }
En este código, la clase PrintVisitor incluye métodos que imprimen los datos de un Auto, Autobús y Motocicleta. Una vez implementada esta clase, nos gustaría usar el siguiente código para visitar todos los vehículos del aparcamiento:
PrintVisitor visitor = new PrintVisitor(); foreach (Vehiculo vehiculo: listaDeVehiculosEstacionados) { visitor.visit(vehiculo); // error de compilación }
Sin embargo, en el código mostrado, el método visit que se debe llamar depende del tipo dinámico del objeto destino de la llamada (visitor) y del tipo dinámico de un parámetro (vehículo). No obstante, en lenguajes como Java, C++ o C#, solo se considera el tipo del objeto destino de la llamada para elegir qué método invocar. En otras palabras, en Java y lenguajes similares, el compilador solo conoce el tipo estático de vehículo, que es Vehículo. Por eso, no puede inferir qué implementación de visit debe llamarse.
Para que quede más claro, el siguiente error ocurre al compilar el código anterior:
visitor.visit(vehiculo); ^ method PrintVisitor.visit(Auto) is not applicable (argument mismatch; Vehiculo cannot be converted to Auto) method PrintVisitor.visit(Autobus) is not applicable (argument mismatch; Vehiculo cannot be converted to Autobus)
De hecho, este código solo compila en lenguajes que ofrecen despacho doble de llamadas a métodos (double dispatch). En estos lenguajes, se utilizan los tipos del objeto destino y de uno de los parámetros de la llamada para elegir el método que se invocará. Sin embargo, el despacho doble solo está disponible en lenguajes más antiguos y menos conocidos hoy en día, como Common Lisp.
Por lo tanto, nuestro problema es el siguiente: ¿cómo simular double dispatch en un lenguaje como Java? Si logramos hacerlo, podremos evitar el error de compilación que ocurre en el código que mostramos.
Solución: La solución a nuestro problema consiste en utilizar el patrón de diseño Visitor. Este patrón define cómo añadir una operación a una familia de objetos, sin necesidad de modificar las clases de los mismos. Además, el patrón Visitor debe funcionar incluso en lenguajes con single dispatching de métodos, como Java.
Como primer paso, debemos implementar un método accept en cada clase de la jerarquía. En la clase raíz, este método es abstracto. En las subclases, recibe como parámetro un objeto del tipo Visitor. Y su implementación simplemente llama al método visit de ese Visitor, pasando this como parámetro. Sin embargo, como la llamada ocurre en el cuerpo de una clase, el compilador conoce el tipo de this. Por ejemplo, en la clase Auto, el compilador sabe que el tipo de this es Auto. Así que sabe que debe llamar a la implementación de visit que tiene Auto como parámetro. Para ser precisos, el método exacto que se llamará depende del tipo dinámico del objeto destino de la llamada (v). Sin embargo, esto no es un problema, ya que significa que tenemos un caso de single dispatch, que está permitido en lenguajes como Java.
abstract class Vehiculo { abstract public void accept(Visitor v); } class Auto extends Vehiculo { ... public void accept(Visitor v) { v.visit(this); } ... } class Autobus extends Vehiculo { ... public void accept(Visitor v) { v.visit(this); } ... } // Idem para Motocicleta
Por último, debemos modificar el bucle que recorre la lista de vehículos estacionados. Ahora, llamaremos a los métodos accept de cada vehículo, pasando el visitor como parámetro.
PrintVisitor visitor = new PrintVisitor(); foreach (Vehiculo vehiculo: listaDeVehiculosEstacionados) { veiculo.accept(visitor); }
En resumen, los visitors facilitan la adición de un método en una jerarquía de clases. Un visitor agrupa operaciones relacionadas —en el ejemplo, la impresión de datos de Vehículo y de sus subclases—. Pero también podría existir un segundo visitor, con otras operaciones —por ejemplo, persistir los objetos en disco—. Por otro lado, la adición de una nueva clase en la jerarquía, por ejemplo, Camión, requerirá la actualización de todos los visitors con un nuevo método: visit(Camión).
Antes de concluir, es importante mencionar que los visitors tienen una desventaja importante: pueden forzar una ruptura en el encapsulamiento de las clases que serán visitadas. Por ejemplo, Vehículo podría tener que implementar métodos públicos que expongan su estado interno para que los visitors puedan acceder a él.