🚨 ¡Nueva review! ¡Mi teclado ideal! ⌨️ Perfecto para programar, el Logitech MX Keys S . ¡Échale un ojo! 👀

¿Qué es el Principio de Sustitución de Liskov o Liskov Substitution Principle (LSP)?

Si hereda de su padre, debe poder sustituirlo sin romper nada. Así de simple.

Escrito por domin el 26 de septiembre de 2024 · Actualizado el 8 de febrero de 2026

🔄 Principio de Sustitución de Liskov (LSP)

¿Qué es?

El Liskov Substitution Principle es uno de los cinco principios SOLID de la programación orientada a objetos. Este principio dice que se debe poder reemplazar un objeto de una clase base con un objeto de cualquiera de sus subclases sin afectar al funcionamiento correcto del programa.

La formulación original de Barbara Liskov es:

“Si para cada objeto o1 de tipo S existe un objeto o2 de tipo T, tal que para todos los programas P definidos en términos de T, el comportamiento de P no cambia cuando o1 es sustituido por o2, entonces S es un subtipo de T.”

Suena bastante académico, ¿no? Vamos a traducirlo a lenguaje humano:

Las subclases no deben alterar la conducta esperada de la clase base. Si algo funciona con la clase padre, debe funcionar igual con cualquiera de sus hijas.

Gato negro representando el principio de sustitución de Liskov LSP de los principios SOLID.

¿De dónde sale este principio?

Este principio lleva el nombre de Barbara Liskov, una científica de la computación del MIT que lo formuló en 1987 junto con Jeannette Wing en un paper titulado “A Behavioral Notion of Subtyping”. Liskov no es cualquiera: fue una de las primeras mujeres en obtener un doctorado en Ciencias de la Computación en Estados Unidos (en 1968, nada menos) y ganó el Premio Turing en 2008, que viene a ser el “Nobel de la informática”.

La idea original de Liskov iba más allá de la simple herencia. Hablaba de subtipos de comportamiento: un subtipo debe respetar el contrato de su tipo base. No basta con que tenga los mismos métodos, tiene que comportarse de forma compatible.

Más tarde, Robert C. Martin (Uncle Bob) lo integró como la L de SOLID en sus escritos sobre diseño orientado a objetos, popularizándolo como lo conocemos hoy.

La idea clave

La clave del LSP es el concepto de contrato. Cuando una clase padre define un método, establece un contrato implícito: “este método recibe esto, devuelve aquello y no lanza excepciones raras”. Las subclases deben respetar ese contrato. Si no lo hacen, quien use la clase padre (sin saber que por debajo le están pasando una subclase) se va a llevar sorpresas desagradables.

Cumple LSP

La subclase respeta el contrato de la clase padre. Puedes sustituir una por otra y todo sigue funcionando como se espera.

Viola LSP

La subclase cambia el comportamiento esperado, lanza excepciones nuevas o ignora métodos. Sustituirla rompe el programa.


¿Por qué es tan importante?

Aplicar el LSP te da ventajas muy claras:


Ejemplo

Imagina que tenemos una serie de clases que extienden de Vehículo, clase que implementa un método llamado arrancarMotor.

class Vehiculo
{
    public function arrancarMotor(): string
    {
        return "broom!! broom!!!";
    }
}

Implementamos esta subclase, la cual sería correcta:

class Camion extends Vehiculo
{
    public function arrancarMotor(): string
    {
        return "broom!! brooooommmmm!! broom!!!";
    }
}

Todo perfecto porque un camión tiene un motor también, el contrato se respeta. Pero imagina que tenemos el siguiente caso:

class Bicicleta extends Vehiculo
{
    public function arrancarMotor(): string
    {
        throw new Exception("¡Pavo!, aquí no tenemos motor.");
    }
}

Aquí incumplimos el LSP porque Bicicleta no puede sustituir a Vehiculo sin romper la lógica. El contrato de Vehiculo dice “yo tengo un método arrancarMotor() que devuelve un string”. Pero Bicicleta en lugar de devolver un string, te lanza una excepción a la cara. Quien esté usando un Vehiculo y le llegue una Bicicleta por debajo, tendrá una sorpresa nada agradable.

Refactor

Para arreglar esta situación, en lugar de forzar a Bicicleta a tener un motor que no tiene, reestructuramos las clases para que el contrato sea correcto:

interface VehiculoDeMotor
{
    public function arrancarMotor(): string;
}

abstract class Vehiculo
{
    abstract public function mover(): string;
}

Ahora Camion extiende de Vehiculo (clase abstracta con el método mover) y además implementa VehiculoDeMotor:

class Camion extends Vehiculo implements VehiculoDeMotor
{
    public function mover(): string {
        return "Me muevo con motor de camión";
    }

    public function arrancarMotor(): string {
        return "¡Arrancando motor del camión!";
    }
}

Y Bicicleta extiende de Vehiculo sin implementar motor porque no lo necesita:

class Bicicleta extends Vehiculo
{
    public function mover(): string {
        return "Me muevo pedaleando";
    }

    public function pedalear(): string {
        return "¡Pedaleando sin motor!";
    }
}

