lunes, 28 de octubre de 2013

The Liskov's Substitution Principle (LSP)

The Liskov's Substitution Principle (LSP)

"Las subclases deben poder sustituirse por sus clases base"

Este post pertenece a la serie Principios SOLID.

En el post "The Open/Close Principle (OCP)" hablamos de que el OCP es la base para crear código mantenible y reutilizable. Además vimos que el concepto clave del OCP era al abstracción. Uno de los principales mecanismos para soportar abstracción en los lenguajes de tipos estáticos como C# es la herencia.
Mediante herencia podemos crear clases derivadas que se ajustan a las interfaces definidas por las clases base.
En este post vamos a ver la forma de crear buenas jerarquías de herencias. Y como evitar que nuestras jerarquía de clases violen el principio Open/Close.

El LSP establece que si un método recibe como parámetro una clase base, esta clase base debe poder sustituirse por una clase derivada sin afectar a la funcionalidad del método.



O lo que es lo mismo, desde un punto de vista más práctico: debemos asegurarnos que las clases derivadas sólo extienden a la clase base pero en ningún caso modifican o reemplazan su comportamiento. De lo contrario las clases derivadas pueden producir efectos no deseados al se usadas en los métodos.
Una clase derivada extiende a la clase base pero no reemplaza su comportamiento
 La importancia de este principio se hace más evidente cuando se consideran las consecuencias de su violación. Si una función no se ajusta al LSP, entonces esa función aunque reciba como parámetro una clase base debe conocer todas las clases derivadas de la clase base para realizar su funcionalidad. Además este método violará también el principio Open/Close por lo que cuando cambie la jerarquía de la clase base deberá cambiar también el método.

Una de las formas más evidentes de violación de este principio es la siguiente. Supongamos el siguiente método para dibujar una figura:
void DrawShape(Shape shape)
{
   if (shape.GetType() == typeof(Square))
      DrawSquare((Square)shape); 
   else if (shape.GetType() == typeof(Circle))
      DrawCircle((Circle)shape);
}

Aunque se que en algún momento todos hemos visto algo como el método anterior. Claramente DrawShape supone un problema. El método debe cambiar cada vez que agregamos una clase derivada de Shape. De hecho DrawShape está bastante próximo a lo podríamos considerar "un atentado" contra el diseño orientado a objetos.

Cuadrado y rectángulo
Hay otras formas más sutiles de violación del LSP. Consideremos el ejemplo típico del dilema del rectángulo y el cuadrado.
Supongamos una clase Rectangle y una clase Square como las siguientes:
public class Rectangle
{
   public virtual int Width { get; set; }
   public virtual int Height { get; set; }
}
 
public class Square : Rectangle
{
   public override int Height
   {
      get { return base.Height; }
      set { SetWidthAndHeight(value); }
   }
 
   public override int Width
   {
      get { return base.Width; }
      set { SetWidthAndHeight(value); }
   }
 
   // Both sides of a square are equal.
   private void SetWidthAndHeight(int value)
   {
      base.Height = value;
      base.Width = value;
   }
}

Bien, tenemos una clase Square que hereda de Rectangle, al fin y al cabo un cuadrado es un rectángulo. Todo parece correcto, sin embargo, la clase Square viola el LSP por que cambia el comportamiento de las propiedades Width y Height de su clase base.

El problema real
Las clases Rectangle y Square parecen ser correctas. La clase Square mantiene una consistencia matemática (sus lados siempre son iguales). Podríamos pasar un objeto Square a un método que acepte un tipo Rectangle y el objeto Square seguiría comportándose como un cuadrado y manteniendo su consistencia.

Por lo tanto, podemos llegar a la conclusión que el modelo anterior es correcto y consistente. Sin embargo, un modelo que sea por si mismo consistente no tiene por que serlo para todos los usuarios. Veamos el siguiente código para entender esto:
[TestMethod]
public void CheckRectangleArea(Rectangle rect)
{
   rect.Width = 4.0;
   rect.Height = 5.0;
   Assert.AreEquals(rect.Width * rect.Height, 20);
}

Este método de prueba establece el alto y el ancho de lo que cree que es un rectángulo. El método funciona bien si pasamos como parámetro un objeto de tipo Rectangle, pero lanza una excepción si pasamos un parámetro de tipo Square.
Al desarrollar el método de prueba asumimos correctamente que al cambiar la altura de un rectángulo su ancho permanece inalterado. Este es un ejemplo de un método que recibe una clase base como parámetro pero que no funciona correctamente si pasamos una clase derivada. El método de prueba no hace sino poner de manifiesto la violación del LSP cometida al implementar la clase Square. Evidentemente además del LSP la implementación de Square viola el principio Open/Close.

