21 de enero de 2011

Principio de Substitución de Liskov - SOLID

Siguiendo con la serie sobre principios SOLID, hoy nos toca ver el principio LSP, Liskov Substitution Principle.

LSP es probablemente el principio SOLID más difícil de explicar ya que los ejemplos de la "vida real" que uno suele encontrar son escasos o muchas veces parecen describir mejor el principio OCP que LSP.

Comencemos por la definición del principio:

Métodos que referencian a clases base, deben poder utilizar objetos de clases derivadas sin necesidad de saberlo.

El ejemplo del Cuadrado y el Rectángulo

Dicho esto, vamos a empezar explicando LSP con el típico ejemplo de libro: el caso del cuadrado y el rectángulo.

Supongamos que tenemos una clase Rectangulo como la siguiente:


public class Rectangulo
{
private int _ancho;
private int _alto;

public virtual void AsignarAncho(int ancho)
{
_ancho = ancho;
}

public virtual void AsignarAlto(int alto)
{
_alto = alto;
}

public int ObtenerAncho()
{
return _ancho;
}

public int ObtenerAlto()
{
return _alto;
}

public int CalcularArea()
{
return _ancho * _alto;
}
}


Luego decidimos que un Cuadrado "es-un" rectángulo y por eso creamos la clase Cuadrado como una subclase de Rectangulo, y para asegurarnos que la forma realmente sea la de un cuadrado, hacemos override de AsignarAlto y de AsignarAncho de forma tal que siempre el alto y el ancho sean iguales.


public class Cuadrado : Rectangulo
{
public override void AsignarAncho(int ancho)
{
base.AsignarAncho(ancho);
base.AsignarAlto(ancho);
}

public override void AsignarAlto(int alto)
{
base.AsignarAlto(alto);
base.AsignarAncho(alto);
}
}


Hasta acá todo parecería correcto, pero veamos qué pasa cuando tenemos una tercer clase, en este caso un test unitario, con el siguiente código:


[TestFixture]
public class Test
{
[Test]
public void TestCalculoAreaRectangulo()
{
testAreaFiguraRectangular(new Rectangulo());
}

[Test]
public void TestCalculoAreaCuadrado()
{
testAreaFiguraRectangular(new Cuadrado());
}

private void testAreaFiguraRectangular(Rectangulo rect)
{
rect.AsignarAlto(10);
rect.AsignarAncho(2);
Assert.AreEqual(20, rect.CalcularArea());
}
}


Cuando la variable rectangulo sea una instancia de la clase Cuadrado este test va a fallar (el area del cuadrado va a ser 2*2=4 en lugar de 2*10=20).

Esto ocurre porque se ha violado el principio LSP. Nuestro test referencia a la clase base Rectangulo y al utilizar una instancia de Cuadrado (clase derivada de Rectangulo) el test comienza a fallar.

Sub-tipos vs Sub-clases y Diseño por Contrato

Una vez planteado el ejemplo viene bien mencionar un par de conceptos que ayudan a entender un poco mas cuándo se puede estar violando LSP y cuándo no.

Barbara Liskov (de ahí el nombre del principio) definió como sub-tipo a la propiedad que tiene un objeto A de poder reemplazar a otro objeto B sin que el comportamiento de los objetos que los utilizan deba ser modificado (A es un sub-tipo de B)

En nuestro ejemplo el Cuadrado no puede reemplazar al Rectangulo ya que el comportamiento de nuestro test debería ser modificado para que siga funcionando correctamente, probablemente haciendo algo así:


private void testAreaFiguraRectangular(Rectangulo rect)
{
rect.AsignarAlto(10);
rect.AsignarAncho(2);

if (rect is Cuadrado)
Assert.AreEqual(4, rect.CalcularArea());
else
Assert.AreEqual(20, rect.CalcularArea());
}


De aquí se deduce que el Cuadrado puede ser una sub-clase del Rectangulo, pero no es un sub-tipo, según la definición de Liskov. Por lo tanto, que una clase herede de otra no nos asegura el principio LSP.

Otro concepto que vale la pena mencionar es el de diseño por contrato, que se puede resumir en que los métodos de una clase declaran pre-condiciones y post-condiciones donde las pre-condiciones se deben cumplir para que el método se ejecute y, luego de la ejecución, las post-condiciones deben ser cumplidas también.

Al redefinir métodos de una clase, para no violar LSP, se deben respetar tanto las pre-condiciones como las post-condiciones definidas en la clase base, es decir, el comportamiento de una clase no debe romper ninguna de las restricciones impuestas por la clase base.