De esta forma, si reemplazamos la clase base por una subclase o viceversa, no habrá problemas. Tanto Camion como Bicicleta pueden sustituir a Vehiculo porque ambas implementan mover() correctamente. Y arrancarMotor() solo lo tienen los que realmente tienen motor. Contrato respetado, Liskov contenta.


El ejemplo clásico: Rectángulo y Cuadrado

Este es el ejemplo que siempre sale en las entrevistas y los libros. Matemáticamente, un cuadrado es un rectángulo (todos los cuadrados son rectángulos). Pero en programación, heredar Cuadrado de Rectángulo suele violar Liskov. Vamos a verlo:

class Rectangulo
{
    public function __construct(
        protected float $ancho,
        protected float $alto
    ) {}

    public function setAncho(float $ancho): void {
        $this->ancho = $ancho;
    }

    public function setAlto(float $alto): void {
        $this->alto = $alto;
    }

    public function getArea(): float {
        return $this->ancho * $this->alto;
    }
}

Ahora alguien dice “un cuadrado es un rectángulo, así que hereda de él”:

class Cuadrado extends Rectangulo
{
    public function setAncho(float $ancho): void {
        $this->ancho = $ancho;
        $this->alto = $ancho; // Para que siga siendo cuadrado
    }

    public function setAlto(float $alto): void {
        $this->alto = $alto;
        $this->ancho = $alto; // Para que siga siendo cuadrado
    }
}

¿Ves el problema? Si alguien tiene código que trabaja con un Rectangulo:

function duplicarAncho(Rectangulo $rectangulo): void {
    $anchoOriginal = $rectangulo->getAncho();
    $rectangulo->setAncho($anchoOriginal * 2);
    // Esperamos que el alto NO haya cambiado
    // Pero si es un Cuadrado... ¡sorpresa!
}

El contrato de Rectangulo dice que puedes cambiar ancho y alto de forma independiente. Cuadrado rompe ese contrato porque al cambiar uno, cambia el otro. Quien use un Rectangulo espera que setAncho() solo modifique el ancho, no el alto. Violación de Liskov de manual.

¿Cómo arreglarlo?

La solución más limpia es no usar herencia aquí. En su lugar, puedes crear una interfaz Forma que ambos implementen de forma independiente:

interface Forma
{
    public function getArea(): float;
}

class Rectangulo implements Forma
{
    public function __construct(
        private float $ancho,
        private float $alto
    ) {}

    public function getArea(): float {
        return $this->ancho * $this->alto;
    }
}

class Cuadrado implements Forma
{
    public function __construct(
        private float $lado
    ) {}

    public function getArea(): float {
        return $this->lado ** 2;
    }
}

Ahora cada clase tiene su propio contrato y nadie se lleva sorpresas. El Cuadrado no necesita fingir que es un Rectangulo.


Otro ejemplo real: sistema de archivos

Este ejemplo te va a sonar si has trabajado con sistemas de almacenamiento. Imagina una clase base para manejar ficheros:

abstract class FileStorage
{
    abstract public function read(string $path): string;
    abstract public function write(string $path, string $content): void;
    abstract public function delete(string $path): void;
}

Implementamos almacenamiento local y en S3:

class LocalStorage extends FileStorage
{
    public function read(string $path): string {
        return file_get_contents($path);
    }

    public function write(string $path, string $content): void {
        file_put_contents($path, $content);
    }

    public function delete(string $path): void {
        unlink($path);
    }
}

class S3Storage extends FileStorage
{
    public function read(string $path): string {
        // Llamada a AWS S3 API...
        return $this->s3Client->getObject($path);
    }

    public function write(string $path, string $content): void {
        // Subir a S3...
        $this->s3Client->putObject($path, $content);
    }

    public function delete(string $path): void {
        // Eliminar de S3...
        $this->s3Client->deleteObject($path);
    }
}

Esto cumple Liskov perfectamente. Puedes pasar LocalStorage o S3Storage y el código que usa FileStorage funciona igual. Ambos leen, escriben y borran ficheros. Que por debajo uno use el sistema de archivos local y el otro una API de Amazon no importa: el contrato se respeta.

Ahora imagina que alguien crea:

class ReadOnlyStorage extends FileStorage
{
    public function read(string $path): string {
        return file_get_contents($path);
    }

    public function write(string $path, string $content): void {
        throw new Exception("Storage de solo lectura, no puedes escribir.");
    }

    public function delete(string $path): void {
        throw new Exception("Storage de solo lectura, no puedes eliminar.");
    }
}

Violación de Liskov. El contrato de FileStorage dice “puedes leer, escribir y borrar”. ReadOnlyStorage rompe dos tercios de ese contrato. Quien use FileStorage esperando poder escribir se va a comer una excepción inesperada.

La solución sería separar los contratos:

interface Readable
{
    public function read(string $path): string;
}

interface Writable
{
    public function write(string $path, string $content): void;
}

interface Deletable
{
    public function delete(string $path): void;
}

class ReadOnlyStorage implements Readable
{
    public function read(string $path): string {
        return file_get_contents($path);
    }
}