La validez de un modelo no es intrínseca.
Lo visto anteriormente nos lleva a la conclusión de que un modelo visto de forma aislada no se puede validar correctamente. El anterior modelo parecía correcto, pero el uso con una asunción correcta sobre mismo (al cambiar el alto de un rectángulo su ancho permanece inalterable) hizo que el modelo fallara.

La validación de un modelo no podemos hacerla de forma aislada. Hay que hacerla desde el punto de vista de las asunciones razonables que quien lo use hará del modelo.

Entonces, ¿Que hemos hecho mal?
¿Qué ha pasado?¿Por qué un modelo de un rectángulo y un cuadrado aparentemente correcto está mal?
Después de todo, ¿un cuadrado no es un rectángulo?¿No se da entre ellos la relación "es un"?
No! Quizá un cuadrado sea un rectángulo. Pero un objeto de tipo Square no es un objeto de tipo Rectangle.
¿Por qué?. Pues porque el comportamiento de un objeto Square no es consistente con el comportamiento de un objeto Rectangle. Es decir, desde el punto de vista del comportamiento un cuadrado no es un rectángulo. Y no debemos olvidar que cuando desarrollamos software realmente estamos desarrollando el comportamiento de un sistema.

Así, el LSP deja claro que la relación "es un" en la jerarquía de herencia de clases debe verse desde un punto de vista del comportamiento público de las clases. Es decir, un cuadrado es un rectángulo (hereda de rectángulo) si el comportamiento público de un cuadrado es consecuente con el comportamiento público de un rectángulo. En este caso hemos visto que no es así por que el comportamiento público de un rectángulo establece que el ancho y el alto cambian de forma independiente, mientras que en un cuadrado cambian a la vez.

 El LSP deja claro que la relación "es un" en la jerarquía de herencia de clases debe verse desde un punto de vista del comportamiento público de las clases
Diseño por contrato.
Para establecer el contrato de un método entre otras cosas establecemos las condiciones que se deben cumplir antes de llamar a un método (pre-condiciones) y las que se deben cumplir después de llamar al método. Si hubiéramos hecho explícitamente el contrato de Rectangle veríamos por ejemplo una pos-condición en el método set de la  propiedad Width que establece que el valor pasado como parámetro (value) debe ser asignado a la propiedad Width y la propiedad Height debe permanecer inalterada.

Claramente la clase Square viola la pos-condición del contrato de Rectangle por que altera el valor de la propiedad Height, por lo que Rectangle no es sustituible por Square y viola el LSP.

En términos de contrato una clase derivada es sustituible por su clase base si:

  1. Sus pre-condiciones son menos restrictivas que las de la clase base.
  2. Sus pos-condiciones son más restrictivas que las de la clase base.
En el caso de Square las pos-condición del método set de la propiedad Width es menos restrictiva que la de Rectangle. La de Rectangle no permite que varíe el valor de Height mientras que la de Square si. Por este motivo Square viola el LSP.

¿Como implementar los contratos en nuestro código?
El término "Design by Contract" fue acuñado por Bertrand Meyer en relación con el diseño de su lenguaje de programación "Eiffel" y es una marca comercial registrada por Eiffel Software por lo que no hay que confundirla con el concepto genérico de diseño por contrato. Microsoft llama a su implementación del diseño por contrato "Code Contracts"


Bien, visto todo lo anterior sólo nos queda resolver como implementar correctamente este modelo para que se ajuste al LSP. Este sería un modelo más correcto para Rectangle y Square:
    public abstract class Shape
    {
        public virtual double Width { get; set; }
        public virtual double Height { get; set; }
    }

    public class Rectangle : Shape
    {
    }

    public class Square : Shape
    {
        public override double Height
        {
            get { return base.Height; }
            set { SetWidthAndHeight(value); }
        }

        public override double Width
        {
            get { return base.Width; }
            set { SetWidthAndHeight(value); }
        }

        // Both sides of a square are equal.
        private void SetWidthAndHeight(double value)
        {
            base.Height = value;
            base.Width = value;
        }
    }

El LSP es una característica importante de los diseños que se ajustan al principio Open/Close. Solamente cuando las clases derivadas son sustituibles por sus clases bases, los métodos que usan las clases bases son completamente reutilizables e independientes de la evolución de la jerarquía de herencia de la clase base. Por lo tanto no se necesitan cambiar cuando cambia esta jerarquía y se ajustan al principio Open/Close.

3 comentarios: