7. Principio de Sustitución de Liskov (Liskov Substitution Principle - LSP)

El Principio de Sustitución de Liskov (LSP, Liskov Substitution Principle) establece que si S es un subtipo de T, entonces los objetos de tipo T pueden ser reemplazados por objetos de tipo S sin alterar el comportamiento correcto del programa. Fue propuesto por Barbara Liskov y Jeannette Wing en 1987.

En términos prácticos, LSP implica que las subclases deben cumplir las expectativas que el código cliente tiene sobre la clase base. Si una subclase rompe invariantes, precondiciones o postcondiciones, la jerarquía se vuelve frágil.

7.1 Contrato del LSP

Para mantener la sustitución válida, una subclase debe:

  • No fortalecer precondiciones: no exigir más que la clase base para funcionar.
  • No debilitar postcondiciones: garantizar al menos los mismos resultados que la clase base.
  • Preservar invariantes: respetar las condiciones que siempre deben cumplirse.
  • Respetar excepciones declaradas: no lanzar nuevas excepciones no previstas por la clase base.

7.2 Señales de violación al LSP

  • Métodos que lanzan excepciones inesperadas: la subclase no soporta ciertos escenarios y rompe el flujo.
  • Condicionales por tipo: el código cliente necesita distinguir qué subclase está usando.
  • Subclases que ignoran métodos heredados: implementaciones vacías o que lanzan UnsupportedOperationException.
  • Jerarquías “rectángulo-cuadrado”: herencias que alteran la semántica original de la clase base.

7.3 Ejemplo clásico: Rectángulo y Cuadrado

Consideremos la jerarquía habitual donde Cuadrado hereda de Rectangulo. A primera vista parece correcto, pero al utilizarlo en un algoritmo genérico encontramos problemas.

class Rectangulo {
    private int ancho;
    private int alto;

    void setAncho(int ancho) { this.ancho = ancho; }
    void setAlto(int alto) { this.alto = alto; }
    int area() { return ancho * alto; }
}

class Cuadrado extends Rectangulo {
    @Override
    void setAncho(int lado) {
        super.setAncho(lado);
        super.setAlto(lado);
    }

    @Override
    void setAlto(int lado) {
        super.setAncho(lado);
        super.setAlto(lado);
    }
}

Un algoritmo que espera modificar ancho y alto por separado deja de funcionar correctamente cuando recibe un cuadrado. El contrato implícito de Rectangulo se rompe.

7.4 Refactorización cumpliento LSP

Podemos eliminar la herencia y modelar ambos conceptos mediante interfaces o composición. Aquí usamos una interfaz común para figuras de cuatro lados y delegamos la creación en clases distintas.

interface Figura {
    int area();
}

class Rectangulo implements Figura {
    private final int ancho;
    private final int alto;

    Rectangulo(int ancho, int alto) {
        this.ancho = ancho;
        this.alto = alto;
    }

    public int area() {
        return ancho * alto;
    }
}

class Cuadrado implements Figura {
    private final int lado;

    Cuadrado(int lado) {
        this.lado = lado;
    }

    public int area() {
        return lado * lado;
    }
}

Al utilizar composición en lugar de herencia forzada, cada clase respeta su contrato y se puede reemplazar libremente entre objetos que esperan una Figura.

7.5 Ejemplo aplicado a un dominio de negocio

Supongamos un módulo bancario que procesa transferencias. Una implementación incorrecta podría reutilizar la clase base pero cambiar restricciones de manera sorpresiva.

class Transferencia {
    void ejecutar(BigDecimal monto) {
        if (monto.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("Monto inválido");
        }
        // lógica común
    }
}

class TransferenciaInternacional extends Transferencia {
    @Override
    void ejecutar(BigDecimal monto) {
        if (monto.compareTo(new BigDecimal("1000")) < 0) {
            throw new IllegalArgumentException("Monto mínimo 1000 para internacionales");
        }
        super.ejecutar(monto);
    }
}

El cliente que trabaja con Transferencia espera que cualquier monto positivo sea válido, pero la subclase viola esa expectativa ampliando las precondiciones. Esto rompe LSP.

7.6 Corrección utilizando composición

En lugar de heredar, definimos una interfaz y separamos las validaciones para cada caso.

interface OperacionTransferencia {
    void ejecutar(BigDecimal monto);
}

class TransferenciaLocal implements OperacionTransferencia {
    public void ejecutar(BigDecimal monto) {
        validarMontoPositivo(monto);
        // lógica local
    }

    private void validarMontoPositivo(BigDecimal monto) {
        if (monto.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("Monto inválido");
        }
    }
}

class TransferenciaInternacional implements OperacionTransferencia {
    public void ejecutar(BigDecimal monto) {
        validarMontoPositivo(monto);
        validarMinimoInternacional(monto);
        // lógica internacional
    }

    private void validarMontoPositivo(BigDecimal monto) {
        if (monto.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("Monto inválido");
        }
    }

    private void validarMinimoInternacional(BigDecimal monto) {
        if (monto.compareTo(new BigDecimal("1000")) < 0) {
            throw new IllegalArgumentException("Monto mínimo 1000 para internacionales");
        }
    }
}

Ahora el cliente depende de la interfaz OperacionTransferencia y cada implementación asume su propio conjunto de reglas sin violar las expectativas de las otras.

7.7 Patrones y prácticas relacionadas

  • Composición sobre herencia: favorece la reutilización sin comprometer contratos.
  • Programación por contrato: documenta precondiciones y postcondiciones para cada clase.
  • Pruebas polimórficas: asegurar que todas las implementaciones se comporten igual ante el cliente.
  • Inmutabilidad: objetos inmutables reducen la cantidad de invariantes que deben protegerse.

7.8 Beneficios de respetar LSP

  • Polimorfismo confiable: el código cliente confía en las abstracciones sin condicionales extra.
  • Jerarquías limpias: se evita la proliferación de herencias problemáticas.
  • Pruebas reutilizables: los mismos casos de prueba se aplican a todas las implementaciones.
  • Mantenimiento más predecible: menos sorpresas al introducir nuevas variantes del mismo contrato.

7.9 Recomendaciones finales

  • Documentar contratos: explicitar qué garantiza cada clase ayuda a detectar violaciones.
  • Usar pruebas de regresión: ejecutar la misma suite contra todas las implementaciones asegura la sustitución.
  • Revisar jerarquías existentes: detectar clases que extienden otras sin respetar las reglas y refactorizar gradualmente.
  • Combinar con DIP: al depender de interfaces claras, LSP se vuelve más fácil de cumplir.

El Principio de Sustitución de Liskov fortalece el polimorfismo y prepara el terreno para aplicar correctamente la segregación de interfaces y la inversión de dependencias. En los siguientes capítulos veremos cómo estos principios continúan construyendo estructuras robustas.