class FullStorage implements Readable, Writable, Deletable
{
    public function read(string $path): string { /* ... */ }
    public function write(string $path, string $content): void { /* ... */ }
    public function delete(string $path): void { /* ... */ }
}

Ahora cada clase implementa solo lo que realmente puede hacer. Fíjate cómo el LSP y el ISP (Segregación de Interfaces) van de la mano aquí.


Las reglas formales del LSP

Para los más curiosos, el LSP define unas reglas concretas que las subclases deben cumplir. Estas reglas se conocen como las condiciones de Liskov:

Precondiciones

La subclase no puede fortalecer las precondiciones del método. Si el padre acepta cualquier número positivo, la hija no puede exigir que sea mayor que 10.

Postcondiciones

La subclase no puede debilitar las postcondiciones. Si el padre garantiza devolver un número positivo, la hija no puede devolver negativos.

Invariantes

La subclase debe mantener las invariantes de la clase base. Si el padre garantiza que el saldo nunca es negativo, la hija tampoco debe permitirlo.

Excepciones

La subclase no puede lanzar excepciones nuevas que el padre no lanzaba. Si el padre nunca lanza IOException, la hija tampoco debería.

Estas reglas vienen del concepto de Design by Contract (Diseño por Contrato) de Bertrand Meyer. Si te suena, es porque Meyer también formuló el principio Open/Closed. Los principios SOLID están todos más conectados de lo que parece.


¿Cómo detectar que estás violando el LSP?

Hay varias señales de alarma que te avisan de que algo huele mal:

Métodos que lanzan excepciones que la clase padre no lanzaba

La señal más clara. Si una subclase lanza UnsupportedOperationException, NotImplementedException o cualquier excepción inesperada en un método heredado, está rompiendo el contrato. Esto es lo que vimos con Bicicleta y ReadOnlyStorage.

Métodos vacíos o que no hacen nada

Si tu subclase implementa un método heredado pero lo deja vacío (sin lógica), es una señal de que ese método no debería estar ahí. La subclase está “fingiendo” que puede hacer algo que realmente no puede.

Uso de instanceof o get_class() para decidir qué hacer

Si en tu código tienes cosas como:

function procesarVehiculo(Vehiculo $vehiculo): void {
    if ($vehiculo instanceof Bicicleta) {
        // Caso especial para bicicleta...
    } else {
        $vehiculo->arrancarMotor();
    }
}

Eso es una señal clara. Si necesitas preguntar “¿de qué tipo eres?” para decidir qué hacer, es que las subclases no son intercambiables. El polimorfismo debería encargarse de eso, no los condicionales.

La subclase sobreescribe métodos para cambiar el comportamiento de forma inesperada

Como el caso de Cuadrado que al hacer setAncho() también cambia el alto. El método existe y no lanza excepciones, pero se comporta de forma diferente a lo esperado por quien trabaja con Rectangulo.

Tests de la clase padre que fallan con la subclase

Esta es la prueba definitiva. Si tienes tests escritos para la clase padre y los ejecutas pasando una subclase y fallan, tienes una violación de Liskov. De hecho, esto se puede usar como técnica de detección: escribe tests genéricos para el contrato de la clase base y ejecútalos con cada subclase.


Errores comunes al aplicar el LSP


¿Cuándo NO es necesario aplicar Liskov a rajatabla?

Como con el resto de principios SOLID, el LSP no es un dogma:

Recuerda: El LSP es especialmente importante cuando trabajas con código que otros van a usar (bibliotecas, frameworks, APIs) o en equipos grandes donde no controlas quién extiende tus clases.


Relación con otros principios SOLID

El LSP no vive solo. Está conectado con el resto de principios y se refuerzan mutuamente:


Conclusión

El Principio de Sustitución de Liskov se resume en una idea muy potente: si algo hereda de otra cosa, debe poder sustituirla sin romper nada. Parece simple, pero en la práctica es de los principios que más se viola, muchas veces sin darse cuenta.

La clave está en pensar siempre en contratos: ¿qué promete la clase padre? ¿Mi subclase cumple esas promesas? Si la respuesta es no, la herencia no es la herramienta correcta. Probablemente necesites composición, interfaces segregadas o simplemente una jerarquía diferente.

Y recuerda el mantra: “herencia no es relación del mundo real, es comportamiento compartido”. Un cuadrado puede ser un rectángulo en geometría, pero en código son cosas distintas.

Espero que se haya entendido ejeejjeje EA nos beermos! 🍻


Pon a prueba lo aprendido

1. ¿Quién formuló el Principio de Sustitución de Liskov?

2. ¿Qué dice el LSP en lenguaje sencillo?

3. En el ejemplo del Vehículo, ¿por qué Bicicleta viola el LSP?

4. ¿Por qué Cuadrado heredando de Rectángulo viola Liskov?

5. ¿Cuál de estas señales indica una posible violación del LSP?

6. Según las reglas formales del LSP, ¿qué NO puede hacer una subclase?

7. ¿Qué principio SOLID necesita del LSP para funcionar correctamente?

8. ¿Cuál es la mejor forma de arreglar una violación de Liskov causada por herencia inadecuada?