[LSP] Principio de sustitución de Liskov

Descripción general

Principios del diseño orientado a objetos; Principios SOLID; [LSP] Principio de sustitución de Liskov
Básicamente lo que nos dice este principio es que 'Cada clase que hereda de otra puede usarse como su padre sin necesidad de conocer las diferencias entre ellas'.

[TOC] Tabla de Contenidos


↑↑↑

[LSP] Principio de sustitución de Liskov


↑↑↑

Los cinco principios SOLID


↑↑↑

SOLID – LSP – Liskov Substitution Principe

El Principio de Sustitución de Liskov fue acuñado por Bárbara Liskov (de ahí el nombre del principio) en el año 1987 durante una conferencia sobre Jerarquía y Abstracción de datos. Su principal cometido es la de asegurar en la herencia entre clases de la Programación Orientada a Objetos que una clase derivada no únicamente es sino que debe comportarse como la clase base. Su definición es:

"Debe ser posible utilizar cualquier objeto instancia de una subclase en lugar de cualquier objeto instancia de su superclase sin que la semántica del programa escrito en los términos de la superclase se vea afectado"

Hay muchas más definiciones de este principio

Idea importante

En lenguajes OO como C# o VB.NET, la clave para conseguir la abstracción y polimorfismo de entidades es mediante la herencia, y es precisamente en esta característica en la que se basa el Principio de Sustitución de Liskov (Liskov Substitution Principle, LSP). El principio indica cuáles son los fundamentos básicos de diseño que debe seguir la herencia en un caso particular, o cuál es la mejor forma de crear jerarquías de herencia entre clases. Porque el hecho de que una clase herede de otra, no asegura que se cumple el principio LSP, si no se respetan también sus "contratos de diseño"


↑↑↑

Ejemplo que NO cumple con el principio

Echemos un vistazo al código siguiente. Este código trata de calcular los impuestos de un vehículo en base a la matricula (antigüedad) y la cilindrada del mismo.

Listado 1

