Si ya leíste el principio Open/Closed, hoy vamos a hablar del principio de sustitución de Liskov:

Principio de Sustitución de Liskov

El principio de sustitución de Liskov nos dice que si en alguna parte de nuestro código estamos usando una clase, y esta clase es extendida, tenemos que poder utilizar cualquiera de las clases hijas y que el programa siga siendo válido. Esto nos obliga a asegurarnos de que cuando extendemos una clase no estamos alterando el comportamiento de la padre.

Este principio viene a desmentir la idea preconcebida de que las clases son una forma directa de modelar la realidad. Esto no siempre es así, y el ejemplo más típico es el de un rectángulo y un cuadrado. En breve veremos por qué.

La primera en hablar de él fue Bárbara Liskov (de ahí el nombre), una reconocida ingeniera de software americana.

¿Cómo detectar que estamos violando el principio de sustitución de Liskov?

Seguro que te has encontrado con esta situación muchas veces: creas una clase que extiende de otra, pero de repente uno de los métodos te sobra, y no sabes que hacer con él. Las opciones más rápidas son bien dejarlo vacío, bien lanzar una excepción cuando se use, asegurándote de que nadie llama incorrectamente a un método que no se puede utilizar. Si un método sobrescrito no hace nada o lanza una excepción, es muy probable que estés violando el principio de sustitución de Liskov. Si tu código estaba usando un método que para algunas concreciones ahora lanza una excepción, ¿cómo puedes estar seguro de que todo sigue funcionando?

Imagen de Derick Balley

Imagen de Derick Balley

Otra herramienta que te avisará fácilmente son los tests. Si los tests de la clase padre no funcionan para la hija, también estarás violando este principio. Con este segundo caso vamos a ver el ejemplo.

Ejemplo

En la vida real tenemos claro que un cuadrado es un rectángulo con los dos lados iguales. Si intentamos modelar un cuadrado como una concreción de un rectángulo, vamos a tener problemas con este principio:

public class Rectangle {
    
    private int width;
    private int height;

    public int getWidth() {
        return width;
    }

    public void setWidth(int width) {
        this.width = width;
    }

    public int getHeight() {
        return height;
    }

    public void setHeight(int height) {
        this.height = height;
    }

    public int calculateArea() {
        return width * height;
    }
}

Y un test que comprueba el área:

@Test
public void testArea() {
    Rectangle r = new Rectangle();
    r.setWidth(5);
    r.setHeight(4);
    assertEquals(20, r.calculateArea());
}

La definición del cuadrado sería la siguiente:

public class Square extends Rectangle {

    @Override public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width);
    }

    @Override public void setHeight(int height) {
        super.setHeight(height);
        super.setWidth(height);
    }
}

Prueba ahora en el test a cambiar el rectángulo por un cuadrado. ¿Qué ocurrirá? Este test no se cumple, el resultado sería 16 en lugar de 20. Estamos por tanto violando el principio de sustitución de Liskov.

¿Cómo lo solucionamos?

Hay varias posibilidades en función del caso en el que nos encontremos. Lo más habitual será ampliar esa jerarquía de clases. Podemos extraer a otra clase padre las características comunes y hacer que la antigua clase padre y su hija hereden de ella. Al final lo más probable es que la clase tenga tan poco código que acabes teniendo un simple interfaz. Esto no supone ningún problema en absoluto:

public interface IRectangle {
    int getWidth();
    int getHeight();
    int calculateArea();
}

public class Rectangle extends IRectangle {
    ...
}

public class Square extends IRectangle {
    ...
}

Pero para este caso en particular, nos encontramos con una solución mucho más sencilla. La razón por la que no se cumple que un cuadrado sea un rectángulo, es porque estamos dando la opción de modificar el ancho y alto después de la creación del objeto. Podemos solventar esta situación simplemente usando inmutabilidad.

La inmutabilidad es un tema muy interesante que trataré en algún artículo más adelante. Consiste en que una vez que se ha creado un objeto, el estado del mismo no puede volver a modificarse. La inmutabilidad tiene múltiples ventajas, entre ellas un mejor uso de memoria (todo su estado es final) o seguridad en múltiples hilos de ejecución. Pero ciñéndonos al ejemplo, ¿cómo nos ayuda aquí la inmutabilidad? De esta forma:

public class Rectangle {

    public final int width;
    public final int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }
}

public class Square extends Rectangle {

    public Square(int side) {
        super(side, side);
    }
}

Desde el momento de la instanciación del objeto, todo lo que hagamos con él será válido, ya usemos un rectángulo o un cuadrado. El problema que había detrás de este ejemplo es que la asignación de una parte del estado modificaba mágicamente otro campo. Sin embargo, con este nuevo enfoque, al no permitir las modificaciones, el funcionamiento de ambas clases es totalmente predecible.

Conclusión

El principio de Liskov nos ayuda a utilizar la herencia de forma correcta, y a tener mucho más cuidado a la hora de extender clases. En la práctica nos ahorrará muchos errores derivados de nuestro afán por modelar lo que vemos en la vida real en clases siguiendo la misma lógica. No siempre hay una modelización exacta, por lo que este principio nos ayudará a descubrir la mejor forma de hacerlo.

La cuarta parte tratará sobre el principio de segregación de interfaces. ¿Qué te ha parecido hasta ahora? ¿Crees que tiene sentido aplicar estos principios en tu día a día?