En el artículo anterior, TDD: la clave para resolver eficientemente la kata Mars Rover, hicimos el diseño y la implementación de la kata Mars Rover. La solución ha sido muy elegante y eficiente, pero mejorable. Tal y como hemos hablado en el artículo sobre Lo bueno, lo feo y lo malo en el diseño de software, debemos eliminar los “olores feos” (code smells) y usar patrones de diseño.

Ahora veremos cómo se puede mejorar nuestra solución de la kata Mars Rover aplicando los patrones de diseño State, Command y Abstract Factory, sin olvidar la importancia de la inmutabilidad.

Hacer el Rover inmutable

Revisando los métodos de los comandos, vemos que son void (que no tienen tipo de retorno), y un método void puede tener solo una de las siguientes dos razones para estar en nuestro código:

  1. Que es impuro (crea efectos de lado); es decir, cambia un estado interno del objeto o un estado global de la aplicación.
  2. Si el punto anterior es falso, significa que este método es inútil y puede ser eliminado.

En nuestro caso, estos métodos son necesarios, lo cual implica que no son puros. El término “funciones puras” viene de la programación funcional y significa que la función no tiene efectos secundarios y siempre devuelve el mismo resultado para los mismos argumentos de entrada. Por ejemplo, si invocamos el método max() con los mismos parámetros, siempre devolverá el mismo resultado independientemente de cuantas veces se invoque:

public static int max(int a, int b) {
   return (a >= b) ? a : b;
}

En nuestro caso no tenemos resultado de la operación.

Si revisamos nuestros tests, vemos que estamos verificando el estado del Rover inicial, porque las ejecuciones de los comandos no devuelven resultado, en vez de esto están mutando el objeto original y esto no me hace sentir seguro. Para mí, la ejecución de la acción del test debe devolver resultado, el cual se debe usar en la verificación. Esto me da indicios de que algo no está del todo bien. Vamos por pasos. Primero resolvemos el problema que los comandos no devuelve resultado.

¿Pero cuál puede ser el problema?

Hay un concepto poderoso y simple en programación que está realmente infrautilizado: la inmutabilidad.

Básicamente, un objeto es inmutable si su estado no cambia una vez que se ha creado el objeto. En consecuencia, una clase es inmutable si sus instancias son inmutables.

Hay un argumento decisivo para usar objetos inmutables: simplifica drásticamente la programación concurrente.

Haciendo el Rover inmutable, resolveremos el problema de los "tests con mal olor".

Manos a la obra: haremos que Rover sea inmutable modificando la anotación de Lombok a @Value. Así quedaría nuestra clase Rover:

@Value
public class Rover {

 Plateau plateau;
 Coordinate coordinate;
 Orientation orientation;

 public Rover move() {
   Coordinate newCoordinate = this.getCoordinate();

   switch (orientation) {
     case NORTH:
       if (getCoordinate().getY() < plateau.getMaxY()) {
         newCoordinate = getValidCoodinate(Coordinate.of(getCoordinate().getX(), getCoordinate().getY() + 1));
       }
       break;
     case SOUTH:
       if (getCoordinate().getY() > 0) {
         newCoordinate = getValidCoodinate(Coordinate.of(getCoordinate().getX(), getCoordinate().getY() - 1));
       }
       break;
     case EAST:
       if (getCoordinate().getX() < plateau.getMaxX()) {
         newCoordinate = getValidCoodinate(Coordinate.of(getCoordinate().getX() + 1, getCoordinate().getY()));
       }
       break;
     case WEST:
       if (getCoordinate().getX() > 0) {
         newCoordinate = getValidCoodinate(Coordinate.of(getCoordinate().getX() - 1, getCoordinate().getY()));
       }
       break;
   }

   return new Rover(getPlateau(), newCoordinate, getOrientation());
 }

