Mucho se habla de diseño y los principios a seguir a la hora de construir software. Sin lugar a dudas, uno de los mantras más repetidos es S.O.L.I.D., un acrónimo para cinco principios básicos que nos ayudan a desarrollar software:

  • S es para Single Responsibility, una función, clase o componente debe tener una única responsabilidad, es decir, una única razón para cambiar.
  • O es para Open-Closed, nuestros componentes tienen que estar abiertos a la extensión y cerrados a la modificación.
  • L es para Liskov Substitution Principle, una clase debería poderse reemplazar por cualquier de sus subclases sin modificar el comportamiento general del sistema.
  • I es para Interface Segregation, las interfaces de los componentes deben estar separadas unas de otras.
  • D es para Dependency Injection, nuestros componentes no crean aquellos otros componentes de los que dependen, sino que éstos se pueden inyectar.

El grado de dificultad para seguir estos principios varía. Por ejemplo, es relativamente sencillo diseñar una clase para que tome sus dependencias de su constructor, de forma que sea fácil inyectarlas desde el exterior. Sin embargo, es más difícil, en general, preparar nuestro sistema para que sea abierto a la extensión, fundamentalmente porque es complicado adelantarse al futuro y saber cómo vamos a querer extender nuestro sistema. A veces, un intento excesivo de aplicar este principio nos puede llevar a diseños más complicados de lo realmente necesario por intentar anticiparnos a lo que va a ocurrir en el futuro y hacer nuestro software demasiado genérico y abstracto.

Pero lo que quería enseñar en este artículo es un ejemplo concreto de cómo la violación de uno de estos principios provocó un fallo de un sistema con consecuencias reales en producción. El principio en concreto que fue violado es, quizá, áquel del que menos solemos hablar: el Principio de Substitución de Liskov. Cuando una clase y sus derivadas siguen este principio, sabemos que podemos substituir con seguridad cualquier aparición de la clase base en nuestra jerarquía por cualquiera de sus subclases sin alterar el comportamiento que se espera. Es decir, podemos extender el comportamiento de nuestro sistema utilizando polimorfismo de clase sin que las cosas se rompan.

La importancia de lo que acabamos de decir a veces no queda clara. Una primera aproximación naive al problema puede llevarnos a pensar que este principio se debe seguir por defecto. Al fin y al cabo, cualquier lenguaje orientado a objetos te dejará substituir una instancia de una clase por cualquiera de sus hijas y el resultado seguirá compilando, el mecanismo de herencia garantiza que las clases hijas siguen siendo sintácticamente compatibles. El problema, sin embargo, no es sintáctico (eso ya nos lo da el lenguaje) sino semántico, y ahí es donde este principio entra en acción.

Cuando LSP falla

¿Qué hay tan malo que pueda pasar cuando las clases hijas no son semánticamente compatibles? Os pondré un ejemplo real donde se ven los efectos que tiene no seguir este principio.

En un proyecto pasado, teníamos un sistema de facturación que debe iniciar el cobro de facturas a partir de una fecha que ya ha sido programada (usualmente la fecha en que se realizará el cobro de la factura se establece cuando se crea un contrato). Tenemos un componente que se encarga de comprobar, para cada uno de los contratos, si ha llegado la fecha de cobro.

public class BillingSystem {

  ...

  public void billIfNeeded(Contract aContract) {
    Date today = Time.currentDateWithNoTime();
    if (isBillableAtDate(aContract.getScheduledBillingDate(), today)){
      startBillingProcess(aContract);
    }
  }

  private boolean isBillableAtDate(Date scheduledDate, Date currentDate) {
    return scheduledDate.equals(currentDate);
  }

  private startBillingProcess(Contract aContract) {
    ...
  }

  ...

}

Nuestro sistema tiene una característica especial: sólo inicia cobros de lunes a viernes, no ejecutándose durante los fines de semana. Debido a esto, es posible que un lunes cualquiera, tengamos que iniciar el cobro de un contrato que debería haber sido cobrado durante el fin de semana. Por lo tanto, la comprobación que se hace en isBillableAtDate no es suficiente, así que modificamos su implementación:

public class BillingSystem {

  ...

  public void billIfNeeded(Contract aContract) {
    Date today = Time.currentDateWithNoTime();
    if (isBillableAtDate(aContract.getScheduledBillingDate(), today)){
      startBillingProcess(aContract);
    }
  }

