Dismitificando SOLID: La "L" de Liskov y el Arte de la Sustitución Segura (LSP)

Dismitificando SOLID: La "L" de Liskov y el Arte de la Sustitución Segura (LSP)

¡Hola nuevamente! Ya hemos navegado por la "S" (Responsabilidad Única) y la "O" (Principio Abierto/Cerrado). Hoy, nos adentraremos en la tercera letra de nuestro acrónimo SOLID: la "L", que corresponde al Principio de Sustitución de Liskov (Liskov Substitution Principle).

Si bien, este nombre en comparación a los anteriores no es tan descriptivo su nombre viene en honor a Barbara Liskov, quien recibió fue pionera en la creación de este concepto.

Este principio es el guardián de nuestras jerarquías de herencia. Nos asegura que podemos usar objetos de clases derivadas en lugar de objetos de sus clases base sin que nuestro programa se comporte de manera inesperada o, peor aún ¡explote!

¿Qué es el Principio de Sustitucion de Liskov (LSP)?

La definición formal puede ser un poco intimidante o confusa:

Si S es un subtipo de T, entonces los objetos de tipo T en un programa pueden ser sustituidos por objetos tipo S sin alterar ninguna de las propiedades deseables de ese programa (corrección, realización de tareas, etc...).

En palabras más sencillas, y como lo resume Uncle Bob:

Los subtipos deben ser sustituibles por sus tipos base.

Esto significa que cualquier código que utilice una referencia a una clase base debe poder operar con un objeto de cualquiera de sus clases derivadas sin "darse cuenta" de la diferencia. La clase derivada debe mantener el contrato con la clase base, funcionando de una manera que no cambie el funcionamiento de la clase base.

¿Por Qué es Tan Importante el LSP?

Respetar el LSP es crucial para mantener un diseño robusto:

  1. Polimorfismo Confiable:
    Permite que el polimorfismo funcione de manera predecible.
  2. Reducción del Acoplamiento:
    El código del cliente no necesita conocer los detalles específicos de cada subtipo, solo el contrato base.
  3. Mejor Reusabilidad del Código:
    El código escrito para el tipo base se vuelve por consecuencia reutilizable para todos los subtipos que cumplen con LSP.
  4. Evita Lógica Condicional Fea:
    Previene la necesidad de usar instanceof o comprobaciones de tipo en el código cliente para tratar de forma diferente a los subtipos.
  5. Jerarquías de Clases Más Coherentes y Mantenibles:
    Fomenta un diseño de herencia donde las subclases realmente extienden el comportamiento de forma compatible.

LSP en la Práctica: El Clásico ejemplo del Rectángulo y el Cuadrado

Este ejemplo sin duda es excelente para ilustrar el LSP. Primero pongámonos en contexto, hablando en términos geométricos, un cuadrado "es un" tipo de rectángulo, vamos a intentar crear esto en JAVA.

La Tentación (Violando el LSP en JAVA):

Como es habitual en mis entradas a este blog, iniciaremos dando un ejemplo de mal uso de este principio. Imaginemos una clase Rectangle con métodos para establecer su ancho y alto. Luego creamos una clase Square que hereda de Rectangle.

class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int width) {
        System.out.println("Rectangle.setWidth(" + width + ")");
        this.width = width;
    }

    public void setHeight(int height) {
        System.out.println("Rectangle.setHeight(" + height + ")");
        this.height = height;
    }

    public int getWidth() {
        return width;
    }

    public int getHeight() {
        return height;
    }

    public int getArea() {
        return this.width * this.height;
    }
}


class Square extends Rectangle {
    // Un cuadrado debe tener ancho y alto iguales.
    // Así que si cambiamos uno, debemos cambiar el otro.

    @Override
    public void setWidth(int side) {
        System.out.println("Square.setWidth(" + side + ") -> también ajustando altura");
        super.setWidth(side);
        super.setHeight(side); // ¡Efecto secundario! Mantiene los lados iguales.
    }

    @Override
    public void setHeight(int side) {
        System.out.println("Square.setHeight(" + side + ") -> también ajustando ancho");
        super.setHeight(side);
        super.setWidth(side); // ¡Efecto secundario! Mantiene los lados iguales.
    }
}

// --- Para demostrar el uso y la violación ---

/*
public class MainLspViolation {
    // Este método ESPERA el comportamiento de un Rectangle genérico
    public static void testRectangleBehavior(Rectangle rectangle) {
        System.out.println("\nProbando con: " + rectangle.getClass().getSimpleName());
        rectangle.setWidth(5);    // Esperamos que el ancho sea 5
        rectangle.setHeight(10);  // Esperamos que el alto sea 10

        // Para un Rectangle, esperaríamos que el área sea 5 * 10 = 50.
        // El cliente de Rectangle espera que setWidth y setHeight sean independientes.
        int expectedArea = 5 * 10;
        int actualArea = rectangle.getArea();

        System.out.println("Ancho final: " + rectangle.getWidth() + ", Alto final: " + rectangle.getHeight());
        System.out.println("Área esperada (si fuera un rectángulo ideal con esos setters): " + expectedArea);
        System.out.println("Área obtenida: " + actualArea);

        if (actualArea != expectedArea && rectangle instanceof Square) {
            System.out.println("¡VIOLACIÓN DE LSP DETECTADA! El Square no se comportó como un Rectangle genérico respecto a los setters.");
        } else if (actualArea == expectedArea) {
             System.out.println("Comportamiento esperado para este tipo de rectángulo.");
        }
    }

    public static void main(String[] args) {
        System.out.println("--- Ejecutando con Potencial Violación de LSP (Java) ---");
        Rectangle rect = new Rectangle();
        Square sq = new Square();

        testRectangleBehavior(rect); // Funciona como se espera para Rectangle (Área 50)
        testRectangleBehavior(sq);   // ¡Sorpresa! Para Square, el área es 100 (10*10), no 50.
                                     // setHeight(10) también cambió el ancho a 10.
        System.out.println("--------------------------------------------------");
    }
}
*/

La Solución: Respetando LSP en JAVA

La herencia Square extends Rectangle es problemática, debido a que un rectángulo tiene setters mutables e independientes para ancho y alto. Un cuadrado no se comporta como un rectángulo. Si queremos respetar LSP, necesitaremos repensar esta jerarquía de los contratos.

Una forma común es usar una interfaz o clase abstracta, donde los contratos se establecerán de forma más clara, permitiendo una mejora en cómo se usan los mismos.

// --- Clases con LSP Respetado en Java ---

// 1. Definimos una interfaz base común (o clase abstracta)
interface Shape {
    int getArea();
}

// 2. Implementación de Rectángulo
class ProperRectangle implements Shape {
    private int width;
    private int height;

    // Constructor para un rectángulo
    public ProperRectangle(int width, int height) {
        this.width = width; // Aquí sí tiene sentido tenerlos independientes
        this.height = height;
    }
    
    public void setDimensions(int width, int height) {
        this.width = width;
        this.height = height;
    }
    
    public int getWidth() { return this.width; }
    public int getHeight() { return this.height; }

    @Override
    public int getArea() {
        return this.width * this.height;
    }
}

// 3. Implementación de Cuadrado
class ProperSquare implements Shape {
    private int side;

    public ProperSquare(int side) {
        this.side = side;
    }
    
    public void setSide(int side) {
        this.side = side;
    }

    public int getSide() { return this.side; }

    @Override
    public int getArea() {
        return this.side * this.side;
    }
}

