Double Dispatch es un patrón de diseño para resolver situaciones en las que el comportamiento resultante no depende solamente del objeto que recibe el mensaje sino también de parámetro enviado en ese mensaje. Veamos un caso concreto para entender mejor esta situación.
Supongamos que debemos modelar un juego de naves espaciales tipo Galaga donde tenemos distintos tipos de objetos espaciales como ser Naves, Estaciones y Asteroides. Estos objetos pueden colisionar entre si y el resultado de dicha colisión depende particularmente de quienes son los objetos involucrados y del estado de los mismos. Veamos un ejemplo:
- Si una nave colisiona con un asteroide la nave es destruida (vida = 0) y el asteroide disminuye su velocidad en 3 unidades
- Si una nave colisiona con una estación a baja velocidad (velocidad < 10), entonces la nave se detiene (velocidad = 0) y la estación no sufre ningún cambio de estado
- Si una nave colisiona con una estación a alta velocidad (velocidad >= 10), entonces la nave es destruida (vida = 0) y la estación disminuye su vida en 4 unidades.
Una solución trivial podría ser que cada objeto tenga un método «collideWith» en el que se verifique contra quien está chocando y se actúe en base a ello:
// SpaceShip public void collideWith(SpaceObject otherObject) { if (otherObject.getClass() == Asteroid.class) { this.life = 0; } if (otherObject.getClass() == Station.class) { if (this.speed < 10){ this.speed = 0; } else{ this.life = 0; } } }
Código completo Java de esta solución disponible aquí.
Las clases Asteroide y Estación tendrían métodos análogos.
Si bien esta solución funciona, resulta un poco «rústica» en términos de Orientación a Objetos porque viola algunos principios de diseño, entre ellos viola el principio abierto-cerrado, ya que en caso que aparecer un nuevo tipo de objeto (como por ejemplo Cometa) habría que modificar cada uno de los métodos CollideWith para contemplar los nuevos casos.
Es aquí donde el Double Dispatch nos propone una alternativa un poco más elegante. La idea es que cuando un objeto recibe una colisión, delega la resolución al otro objeto invocando a un método más concreto como se muestra en el siguiente fragmento de código:
// SpaceShip // Dado que aquí "this" es SpaceShip, se terminará ejecutando // el método collideWith(SpaceShip) del otherObject public void collideWith(SpaceObject otherObject) { otherObject.collideWith(this); } protected void collideWith(Asteroid asteroid) { this.life = 0; } protected void collideWith(Station station) { // do something } protected void collideWith(SpaceShip ship) { // do other something }
Código completo Java de esta solución disponible aquí.
Esta solución implica que cada clase tendrá un método collideWith por cada clase con la que pueda llegar a colisionar. En este caso particular donde tenemos 3 clases, tendremos 9 métodos (dependiendo del comportamiento particular que definamos para resolver la colisión podríamos tener tal vez algunos métodos menos). En cierto modo esta solución también viola el principio abierto-cerrado, porque en caso de aparecer un nuevo tipo de objeto es necesario modificar todas las clases para agregar un nuevo método que resuelva la colisión aunque a diferencia de la solución anterior, aquí no modificamos un método existente sino que agregamos un nuevo método, lo cual es menos rústico.
Finalmente la solución a que mi gusta para esta situación entra en la categoría que Steve McConnell denomina Table-Driven methods (capítulo 18 de libro Code Complete). La idea es emular una tabla de métodos virtuales que es lo que usan algunos lenguajes a bajo nivel para resolver el late binding. Más concretamente lo que se hace es que cada objeto tenga una tabla o mejor dicho un diccionario (collisionMap) donde la clave es la clase contra la cual colisiona y el valor es un closure/lamba con la lógica a ejecutarse al colisionar contra ese tipo de objeto. Implementar esto en Smalltalk y/o Ruby es trivial, pero en Java tiene una vuelta de rosca extra, simplemente por la forma en que se implementan los lambdas en Java.
// Cuando se crea el objeto debe inicializarse el collitionMap protected void initCollisionMap() { collisionMap = new HashMap<>(); collisionMap.put(Asteroid.class, (x) -> collideWithAsteriod(x)); collisionMap.put(Station.class, (x) -> collideWithStation(x)); } // Este el punto de entrada en la colision // Notar que la busqueda en la tabla es polimorfica public void collideWith(SpaceObject otherObject) { CollitionHandler handler = this.collisionMap.get(otherObject.getClass()); handler.collideWith(otherObject); } private void collideWithAsteriod(SpaceObject other) { this.life = 0; } private void collideWithStation(SpaceObject x) { // do something }
Código completo Java de esta solución disponible aquí.
A la hora de agregar un nuevo tipo de objeto, esta solución requiere agregar una nueva entrada al collisionMap y un nuevo método si es que la interacción con este nuevo tipo de objeto es distinta a las ya existentes. Otro punto interesante de esta solución es la posibilidad de variar el comportamiento de cada instancia de la misma clase, ya que el collisionMap puede alterarse en tiempo de ejecución.
Continuará…