  private boolean isBillableAtDate(Date scheduledDate, Date currentDate) {
    return scheduledDate.equals(currentDate) || scheduledDate.before(currentDate);
  }

  private startBillingProcess(Contract aContract) {
    ...
  }

  ...

}

Con este cambio, iniciaremos en proceso de cobro tanto si la fecha programada es hoy como si ésta ya ha pasado. Todo parecería que está bien hasta que empezamos a observar que estamos cobrando un día más tarde de lo que habíamos programado. ¿Cómo es esto posible?

La herencia de clases entra en juego

Nuestra clase Contract tiene una peculiaridad: la información que contiene es persistida en una tabla (contracts) de una base de datos relacional y utilizamos un motor de persistencia. Nuestra clase es algo parecido a:

public class Contract {

  ...

  private Date scheduledBillingDate;

  public Date getScheduledBillingDate() {
    return scheduledBillingDate;
  }

  ...

}

Todo debería estar bien, pero nuestro framework tiene una peculiaridad: cuando recupera fechas de la base de datos, los campos de tipo Date no son rellenados con instancias reales de la clase Date sino con instancias de una subclase que tiene una implementación algo peculiar del método equals:

public class FrameworksSpecificDate extends Date {

  ...

  public boolean equals(Object anotherDate) {
    if (!anotherDate.getClass() != getClass()) return false;

    ...
  }

  ...

}

Es esta implementación de equals la que nos hace darnos cuenta de que tenemos un comportamiento extraño cuando hacemos scheduledDate.equals(currentDate), ya que scheduledDate es una instancia de la subclase de Date implementada por nuestro framework de persistencia y currentDate es una instancia de Date. En circunstancias normales, esperaríamos que, si currenDate representa la misma fecha que scheduledDate, la comprobación hecha con equals debería devolver true. Sin embargo, como la implementación de equals que hemos visto en esta subclase empieza realizando una comprobación del tipo del objeto con el que se compara, nos está devolviendo false porque currentDate no es una instancia de esta clase específica, sino de una instancia de Date.

¿Por qué tenemos este problema?

Nuestra expectativa sobre el método equals era que dos objetos que representen la misma fecha retornarían true cuando llamamos a equals sobre cualquiera de ellos. Sin embargo, la implementación ofrecida por esta implementación en particular, que una subclase de Date, no satisface esta expectativa. Nos encontramos con que la semántica de dicha implementación difiere de la original en la clase base Date y esto hace que nuestro código termine iniciando el proceso de cobro un día más tarde (la implementación de before ofrecida por la subclase sí se adhiere lo suficiente a lo que esperamos para que se evalúe como true cuando la fecha planificada es anterior a la fecha actual).

Se da la circunstancia de que este las pruebas unitarias que teníamos para esta lógica no detectó en ningún momento el problema porque en ellas se utiliza una instancia de Contract que se inicia con un valor para scheduledDate que usaba una instancia de Date.

Conclusiones

Una conclusión clara que podemos sacar es no dar por sentado que, cuando vayamos a usar una librería o framework desarrollado por otros, el principio de substitución de Liskov vaya a ser siempre respetado. Siendo justos con nuestro framework ORM, es bastante frecuente que las implementaciones de métodos como equals incumplan dicho principio (esta pregunta relacionada en StackOverflow es bastante ilustrativa). Por lo tanto, lo más seguro sería utilizar en nuestros tests instancias de las clases concreatas que vayan a usarse en producción. Esto es más fácil de decir que de hacer muchas veces, ya que no siempre sabemos que un determinado framework va a substituir una instancia de la clase que esperamos por una subclase propia.

La segunda conclusión sería intentar seguir este principio lo máximo posible cuando desarrollemos código. De esta forma, estamos ahorrando errores que pueden llegar a ser muy difíciles de detectar en el futuro, tanto a nosotros como a terceros.

Por cierto, si tenéis curiosidad, aquí tenéis la solución que le dimos al problema:

public class BillingSystem {

  ...

  private boolean isBillableAtDate(Date scheduledDate, Date currentDate) {
    return scheduledDate.getTime() <= currentDate.getTime();
  }

  ...

}

De esta forma, evitamos el problema con equals (la implementación de getTime es correcta en ambas clases, Date y la subclase utilizada por nuestro framework de persistencia) y, además, el código queda más claro.