En el ejemplo del rectangulo y el cuadrado, las post-condiciones del método AsignarAncho definidas en la clase base Rectangulo son las siguientes:

  • "El ancho tiene el nuevo valor asignado"
  • "El alto se mantiene con el mismo valor que tenía antes"

La segunda post-condición no se respeta en la redefinición del método AsignarAncho de la clase Cuadrado, ya que ante un cambio en el ancho, también se modifica el alto en forma automática.

Un caso donde no se respetarían las pre-condiciones sería por ejemplo que en AsignarAncho de Cuadrado se haga algo así:


public override void AsignarAncho(int ancho)
{
if (ancho == 0)
throw new Exception("El ancho debe ser distinto de 0");

base.AsignarAncho(ancho);
base.AsignarAlto(ancho);
}


En este caso, estamos pidiendo que el ancho sea distinto de cero como pre-condición de AsignarAncho en Cuadrado, mientras que eso no se exige en la clase Rectangulo.

LSP y las interfaces

Por último vale la pena mencionar qué pasa con las interfaces. Si en lugar de heredar de una clase base con comportamiento ya definido, se implementa una interfaz, hay pre-condiciones y post-condiciones que respetar? La respuesta es: en la mayoría de los casos si las hay, y por lo general son implícitas y se desprenden del sentido común.

Por ejemplo, si en lugar de tener la clase Rectangulo, tengo una interfaz IFiguraRectangular, y dos clases que la implementan, Rectangulo y Cuadrado. Algo de lo mencionado previamente deja de tener vigencia? No sería lógico que si una clase interactúa con una variable de tipo IFiguraRectangular, la cual tiene sus métodos AsignarAncho y AsignarAlto, pueda asumir que el alto y el ancho se asignan por separado? Yo creo que sí...

Un ejemplo de la vida real

Otro ejemplo, un poco distinto, que a mi entender es mucho más visto en la vida real es el siguiente.

Supongamos que tenemos una interfaz para mis Daos como la siguiente:


public interface IDao
{
void Insert(object entity);
void Update(object id, object entity);
void Delete(object id);
object[] GetAll();
object GetById(object id);
}


Luego decido crear un Dao para mis Facturas


public class FacturaDao : IDao
{
public void Insert(object entity)
{
//se inserta una factura
}

public void Update(object id, object entity)
{
//se actualiza una factura
}

public void Delete(object id)
{
//se elimina una factura
}

public object[] GetAll()
{
//se obtienen todas las facturas
}

public object GetById(object id)
{
//se obtiene una factura por id
}
}


Hasta ahí todo bien, salvo que luego, por algún motivo necesito crear una capa de acceso a datos donde sólo pueda permitir lecturas, entonces mi Dao para Facturas debería ser como el siguiente:


public class FacturaDaoReadOnly : IDao
{
public void Insert(object entity)
{
throw new DaoReadOnlyException();
}

public void Update(object id, object entity)
{
throw new DaoReadOnlyException();
}

public void Delete(object id)
{
throw new DaoReadOnlyException();
}

public object[] GetAll()
{
//se obtienen todas las facturas
}

public object GetById(object id)
{
//se obtiene una factura por id
}
}


Aquí el problema que se presenta es que las clases que interactúen con IDao, van a tener que tener en cuenta que en algunos casos los métodos Insert, Update y Delete pueden lanzar una excepción DaoReadOnlyException, lo cual viola el principio de Liskov por los siguientes motivos:

  • No se estarían cumpliendo las post-condiciones de los métodos Insert, Update y Delete porque se debe contemplar que el Dao puede lanzar una excepción DaoReadOnlyException, lo cual es una post-condición particular de una implementación concreta del Dao, en este caso el dao readonly de Facturas.
  • No se estaría cumpliendo el concepto de diseño por contrato, ya que en ninguna parte de la interfaz IDao se sugiere que un Dao puede ser readonly.

Una posible forma de solucionar esto sería mediante la separación en distintas interfaces de las distintas acciones del Dao, por ejemplo IDaoLectura, IDaoEscritura, pero esto es algo que vamos a cubrir cuando veamos el principio de segregación de interfaces (ISP).

Conclusión

Por último y resumiendo, no son demasiadas las aplicaciones en la "vida real" que se le pueden dar al principio LSP, pero casos como el anteriormente descripto (o ligeras variaciones del mismo) se ven muy seguido, por lo tanto es un principio que vale la pena tener bien presente.

En mi primer post sobre el principio SRP podrán encontrar varios enlaces a las lecturas recomendadas sobre principios SOLID.

Espero que sea de utilidad, cualquier comentario será bienvenido.

Posts anteriores sobre principios SOLID
Principio de responsabilidad única - SRP
Principio abierto-cerrado - OCP