En el artículo anterior analizamos el principio Abierto-cerrado. En este tercer artículo abarcaremos la "L" de Liskov substitution principle ¡vamos!
Historia
El principio de sustitución de Liskov fue presentado por Bárbara Liskov en una conferencia magistral titulada Abstracción de datos y Jerarquía en 1987. Liskov y Jeannette Wing formularon este principio de manera conjunta en un artículo en el año 1994. LSP es una colección de pautas para crear jerarquías de herencia en las cuales un cliente podrá utilizar de manera confiable cualquier clase o subclase (clase hija) sin comprometer el comportamiento esperado.
LSP: Principio de Sustitución de Liskov
Si S es un subtipo de T, entonces los objetos de tipo T pueden ser reemplazados con objetos de tipo S, sin romper el programa.
—Barbara Liskov
Descripción
El comportamiento de una subclase debería permanecer compatible con el de la superclase (clase padre). Este concepto es crítico al momento de desarrollar librerías y frameworks ya que dichas clases serán utilizadas por terceros cuyo código será desconocido para nosotros.
A diferencia de otros principios SOLID, los cuales están más abiertos a interpretación, LSP establece un conjunto de requerimientos formales. Pueden ser divididos en dos categorías: reglas de contrato (relacionadas con la expectativa de las clases) y reglas de varianza (relacionadas a los tipos que pueden ser sustituidos en el código).
Reglas de Contrato
Para entender las reglas de contrato es necesario entender los conceptos de diseño por contrato.
- Las condiciones previas no pueden fortalecerse en un subtipo.
- Por ejemplo, un método base tiene un parámetro de tipo
int
. Si una subclase sobreescribe este método y requiere que el valor del argumento pasado a dicho método sea únicamente positivo (lanza una excepción en caso de que sea negativo), esto fortalece las pre-condiciones. El código del cliente solía funcionar sin problemas cuando pasábamos números negativos al método, ahora deja de funcionar si empieza a utilizar un objeto de su subclase.
- Por ejemplo, un método base tiene un parámetro de tipo
- Las condiciones posteriores no pueden debilitarse en un subtipo.
- Supongamos que tenemos una clase con un método que funciona con una base de datos. Se espera que este método cierre las conexiones con la base de datos al retornar un valor. Luego creamos una subclase y sobreescribimos este método, de modo que las conexiones queden abiertas para que puedas reutilizarlas posteriormente. Pero el cliente no tenía idea de tus intenciones. Porque espera que los métodos cierren todas las conexiones, ahora tendrás un descontrol con tus conexiones a la base.
- Las invariantes —condiciones que deben permanecer verdaderas— del supertipo deben conservarse en un subtipo.
- Las invariantes son condiciones en las cuales un objeto tiene sentido. Por ejemplo, las invariantes de un perro son tener cuatro patas, una cola, habilidad de ladrar. La parte confusa acerca de las invariantes es que mientras pueden ser definidas explícitamente en forma de interfaces de contrato o un conjunto de aserciones en los métodos, pueden ser implícitas por algunos test unitarios y expectativas del cliente. Es una de las reglas más fáciles de violar ya que se puede malinterpretar o no darnos cuenta de todas las invariantes de una clase compleja. De este modo, el modo más seguro de extender una clase es introducir nuevos campos y métodos, y no manipular miembros existentes de la superclase. Por supuesto esto no siempre es realizable en la vida real.
Reglas de Varianza
- Los tipos de parámetros en el método de una subclase deben coincidir o ser más abstractos que los tipos de parámetro en el método de la superclase.
- Supongamos que utilizamos una clase para alimentar perros:
alimentar(Perro p)
. El cliente siempre pasa objetos de tipo perro para este método. - Correcto: Creamos una subclase que sobreescribe este método, ahora podemos alimentar a cualquier animal (una superclase de Perro):
alimentar(Animal a)
. De este modo si pasamos un objeto de esta subclase en vez de un objeto de la super clase al cliente, no habrá ningún inconveniente. El método puede alimentar todos los animales, incluso cualquier Perro que nos envíe el cliente. - Incorrecto: Creamos una subclase y restringimos el método para que sólo acepte Perros Dachshund (una subclase de Perro):
alimentar(Dachshund d)
. Lo que sucederá es que limitamos el método a que solo alimente a una raza de perros específica, de modo que si le pasamos en un futuro cualquier perro de otra raza, el programa romperá.
- Supongamos que utilizamos una clase para alimentar perros:
- El tipo de retorno en un método de una subclase debe coincidir o ser subtipo del tipo que retorna el método de la superclase.
- Supongamos que tenemos una clase con el método
adoptarPerro(): Perro
. El cliente espera recibir cualquier Perro como resultado de la ejecución de este método. - Correcto: Una subclase sobreescribe este método del siguiente modo:
adoptarPerro(): Rottweiler
. El cliente recibe un Rottweiler, el cual sigue siendo un perro. - Incorrecto: Una subclase sobreescribe el método del siguiente modo:
adoptarPerro(): Animal
. Ahora el cliente tendrá serios problemas ya que recibirá animales genéricos desconocidos para él, los cuales no se ajustan a la estructura específica del perro.
- Supongamos que tenemos una clase con el método
- Un método en una subclase no debe lanzar tipos de excepciones que el método base no espera lanzar. En otras palabras, las excepciones deben coincidir o ser subtipos de aquellas que el método base es capaz de lanzar. Esta regla viene del hecho de que los bloques try-catch en el código del cliente pueden estar esperando excepciones específicas, las cuales el método base debería lanzar. Por lo tanto, una excepción inesperada puede sobrepasar este try-catch y romper toda la aplicación.
Ejemplo común de violación de LSP
Supongamos que tenemos una clase Pajaro:
public class Pajaro{
public void volar(){}
public void comer(){}
}
Ahora tenemos que agregar a nuestra aplicación un Pato y un Pingüino:
public class Pato extends Parajo{}
public class Pinguino extends Pajaro{}
El problema con el que nos encontraremos es que si bien el Pingüino es un Pajaro, no tiene la habilidad de volar como el Pato. Por ende tendremos un método en Pingüino que no podremos implementar. A pesar de que ambos pueden ser categorizados como Pajaro, esta abstracción no aplica para ambos. Una posible solución sería:
public class Pajaro{
public void comer(){}
}
public class PajaroVolador extends Pajaro{
public void volar(){}
}
public class Pato extends PajaroVolador{}
public class Pinguino extends Pajaro{}
Conclusión
Este principio es una advertencia de que el polimorfismo es poderoso, pero no siempre fácil de aplicar correctamente. En lenguajes de programación modernos, específicamente los de tipado estático como Java, estas reglas están construidas dentro del lenguaje. No podrás compilar un programa que viole las reglas de Varianza. Esto de entrada nos ahorra numerosos problemas. Cabe destacar que LSP es uno de los principales facilitadores de OCP (principio abierto-cerrado). Es la sustitución de subtipos lo que le permite a un módulo, expresado en términos de tipo base, poder ser extendido sin modificaciones.
Referencias
Refactoring Guru. Liskov Substitution Principle. Refactoring Guru. Recuperado de https://refactoring.guru/didp/principles/solid-principles/lsp
Gary McLean Hall. (15 de octubre de 2014). TThe Liskov Substitution Principle. Recuperado de https://www.microsoftpressstore.com/articles/article.aspx?p=2255313
Robert C. Martin, (25 de octubre de 2002), Agile Software Development, Principles, Patterns and Practices, Upper Saddle River, New Jersey, Pearson Education, Inc.