LSP: Principio de Sustitución de Liskov
LSP: Principio de Sustitución de Liskov
Llegamos a la L de SOLID, el principio con el nombre propio más famoso del acrónimo y, curiosamente, el que más gente enuncia mal. Después de ver cómo el OCP nos deja extender comportamiento con polimorfismo, toca preguntarnos algo incómodo: ¿y si ese polimorfismo miente? ¿Qué pasa cuando una subclase promete ser un tipo, pero se comporta como otro? Ahí es donde entra Liskov.
Definición formal
El principio nace de un trabajo de Barbara Liskov presentado en 1987 (Data Abstraction and Hierarchy) y refinado junto a Jeannette Wing en 1994. La formulación académica dice algo como:
Sea
Φ(x)una propiedad demostrable sobre objetosxde tipoT. EntoncesΦ(y)debe ser cierta para objetosyde tipoS, dondeSes un subtipo deT.
Traducido a la práctica de todos los días:
Si
Ses un subtipo deT, los objetos de tipoTen un programa pueden reemplazarse por objetos de tipoSsin alterar la corrección de ese programa.
En cristiano
Una subclase no debe mentir sobre el contrato de su superclase. Si en cualquier parte del código dice Rectangulo, yo tengo que poder pasarle un Cuadrado (si Cuadrado extends Rectangulo) y que todo siga funcionando exactamente igual. Si al hacer ese reemplazo algo se rompe, cambia de comportamiento o me obliga a preguntar “¿de qué subtipo concreto se trata?”, entonces la herencia está rota y estás violando LSP.
Dicho de otro modo: heredar no es solo reusar código, es firmar un contrato. Cuando escribís class Cuadrado extends Rectangulo, le estás jurando al mundo que un cuadrado es un rectángulo en todo lo que importa. LSP es la factura de esa promesa.
El code smell que ataca
LSP ataca la herencia por conveniencia: usar extends porque “se parecen” o “comparten campos”, sin verificar que el subtipo respete de verdad las expectativas del tipo base. Los olores concretos que delatan una violación de LSP son:
- Métodos sobrescritos que lanzan excepciones del tipo
throw new Error("no soportado")oNotImplementedException. - Métodos sobrescritos que no hacen nada (cuerpo vacío) para “cancelar” comportamiento heredado.
- Chequeos de tipo con
instanceofo banderas para tratar distinto a ciertos subtipos (“si es un pingüino, no llames a volar”). - Fortalecer las condiciones de entrada del subtipo (pedir más de lo que pedía el padre) o debilitar las garantías de salida (devolver menos de lo prometido).
Todos son síntomas de lo mismo: el subtipo no puede sustituir al tipo base sin sorpresas.
Ejemplo MALO: el clásico Rectángulo / Cuadrado
Este es el ejemplo canónico de LSP, y lo es porque es tramposamente intuitivo. En matemáticas, un cuadrado es un rectángulo (uno con los cuatro lados iguales). Parece la excusa perfecta para usar herencia. Veamos qué pasa.
class Rectangulo {
protected ancho: number;
protected alto: number;
constructor(ancho: number, alto: number) {
this.ancho = ancho;
this.alto = alto;
}
setAncho(ancho: number): void {
this.ancho = ancho;
}
setAlto(alto: number): void {
this.alto = alto;
}
getAncho(): number {
return this.ancho;
}
getAlto(): number {
return this.alto;
}
area(): number {
return this.ancho * this.alto;
}
}
Ahora modelamos el cuadrado como subclase. Como un cuadrado debe mantener siempre ancho === alto, sobrescribimos los setters para preservar esa invariante:
class Cuadrado extends Rectangulo {
constructor(lado: number) {
super(lado, lado);
}
// Un cuadrado no puede tener ancho distinto del alto:
// si cambias uno, cambiamos ambos para "mantener la invariante".
override setAncho(ancho: number): void {
this.ancho = ancho;
this.alto = ancho;
}
override setAlto(alto: number): void {
this.ancho = alto;
this.alto = alto;
}
}
A primera vista parece razonable e incluso “cuidadoso”. El problema aparece cuando alguien escribe código genérico que trabaja contra el tipo base Rectangulo, tal como LSP nos permite hacer:
// Esta función NO sabe (ni debería saber) qué subtipo concreto recibe.
// Su contrato depende del comportamiento prometido por Rectangulo:
// "si fijo ancho=5 y alto=4, el área es 20".
function redimensionarYVerificar(rect: Rectangulo): void {
rect.setAncho(5);
rect.setAlto(4);
const esperado = 5 * 4; // 20
if (rect.area() !== esperado) {
throw new Error(
`Se esperaba área ${esperado} pero se obtuvo ${rect.area()}`
);
}
}
El test que falla
import { describe, it, expect } from "vitest";
describe("LSP - Rectangulo vs Cuadrado", () => {
it("un Rectangulo respeta el contrato", () => {
const rect = new Rectangulo(2, 3);
expect(() => redimensionarYVerificar(rect)).not.toThrow();
expect(rect.area()).toBe(20); // ancho=5, alto=4 → 20 ✅
});
it("un Cuadrado ROMPE el contrato del tipo base", () => {
const cuadrado = new Cuadrado(2);
// setAncho(5) → ancho=5, alto=5
// setAlto(4) → ancho=4, alto=4
// area() = 16, no 20 → la función lanza excepción
expect(() => redimensionarYVerificar(cuadrado)).toThrow(
"Se esperaba área 20 pero se obtuvo 16"
);
});
});
El segundo test demuestra el desastre: redimensionarYVerificar funciona con un Rectangulo y falla con un Cuadrado, aunque Cuadrado sea un subtipo perfectamente válido a ojos del compilador. TypeScript compila sin una sola queja. El error solo aparece en tiempo de ejecución, con datos reales, probablemente en producción.
La conclusión es contraintuitiva pero fundamental: en programación orientada a objetos, un cuadrado NO es un rectángulo. La relación “es-un” de la geometría no equivale a la sustituibilidad de comportamiento que exige la herencia. El Rectangulo promete que setAncho y setAlto son independientes; el Cuadrado rompe esa promesa. Eso es mentir sobre el contrato.
Ejemplo MALO nº 2: el pájaro que no vuela
El segundo caso clásico usa el mundo animal, y expone otra forma de violar LSP: sobrescribir un método para lanzar una excepción porque el subtipo “no puede” cumplirlo.
class Ave {
private nombre: string;
constructor(nombre: string) {
this.nombre = nombre;
}
getNombre(): string {
return this.nombre;
}
volar(): string {
return `${this.nombre} está volando`;
}
}
class Aguila extends Ave {
// Perfecto: un águila vuela, respeta el contrato de Ave.
}
class Pinguino extends Ave {
// ❌ Un pingüino NO vuela. Sobrescribimos para lanzar excepción.
override volar(): string {
throw new Error("Los pingüinos no pueden volar");
}
}
Y otra vez, cualquier código genérico escrito contra Ave explota al recibir un Pinguino:
function hacerVolarLaBandada(aves: Ave[]): string[] {
// El contrato de Ave dice: "toda ave puede volar".
// Confiamos en él. El Pinguino nos traiciona.
return aves.map((ave) => ave.volar());
}
const bandada: Ave[] = [
new Aguila("Águila real"),
new Pinguino("Pingüino emperador"), // 💥 revienta en runtime
];
// hacerVolarLaBandada(bandada) lanza:
// "Los pingüinos no pueden volar"
El olor está a la vista: un override cuyo único cometido es hacer throw. Es la subclase gritando “yo no soy realmente esto que digo ser”. El mismo patrón aparece con el PatoDeGoma extends Pato: un pato de goma no nada ni grazna de verdad, y si lo forzás en la jerarquía terminás sobrescribiendo métodos para lanzar errores o para no hacer nada.
Las reglas de LSP (contrato por diseño)
Bertrand Meyer, con su idea de diseño por contrato, dio el marco teórico que explica por qué fallan estos ejemplos. Un subtipo respeta LSP si, y solo si:
1. Las precondiciones no pueden fortalecerse
Una precondición es lo que un método exige para funcionar. El subtipo no puede pedir más que el tipo base. Si Rectangulo.setAncho acepta cualquier número positivo, el Cuadrado no puede exigir de repente “solo si además es igual al alto”. Fortalecer la precondición rompe a quienes llamaban confiados en la regla del padre.
2. Las postcondiciones no pueden debilitarse
Una postcondición es lo que un método garantiza al terminar. El subtipo no puede prometer menos. Si Rectangulo.area() garantiza devolver ancho * alto con ambos independientes, el Cuadrado no puede debilitar esa garantía haciendo que cambiar el alto altere el ancho.
3. Las invariantes deben preservarse
Una invariante es una verdad que se mantiene siempre en un objeto. Las invariantes del tipo base deben seguir siendo ciertas en el subtipo. El pingüino viola la invariante implícita “toda Ave responde a volar() sin explotar”.
Contravarianza y covarianza (sin abrumar)
Detrás de esas tres reglas hay dos ideas más finas que conviene conocer aunque no las apliques a diario:
- Contravarianza de los argumentos: un método sobrescrito puede aceptar parámetros de tipos iguales o más generales (nunca más específicos). Es la versión “tipada” de “no fortalezcas las precondiciones”.
- Covarianza del retorno: un método sobrescrito puede devolver tipos iguales o más específicos (nunca más generales). Es la versión “tipada” de “no debilites las postcondiciones”.
TypeScript ya aplica parte de estas reglas a nivel de tipos, pero LSP va más allá de lo que el compilador verifica: incluye el comportamiento (los valores, las excepciones, los efectos), y eso el compilador no lo ve.
El refactor: composición e interfaces correctas
La cura no es “arreglar la subclase”, es dejar de forzar una herencia que no existe. Tenemos dos herramientas principales.
Solución A: modelar el contrato correcto con una interfaz
Para las aves, el problema es que “volar” no pertenece a toda ave. Segregamos la capacidad (esto es también ISP en acción):
interface Ave {
getNombre(): string;
}
interface Voladora {
volar(): string;
}
class Aguila implements Ave, Voladora {
constructor(private readonly nombre: string) {}
getNombre(): string {
return this.nombre;
}
volar(): string {
return `${this.nombre} está volando`;
}
}
class Pinguino implements Ave {
constructor(private readonly nombre: string) {}
getNombre(): string {
return this.nombre;
}
nadar(): string {
return `${this.nombre} está nadando`;
}
}
Ahora la función genérica solo pide lo que de verdad necesita, y es imposible pasarle un pingüino donde se requieren voladoras:
function hacerVolarLaBandada(voladoras: Voladora[]): string[] {
return voladoras.map((v) => v.volar());
}
// hacerVolarLaBandada([new Pinguino("emperador")]);
// ^^^^^^^^^^^^^^^^^^^^^^^^^
// ❌ Error de COMPILACIÓN: Pinguino no implementa Voladora.
// El bug pasó de runtime a tiempo de compilación. Victoria.
Solución B: figuras inmutables sin herencia forzada
Para el caso Rectángulo/Cuadrado, la raíz del mal son los setters mutables que permiten dejar el objeto en un estado inconsistente. Modelamos las figuras como inmutables y hermanas, no una heredando de la otra, bajo una interfaz común Figura:
interface Figura {
area(): number;
}
class Rectangulo implements Figura {
constructor(
private readonly ancho: number,
private readonly alto: number
) {}
area(): number {
return this.ancho * this.alto;
}
// "Redimensionar" devuelve un objeto NUEVO, no muta el actual.
conAncho(ancho: number): Rectangulo {
return new Rectangulo(ancho, this.alto);
}
conAlto(alto: number): Rectangulo {
return new Rectangulo(this.ancho, alto);
}
}
class Cuadrado implements Figura {
constructor(private readonly lado: number) {}
area(): number {
return this.lado * this.lado;
}
conLado(lado: number): Cuadrado {
return new Cuadrado(lado);
}
}
Fijate lo que logramos: Cuadrado ya no hereda de Rectangulo, así que no puede mentir sobre setters que no existen. Ambas son Figura y son perfectamente sustituibles en cualquier lugar donde solo importe area(). La sustituibilidad se cumple porque el contrato común (Figura) es honesto: promete solo lo que ambas pueden cumplir de verdad.
Diagrama: antes y después
Antes — herencia rota, el subtipo miente sobre el contrato del padre:
classDiagram
class Rectangulo {
+setAncho(n)
+setAlto(n)
+area() number
}
class Cuadrado {
+setAncho(n) override
+setAlto(n) override
}
Rectangulo <|-- Cuadrado : extends (MIENTE)
note for Cuadrado "Sobrescribe los setters y\nrompe la invariante del padre:\nsetAncho/setAlto ya no son\nindependientes → viola LSP"
Después — contratos honestos, subtipos realmente sustituibles:
classDiagram
class Figura {
<<interface>>
+area() number
}
class Rectangulo {
-ancho: number
-alto: number
+area() number
+conAncho(n) Rectangulo
+conAlto(n) Rectangulo
}
class Cuadrado {
-lado: number
+area() number
+conLado(n) Cuadrado
}
Figura <|.. Rectangulo : implements
Figura <|.. Cuadrado : implements
note for Figura "Ambas cumplen el contrato\nreal (area). Son sustituibles\nsin sorpresas → respeta LSP"
Matices y errores comunes
La herencia no es el enemigo; la herencia incorrecta sí. LSP no te dice “nunca uses extends”. Te dice “usá extends solo cuando el subtipo pueda sustituir de verdad al tipo base en todo comportamiento observable”. Cuando la herencia es legítima (una AveVoladora que refina una Voladora), adelante. El problema es heredar por reusar campos o por un parecido superficial.
El olor del instanceof. Si en tu código escribís if (figura instanceof Cuadrado) { ... } para tratar distinto a un subtipo, es una señal casi segura de que ese subtipo no es sustituible y estás violando LSP. El polimorfismo bien hecho hace innecesarios esos chequeos.
El olor del throw new NotImplemented. Sobrescribir un método solo para lanzar una excepción es una violación de LSP con luces de neón. Significa que el subtipo no puede cumplir el contrato: no pertenece a esa jerarquía. Segregá la interfaz.
“Es-un” del lenguaje natural ≠ subtipo de comportamiento. El caso Cuadrado/Rectángulo enseña que las relaciones intuitivas (“un cuadrado es un rectángulo”, “un avestruz es un ave”) no garantizan sustituibilidad. Preguntate siempre: ¿este subtipo puede hacer TODO lo que promete el padre, con las mismas garantías? Si la respuesta es “sí, pero…” entonces es “no”.
La inmutabilidad ayuda muchísimo. Muchas violaciones de LSP nacen de setters que dejan el objeto en un estado que el subtipo no admite. Modelar con objetos inmutables (como en la solución B) elimina de raíz clases enteras de estos problemas.
Relación con los otros principios
LSP no vive solo: es el guardián de la herencia dentro de SOLID y se apoya en sus vecinos.
- Con OCP (capítulo 3): el OCP nos deja extender con polimorfismo, pero ese polimorfismo solo es seguro si se cumple LSP. Si tus subtipos mienten, el “extender sin modificar” del OCP se convierte en “extender y que todo explote”. LSP es lo que hace confiable al polimorfismo del que depende OCP.
- Con ISP (capítulo 5): muchas violaciones de LSP (el pingüino que lanza excepción en
volar) se curan segregando interfaces. Sivolarno estuviera metido a la fuerza en un contrato demasiado grande, el pingüino nunca se habría visto forzado a mentir. Verás que ISP y LSP se dan la mano una y otra vez. - Con SRP: cuando una jerarquía intenta hacer demasiadas cosas, aparecen subtipos que solo cumplen una parte del contrato. Responsabilidades bien separadas producen jerarquías más honestas.
Resumen
- LSP (Barbara Liskov, 1987): si
Ses subtipo deT, los objetos de tipoTdeben poder reemplazarse por objetos de tipoSsin alterar la corrección del programa. - En cristiano: una subclase no debe mentir sobre el contrato de su superclase.
- El caso Cuadrado/Rectángulo demuestra que la relación “es-un” del lenguaje natural no equivale a la sustituibilidad de comportamiento: en OOP, un cuadrado no es un rectángulo.
- El pájaro que no vuela muestra la violación por excepción: sobrescribir un método solo para hacer
throwes LSP roto con luces de neón. - Reglas del contrato: las precondiciones no se fortalecen, las postcondiciones no se debilitan y las invariantes se preservan (contravarianza de argumentos, covarianza de retornos).
- Cura: preferir composición sobre herencia, modelar con interfaces honestas y usar objetos inmutables. Los olores a vigilar son el
instanceofy elthrow new NotImplemented. - La herencia no es el enemigo; la herencia incorrecta sí.
Con las interfaces honestas ya asomando en las soluciones, el siguiente paso natural es aprender a diseñarlas bien: pequeñas, cohesivas y sin obligar a nadie a implementar de más.
Siguiente capítulo: ISP: Principio de Segregación de Interfaces →