 public Rover turnLeft() {
   Orientation newOrientation = getOrientation();

   switch (orientation) {
     case NORTH:
       newOrientation = Orientation.WEST;
       break;
     case SOUTH:
       newOrientation = Orientation.EAST;
       break;
     case EAST:
       newOrientation = Orientation.NORTH;
       break;
     case WEST:
       newOrientation = Orientation.SOUTH;
       break;
   }

   return new Rover(getPlateau(), getCoordinate(), newOrientation);
 }

 public Rover turnRight() {
   Orientation newOrientation = getOrientation();

   switch (orientation) {
     case NORTH:
       newOrientation = Orientation.EAST;
       break;
     case EAST:
       newOrientation = Orientation.SOUTH;
       break;
     case SOUTH:
       newOrientation = Orientation.WEST;
       break;
     case WEST:
       newOrientation = Orientation.NORTH;
       break;
   }

   return new Rover(getPlateau(), getCoordinate(), newOrientation);
 }

 private Coordinate getValidCoodinate(Coordinate nextCoordinate) {
   if (plateau.hasObstacleAt(nextCoordinate)) {
     throw new ObstacleDetectedException("An obstacle has been detected at the " + coordinate);
   }

   return nextCoordinate;
 }
}

Y los tests verificarán el valor de actual:

@ParameterizedTest
@MethodSource("dataForTest")
void roverShouldMoveTo(Coordinate initial, Orientation orientation, Coordinate expected) {
 Rover rover = new Rover(plateau, initial, orientation);

 Rover actual = rover.move();

 assertThat(actual.getCoordinate()).isEqualTo(expected);
}

Usar el patrón State para el Rover

Ya tenemos los test bien, pero vamos a revisar la clase Rover. En el artículo anterior, repasamos Los principios SOLID, ¿cuáles son y cómo pueden ayudarte?

Revisando la clase Rover, detectamos el code smell Shotgun Surgery y que no respetamos los principios de Single responsibility principle y Open / closed principle.

¿Cómo se puede detectar esto?

Para solucionar estos problemas, implementaremos el patrón de diseño State (hemos hablado de él en el artículo Lo bueno, lo feo y lo malo en el diseño de software) El patrón State permite que un objeto altere su comportamiento cuando cambie su estado interno. Esto puede dar la impresión de que el objeto cambia de clase. Por ejemplo, un interruptor tiene dos estados: “encendido” o “apagado”. Cuando el botón está en el estado “encendido” (es una instancia de la clase LightOn que extiende la clase abstracta LightSwitch), la luz está encendida y cuando el botón está en el estado “apagado” (cambia su tipo a LightOff que también extiende de la clase abstracta LightSwitch), la luz está apagada.

Este es el ejemplo de la implementación básica del código del interruptor:

public abstract class LightSwitch {
 public abstract LightSwitch pressTheSwitch();
}

public class LightOn extends LightSwitch {

 @Override
 public LightSwitch pressTheSwitch() {
   return new LightOff();
 }
}

public class LightOff extends LightSwitch {

 @Override
 public LightSwitch pressTheSwitch() {
   return new LightOn();
 }
}

Con esta simple implementación del patrón State vemos que el código es simple y nos permitirá definir nuevos estados y comportamientos de manera sencilla. Por ejemplo: si tenemos unas lámparas fluorescentes, su funcionamiento tiene 3 estados: Arranque, Encendido y Apagado.

En el estado del arranque, cuando se aplica la tensión eléctrica, la lámpara fluorescente necesita una corriente alta para encenderse, y un circuito auxiliar proporciona una alta tensión para ionizar el gas dentro del tubo y arranca la lámpara.

Si cambiamos los requisitos del programa del interruptor, añadiendo este nuevo estado diciendo que el interruptor tiene tres estados y para que la luz se encienda, debe pasar por el estado del arranque… Es muy fácil añadir este nuevo estado.

Esta sería la solución:

public abstract class LightSwitch {
 public abstract LightSwitch pressTheSwitch();
}

