Principio de Sustitución de Liskov (LSP)
¿Qué explica este principio?
El Principio de Sustitución de Liskov establece que una subclase debe poder reemplazar a su clase base sin afectar el correcto funcionamiento del programa.
En otras palabras:
Si tienes una clase llamada Ave y creas una subclase Pato, esta última debe poder ser utilizada en cualquier lugar donde se espere una Ave, sin alterar el comportamiento esperado del sistema.
- Esto significa que las subclases deben cumplir con el contrato definido por la clase base.
- Las condiciones previas no deben hacerse más estrictas.
- Las condiciones posteriores no deben debilitarse.
¿Qué beneficios obtenemos al aplicarlo?
- Se logra un verdadero polimorfismo: las subclases pueden comportarse como la clase base sin problemas.
- El código se vuelve más fácil de probar y mantener.
- El desarrollo es más predecible y coherente respecto al contrato de la clase base.
Programación vs Matemáticas
Un ejemplo sencillo pero fundamental para comprender este principio es el caso del cuadrado y el rectángulo.
Ambas figuras comparten ciertos requisitos: tienen cuatro lados. Sin embargo, no cumplen las mismas condiciones geométricas.
- Un cuadrado requiere cuatro lados iguales.
- Un rectángulo necesita lados con diferente anchura y altura.
En la práctica, no es correcto heredar Square de Rectangle, porque estaríamos forzando comportamientos que rompen las reglas de una u otra figura. Si lo hacemos, estaremos sobrecargando la clase base y rompiendo el principio de Liskov.
// Clase base: Rectángulo
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int CalculateArea()
{
return Width * Height;
}
}
// VIOLACIÓN LSP: Cuadrado hereda de Rectángulo
public class Square : Rectangle
{
private int _side;
public override int Width
{
get => _side;
set => _side = value;
}
public override int Height
{
get => _side;
set => _side = value;
}
}
// PROBLEMA: Este código FALLA
public void TestRectangle(Rectangle rect)
{
rect.Width = 5;
rect.Height = 10;
// Esperamos: 5 * 10 = 50
// Con Rectangle: 50
// Con Square: 100 (ambos lados cambian al último valor: 10)
Console.WriteLine($"Área: {rect.CalculateArea()}");
}
Problemas detectados:
- No deberíamos heredar de una clase que ya cumple una función solo porque parece “similar”.
- Tenemos que manipular propiedades para intentar obtener un resultado esperado.
- El cuadrado no puede adaptarse correctamente al comportamiento del rectángulo.
Solución correcta
En lugar de forzar una relación de herencia, debemos usar una interfaz común que defina el contrato general para todas las figuras:
// Interfaz común
public interface IShape
{
int CalculateArea();
string GetDescription();
}
// Rectángulo implementa IShape
public class Rectangle : IShape
{
public int Width { get; set; }
public int Height { get; set; }
public int CalculateArea() => Width * Height;
public string GetDescription() => $"Rectángulo: {Width}x{Height}";
}
// Cuadrado implementa IShape (NO hereda de Rectangle)
public class Square : IShape
{
public int Side { get; set; }
public int CalculateArea() => Side * Side;
public string GetDescription() => $"Cuadrado: {Side}x{Side}";
}
// FUNCIONA: Ambos son IShape pero sin herencia problemática
public void ProcessShape(IShape shape)
{
Console.WriteLine(shape.GetDescription());
Console.WriteLine($"Área: {shape.CalculateArea()}");
}
¿Qué logramos?
- Una interfaz flexible ante nuevos requerimientos.
- Se aplica el principio de responsabilidad única (SRP): cada clase representa correctamente su figura.
- La interfaz IShape está abierta a la extensión y cerrada a la modificación, cumpliendo también con el principio Open/Closed.
- Fácil de probar y extender con nuevas figuras en el futuro.
- No fortalecemos condiciones previas ni debilitamos las posteriores: mantenemos un contrato coherente y estable.
Conclusión
El Principio de Sustitución de Liskov es esencial cuando se crean variantes de un objeto con comportamientos o reglas diferentes, pero que comparten un mismo propósito general.
Por ejemplo, en un sistema de reportes, las operaciones de guardar, leer y exportar son comunes. Si queremos exportar diferentes tipos de documentos, lo ideal es definir una interfaz con estas funcionalidades y luego crear implementaciones específicas para cada tipo de reporte.
Así logramos un diseño extensible, predecible y fácil de mantener, evitando la fragilidad de las herencias mal aplicadas y garantizando un verdadero polimorfismo.