// --- Para demostrar el uso ---
/*
public class MainLspCompliant {
    public static void printArea(Shape shape) {
      System.out.println("El area es de: " + shape.getArea());
    }
  
    // Cliente que espera específicamente un ProperRectangle y su comportamiento mutable
    public static void testMutableRectangleBehavior(ProperRectangle r) {
        r.setDimensions(7, 3); // Esto se comporta como un rectángulo
        System.out.println("Área del ProperRectangle mutable después de setDimensions(7,3): " + r.getArea()); // Esperado: 21
    }

    public static void main(String[] args) {
        System.out.println("--- Ejecutando con LSP Respetado (Java) ---");
        Shape rectShape = new ProperRectangle(5, 10);
        Shape squareShape = new ProperSquare(7);

        printArea(rectShape);  // Área 50
        printArea(squareShape); // Área 49

        // Podemos usar un ProperRectangle donde se espera un ProperRectangle
        ProperRectangle mutableRect = new ProperRectangle(2,2);
        testMutableRectangleBehavior(mutableRect);

        // NOTA: No intentaríamos pasar un ProperSquare a testMutableRectangleBehavior, porque ese método espera el contrato específico de mutabilidad de ProperRectangle, y ProperSquare no es un ProperRectangle en esta jerarquía. Son ambos Shapes.
        // Esto demuestra que la jerarquía ahora es más clara y segura.
        System.out.println("--------------------------------------------");
    }
}
*/

En esta versión, ProperRectangle y ProperSquare implementan Shape. Ya no forzamos a que Square sea un Rectangle si sus comportamientos de mutación son fundamentalmente diferentes.

Señales de alerta: ¿Cómo Detectar Violaciones de LSP?

Existen diversas señales que nos indican que no estamos siguiendo LSP, como las excepciones inesperadas, donde una subclase lanza una excepción que la superclase no declara o no se espera, esto es comúnmente utilizado con el famoso "Not implemented", o cuando la subclase en su lógica necesita enviar una excepción para dar información específica de su contrato.

Otra señal es el dejar métodos vacíos debido a que la subclase no necesita métodos que declara el padre (el cual sí los necesita), por ejemplo tenemos una clase Pingüino que hereda de Ave con un método volar().

También la comprobación de tipos en el cliente es una señal clara, el cliente no necesita comprobar los tipos con condicionales para cambiar su comportamiento, a esto se le suma la precomprobaciones grandes de condicionales para decidir qué implementación se usará, estas son señales fuertes de que se está irrespetando LSP.

Y claro, no podemos omitir la incertidumbre de cuál será el resultado luego de ejecutar un método, ya que no tendremos una forma clara de garantizar que el comportamiento será el esperado.

¿Cómo cumplir con LSP?

Para mencionar una guía básica podemos definir una serie de condiciones, como recordar que la relación "es un" (is-a) se refiere al comportamiento, no solo a la clasificación o taxonomía del mundo real.

También es necesario entender que las subclases deben extender la funcionalidad, no restringirla de forma incompatible, ya que el objetivo es asegurar la integridad de nuestro código y su funcionamiento.

Y claro, la importancia de los contratos definidos por el tipo base, ya que estos deben ser mantenidos y fortalecidos por los subtipos.

Conclusión: La Base de la Confianza en la Herencia.

La herencia es uno de los temas fundamentales en la programación orientada a objetos, y es de los temas que se ven primero, pero rara vez se ve bien explicado u orientado a un buen uso del LSP y lo que conlleva.

Sin dudas, este principio se ha vuelto un pilar que asegura que nuestras abstracciones y jerarquías sean sólidas y confiables. Nos empuja a pensar fuera cuidadosamente sobre la definición de los contratos y comportamientos de nuestras clases, llevando a un diseño más robusto, flexible y mantenible. Es el principio que nos permite decir con confianza: "puedes usar este objeto aquí, sin importar su tipo exacto, siempre y cuando cumpla con este contrato base".

Hasta aquí la entrada de hoy, nos vemos en la próxima, donde veremos el funcionamiento de la "I" de SOLID, ¡Hasta entonces!