public class LightPreheat extends LightSwitch {

 @Override
 public LightSwitch pressTheSwitch() {
   return new LightOn();
 }
}

public class LightOn extends LightSwitch {

 @Override
 public LightSwitch pressTheSwitch() {
   return new LightOff();
 }
}

public class LightOff extends LightSwitch {

 @Override
 public LightSwitch pressTheSwitch() {
   return new LightPreheat();
 }
}

Vamos a aplicar el patrón State a nuestra solución de Mars Rover utilizando la técnica de refactoring "Replace Type Code with Subclasses", que implica crear subclases para cada valor del tipo codificado. Después extraeremos los comportamientos relevantes de la clase original y los trasladaremos a estas subclases. De esta manera, lograremos reemplazar el código del flujo de control por polimorfismo y mejorar la flexibilidad y mantenibilidad de nuestro código.

En esta solución, modificaremos el Rover que sea una clase abstracta que será extendida por los 4 estados que tendría: posicionado hacia los cuatro puntos cardinales de la brújula.

Creamos un package llamado rover y movemos en él la clase Rover. Duplicamos la clase Rover (copiar y pegar el fichero en la misma carpeta) y creamos la clase RoverPositionedEast con el mismo código.

Modificamos RoverPositionedEast que extiende de la clase Rover.

Modificamos RoverPositionedEast para que extienda de la clase Rover.

Ahora duplicamos la clase RoverPositionedEast y creamos el resto de las implementaciones del estado con el mismo código: RoverPositionedNorth, RoverPositionedSouth, RoverPositionedWest.

Convertimos el Rover en clase abstracta que contiene los atributos y proporciona un constructor protected para que sea utilizado solo de las clases que la extienden. También eliminamos las implementaciones de los comandos. Así quedaría el código de la clase Rover:

@EqualsAndHashCode
public abstract class Rover {

 private final Plateau plateau;
 private final Coordinate coordinate;
 private final Orientation orientation;

 protected Rover(Plateau plateau, Coordinate coordinate, Orientation orientation) {
   if (plateau.hasObstacleAt(coordinate)) {
     throw new ObstacleDetectedException("An obstacle has been detected at the " + coordinate);
   }
   this.plateau = plateau;
   this.coordinate = coordinate;
   this.orientation = orientation;
 }

 public abstract Rover move();

 public abstract Rover turnLeft();

 public abstract Rover turnRight();

 public Plateau getPlateau() {
   return plateau;
 }

 public Coordinate getCoordinate() {
   return coordinate;
 }

 public Orientation getOrientation() {
   return orientation;
 }
}

Se podría usar las anotaciones de Lombok para hacer, pero para mayor legibilidad del código usamos solo la anotación @EqualsAndHashCode.

El IDE nos advierte que esto ha provocado problemas en los tests. Vamos a arreglarlos.

En vez de pasar a los tests la combinación de las coordenadas y orientación, ahora vamos a pasar el Rover con su estado. Esta es la modificación en los tests:

Modificación de los test.

Vamos a configurar los estados. Empezamos por RoverPositionedEast:

Después de realizar todos estos cambios, la clase RoverPositionedEast quedará así:

public class RoverPositionedEast extends Rover {

 public static final Orientation ORIENTATION = Orientation.EAST;

 public RoverPositionedEast(Plateau plateau, Coordinate coordinate) {
   super(plateau, coordinate, ORIENTATION);
 }

 public Rover move() {
   Coordinate coordinate = getCoordinate();

   if (coordinate.getX() < this.getPlateau().getMaxX()) {
     return new RoverPositionedEast(getPlateau(), Coordinate.of(coordinate.getX() + 1, coordinate.getY()));
   }

   return this;
 }

 public Rover turnLeft() {
   return new RoverPositionedNorth(getPlateau(), getCoordinate());
 }