class Vehiculo { public string Marca { get; set; } public string Modelo { get; set; } public int Cilindrada { get; set; } } class Ciclomotor : Vehiculo { public string ObtenerNumLicencia() { // Devuelve número de licencia } } class Coche : Vehiculo { public string ObtenerMatricula() { // Devuelve matrícula } } class Impuestos { public void CalcularImpuesto(Vehiculo vehiculo) { string matricula = ((Coche)vehiculo).ObtenerMatricula(); ServicioCalculoImpuestos(matricula, vehiculo.Cilindrada); } }
Figura 1

Figura 1

Básicamente, LSP afirma que si tenemos dos objetos de tipos diferentes –Coche y Ciclomotor– que derivan de una misma clase base –Vehículo–, deberíamos poder reemplazar cada uno de los tipos –Coche/Ciclomotor y viceversa– allí dónde el tipo base –Vehículo– esté implementado. En el ejemplo anterior tenemos un claro caso de violación del LSP, ya que la ejecución del método CalcularImpuesto generará una excepción de conversión de tipo si el objeto pasado por parámetro es de tipo Ciclomotor en lugar de Coche, pese a que ambas clases derivan de la misma clase base Vehículo.

Podríamos pensar en solucionar el problema modificando el código tal y como se muestra a continuación:

Listado 2

public void CalcularImpuesto(Vehiculo vehiculo) { string matricula = string.Empty; if (vehiculo.GetType().Name == "Coche") matricula = ((Coche)vehiculo).ObtenerMatricula(); else if (vehiculo.GetType().Name == "Ciclomotor") matricula = ((Ciclomotor)vehiculo).ObtenerNumLicencia(); ServicioCalculoImpuestos(matricula, vehiculo.Cilindrada); }

Pese a que el compilador no genere ninguna excepción de conversión de tipo, esta clase aún viola el LSP. Esto es debido a que estamos forzando a un objeto Vehículo pasado como parámetro a comportarse como Ciclomotor o Coche. Además, esta aproximación vulnera el Principio Open/Closed, ya que ante cualquier nueva entidad que derive de Vehiculo deberemos modificar el método CalcularImpuesto.


↑↑↑

Ejemplo que SI cumple el principio

Vamos a ver en este ejemplo el diseño de una calculadora siguiendo el principio LSP:

LSP es básicamente una extensión del principio abierto-cerrado. Lo que nos dice es que si tenemos una clase y varias subclases de esta al usar una referencia a la clase principal esta debe ser capaz de aceptar cualquier objeto de sus clases hijas.

Figura 2

Figura 2

Listado 2

// Tenemos una clase base public abstract class Operacion { public double OperadorUno { get; set; } public double OperadorDos { get; set; } public abstract double Calcular(); } // Una clase derivada de Operacion public class OperacionSumar : Operacion { public override double Calcular() { double result = this.OperadorUno + this.OperadorDos; return result; } } //Otra clase derivada de Operación public class OperacionMultiplicar : Operacion { public override double Calcular() { double result = this.OperadorUno * this.OperadorDos; return result; } } public class Calculadora { //Aquí pasamos una referencia de la clase base public void Calcular(Operacion op) { //Esa referencia la usamos aquí que es op double result = op.Calcular(); Console.WriteLine("Resultado=" + result); } } public class Main { public static void main(String[] args) { Calculadora calc = new Calculadora(); OperacionSumar suma = new OperacionSumar(); suma.OperadorUno=10; suma.OperadorDos=4; //El metodo calcular acepta cualquier objeto //de una clase derivada de Operación calc.Calcular(suma); OperacionMultiplicar multiplicacion = new OperacionMultiplicar(); multiplicacion.OperadorUno=6; multiplicacion.OperadorDos=5; //El método calcular acepta cualquier objeto //de una clase derivada de Operación calc.Calcular(multiplicacion); } }

Aquí vemos la aplicación de este principio en el método calcular. Este método usa una referencia a la clase base y nunca se entera cual es el objeto derivado (subtipo) que se está usando.


↑↑↑

Un apunte sobre el diseño de objetos basado en contratos

Existe una fuerte relación entre el LSP y el concepto de diseño por contrato, usando este último los métodos de una clase declaran pre-condiciones y post-condiciones. Las primeras deben ser verdaderas para que el método se ejecute. Una vez terminado, el método garantiza que las post-condiciones sean ciertas. Usando LSP, la pre-condición se traduce a que cualquier clase derivada debe aceptar cualquier cosa que la clase base puede aceptar, mientras la post-condición se traduce a que las clases derivadas deben ajustarse a todas la post-condiciones de la clase base. Es decir, sus comportamientos y los resultados no deben infringir ninguna de las limitaciones establecidas para la clase base. Los usuarios de la clase base no debe confundirse ante una nueva clase derivada.

Por tanto, se deben tener dos criterios para usar clases bases, uno es dar a una aplicación un comportamiento común, y el otro usar métodos de ayuda para las clases derivadas. Pero sobre todo lo primero. Por ejemplo, puedo crear un formulario base porque quiero que todos los formularios tengan el mismo comportamiento frente a un evento concreto, pero también lo puedo hacer para tener funciones de ayuda. Siempre es preferible el primer criterio porque el segundo criterio lo puedo aislar luego en una clase de ayuda de simple instancia y llevármelo a otro proyecto.


↑↑↑

Un Ejemplo que NO cumple el principio LSP

Vamos a ver el típico ejemplo de libro de texto: el caso del cuadrado y el rectángulo, en el que mostraremos que el que una clase herede de otra, no asegura que se cumple el principio LSP, si no se respetan también sus "contratos de diseño"

Supongamos que tenemos una clase Rectángulo como la siguiente:

Listado 3

public class Rectangulo { //----------------------------------------------------- //La palabra clave virtual se utiliza para modificar un //método, de forma que pueda ser invalidado (sobre escrito) //en una clase derivada. //----------------------------------------------------- public virtual int Ancho { get; set; } public virtual int Alto { get; set; } public virtual float Area() { return Ancho * Alto; } }

Luego decidimos que un Cuadrado "es-un" rectángulo y por eso creamos la clase Cuadrado como una subclase de Rectángulo, y para asegurarnos que la forma realmente sea la de un cuadrado, las propiedades Alto y Ancho, las sobre escribimos de forma que nos aseguremos de que, tal y como corresponde a un cuadrado, el valor del ancho y del alto sean iguales

Listado 4

public class Cuadrado : Rectangulo { public override int Ancho { get { return base.Ancho; } set { base.Ancho = value; base.Alto = value; } } public override int Alto { get { return base.Alto; } set { base.Ancho = value; base.Alto = value; } } public override float Area() { return Ancho * Alto; } }
Figura 3

Figura 3

Hasta aquí todo parece correcto, pero veamos qué pasa cuando tenemos una tercera clase, en este caso un test unitario, con el siguiente código:

Listado 5

public class Test { public void TestCalculoAreaRectangulo() { testAreaFiguraRectangular( new Rectangulo()); } public void TestCalculoAreaCuadrado() { testAreaFiguraRectangular(new Cuadrado()); } private void testAreaFiguraRectangular(Rectangulo rect) { rect.Alto=10; rect.Ancho=2; Assert.AreEqual(20, rect.Area()); } }

Cuando la variable rectángulo sea una instancia de la clase Cuadrado este test va a fallar (el área 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 Rectángulo y al utilizar una instancia de Cuadrado (clase derivada de Rectángulo) 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 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 Rectángulo ya que el comportamiento de nuestro test debería ser modificado para que siga funcionando correctamente, probablemente haciendo algo así:

Listado 6

private void testAreaFiguraRectangular(Rectangulo rect) { rect.Alto = 10; rect.Ancho = 2; if (rect is Cuadrado) Assert.AreEqual(4, rect.Area()); else Assert.AreEqual(20, rect.Area()); }

De aquí se deduce que el Cuadrado puede ser una sub-clase del Rectángulo, 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. Las pre-condiciones se deben cumplir para que el método se ejecute y, cuando finalice la ejecución del método, también deben de cumplirse las post-condiciones.

Para no violar LSP, cuando se redefinen los métodos de las cases derivadas, 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 rectángulo y el cuadrado, las post-condiciones del método Ancho definidas en la clase base Rectángulo son las siguientes:

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

Ya puestos vamos a ver una implementación diferente de la propiedad Ancho de la clase cuadrado que tampoco respeta las pre-condiciones de la clase base rectángulo, sería por ejemplo algo así:

Listado 7

public override int Ancho { get { return base.Ancho; } set { if (value == 0) throw new Exception("El ancho debe ser distinto de 0"); base.Ancho = value; base.Alto = value; } }

En este caso, estamos pidiendo que el ancho sea distinto de cero como pre-condición de la propiedad Ancho en la clase Cuadrado, mientras que esa restricción no se exige en la clase Rectángulo.


↑↑↑

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.


↑↑↑

Otro Ejemplo que NO cumple el principio LSP

Supongamos que tenemos una interfaz para nuestros objetos DAO como la siguiente:

Listado 7

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 usar la interface para mis Facturas

Listado 7

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 } } }

En este momento… y por algún motivo inexplicable necesito crear una capa de acceso a datos que sólo permita lecturas de facturas. La implementación de la clase debería ser como la siguiente:

Listado 7

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 } }
Figura 4

Figura 4

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:


↑↑↑

Resumiendo

Podemos decir que un sistema que use este principio será más fácil de mantener que otro que no lo use. Este principio está relacionado con otros como cohesión o acoplamiento entre clases (No hables con extraños) y con los patrones de diseño de tipo factorías (¿cómo se si debo desde el cliente usar la clase A o la B?)


↑↑↑

Referencia Bibliográfica


↑↑↑

A.2.Enlaces

[Para saber mas]
[Grupo de documentos]
[Documento Index]
[Apuntes sobre principios de diseño]
[Documento Start]
[Imprimir el Documento]