 public Rover turnRight() {
   return new RoverPositionedSouth(getPlateau(), getCoordinate());
 }
}

Aplicando los mismos cambios para el resto de las clases del estado del Rover, terminamos con la implementación del patrón State.

Usar el patrón Command para los comandos.

El patrón Command encapsula una petición como un objeto, lo que nos permite parametrizar clientes con diferentes peticiones, poner en cola o registrar peticiones y admitir operaciones que se pueden deshacer. El comando logra desacoplar al objeto que invoca la operación del que sabe cómo realizarla, gracias a una clase base abstracta que asocia un receptor (objeto) con una acción (llamada al método). El método execute() de esta clase simplemente llama a la acción en el receptor.

Para todos los clientes de los objetos Command, estos son vistos como "cajas negras", simplemente invocando el método virtual execute() del objeto cada vez que se requiere el "servicio" del objeto.

Una clase Command contiene algún subconjunto de lo siguiente: un objeto, un método que se aplicará al objeto y los argumentos que se pasarán cuando se aplique el método. El método de execute() del comando hace que las piezas se unan para realizar la petición.

En nuestro caso tenemos unos comandos que están representados de un character, el switch decide qué comando se debe ejecutar dependiendo de este character.

Aplicando el patrón Command y Abstract Factory, podemos eliminar este switch. Vamos a hacerlo.

Creamos el package command donde estarán ubicados los comandos. Creamos la interfaz Command que definirá el método execute():

public interface Command {

 Rover execute(Rover rover);
}

Ahora crearemos la clase CommandMove que implementa esta interfaz:

public class CommandMove implements Command {

 @Override
 public Rover execute(Rover rover) {
   return rover.move();
 }
}

Crearemos de la misma manera las clases CommandTurnLeft y CommandTurnRight.

Ahora queda “pegar” todas estas implementaciones para que trabajen en conjunto. Usaremos una factoría para crear y aplicar los comandos:

public class CommandFactory {

 private Map<String, Command> commands;

 public CommandFactory() {
   this.initCommands();
 }

 private void initCommands() {
   this.commands = Map.of(
       "M", new CommandMove(),
       "L", new CommandTurnLeft(),
       "R", new CommandTurnRight()
   );
 }

 public Rover execute(Rover rover, String c) {
   Command command = commands.get(c);
   return command.execute(rover);
 }
}

Al implementar los patrones de diseño Command y Abstract Factory en nuestro código, hemos mejorado significativamente su flexibilidad y modularidad. Con el patrón Command, pudimos separar la responsabilidad de los objetos que invocan una operación de aquellos que la realizan, lo que nos permite crear objetos más modulares y fáciles de cambiar. Por otro lado, el patrón Abstract Factory nos permitió encapsular la creación de objetos relacionados en una factoría, lo que nos brinda la capacidad de cambiar fácilmente la implementación concreta de los objetos o añadir nuevas implementaciones sin afectar el resto del código.

Conclusión

Al detectar y eliminar los code smells, así como aplicar patrones de diseño adecuados, podemos mejorar significativamente la solidez y escalabilidad de una solución de software, lo que nos permite incorporar nuevas funcionalidades y adaptarnos a cambios en los requisitos de manera más eficiente.

Si bien algunos podrían considerar esta práctica como sobre ingeniería para este caso en concreto, aunque estoy de acuerdo con ellos, en este caso se ha utilizado como un ejemplo para demostrar las ventajas de la inmutabilidad y los patrones de diseño State, Command y Abstract Factory en el desarrollo de software robusto y eficiente.

Espero que este artículo haya sido útil para mostrar cómo estas técnicas pueden mejorar significativamente la calidad y la eficiencia del software.

Como siempre, tenéis el código en GitHub. Podéis usar la rama solution como punto de partida y la rama apply design patterns para ver la solución con commit separado para cada paso.

Bibliografía

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete

Estamos comprometidos.

Tecnología, personas e impacto positivo.