Alexandre Freire

Blog sobre desarrollo en Swift, iOS y Xcode

Principio de Substitución de Liskov – SOLID

May 9, 2020

El Principio de sustitución de Liskov, también conocido como Principio Liskov, Liskov’s Substitution Principle o LSP, es el tercero de los principios SOLID de la Programación Orientada a Objetos.

Para los más curiosos, este nombre tan raro tiene su origen en una conferencia que dio Barbara Liskov en 1987, donde introducía el concepto de este principio:

«Los objetos de un programa deberían ser intercambiables por instancias de sus subtipos sin alterar el correcto funcionamiento del programa»

Es decir, que si el programa utiliza una clase (clase A), y ésta es extendida (clases B, C, D, etc…) el programa tiene que poder utilizar cualquiera de sus subclases y seguir siendo válido.

Este principio nos explica cómo se debería utilizar correctamente la herencia y el polimorfismo. Ambos son muy poderosos, si los aplicas correctamente.

«Un gran poder conlleva una gran responsabilidad»

Tío Ben – Spiderman

Ejemplo de buena aplicación del Principio de Substitución de Liskov en UIKit

Imagina que estás creando la interfaz de tu aplicación por código, sin utilizar el InterfaceBuilder.

Para hacerlo, seguramente utilices alguna de estas clases:

  • UILabel
  • UITextField
  • UITableView
  • UIButton
  • etc…

Luego, para crear tu layout, utilizarás la siguiente función para añadirlos a tu vista:

func addSubview(_ view: UIView)

Por ejemplo, en un LoginViewController podrías tener:

func setupUI() {
    let emailLabel = UILabel()
    view.addSubview(emailLabel)

    let emailTextField = UITextField()
    view.addSubview(emailTextField)

    // Añadir AutoLayout constraints...
    // Resto de setup, etc... 
}

Fíjate que la función addSubview() acepta un parámetro de tipo UIView. Sin embargo, la estás pasando un UILabel y un UITextField… ¿Por qué funciona?

¿Cómo es posible que funcione?

Esto es el Liskov Substitution Principle bien aplicado en UIKit, haciendo que puedas intercambiar cualquier instancia por instancias de sus subtipos sin alterar el correcto funcionamiento del programa.

Estás reemplazando el parámetro de tipo UIView por un subtipo o subclase, haciendo que el programa siga funcionando a la perfección. Tanto UILabel como UITextField son subclases de UIView.

En otras palabras, el código de la función addSubview() depende de la abstacción UIView, y por eso podemos pasarle cualquiera de sus subclases (UILabel, UITextField, etc…) como parámetro y el programa seguirá funcionando perfectamente.

Así, el día de mañana cuando UIKit tenga una nueva subclase de UIView, podrás seguir utilizando esta función sin ningún problema.

Ejemplo de violación del principio de Liskov

Como has podido comprobar en el ejemplo anterior, el Principio de Sustitución de Liskov está estrechamente ligado a la herencia y el polimorfismo.

Cuando modelamos nuestro programa utilizando herencia y realizamos las abstracciones y suposiciones erróneas, acabaremos violando el Lisvkov Substitution Principle.

Pongamos el ejemplo de un videojuego sobre aves. Este videojuego dibuja varias aves en un canvas.

En la versión beta, el juego sólamente soporta tres tipos de aves: palomas, vencejos (Swifts 😉 ), y cuervos.

Vencejo
Un Swift cachorro

Entonces, creas una clase Bird, y diferentes subclases para cada ave:

class Bird {
    var currentAltitude = 0
    
    func fly() {
        currentAltitude = Int.random(in: 1...300)
    }
}
class Pigeon: Bird {
    override func fly() {
        // Las palomas vuelan a menos altura
        currentAltitude = Int.random(in: 1...100)
    }
}
// Los cuervos no necesitan sobreescribir nada de su superclase
class Raven: Bird { }
// Los Swifts no necesitan sobreescribir nada de su superclase
class Swift: Bird { }

El objeto Canvas, lo único que hace es dibujar los pajaritos a la altura actual a la que están volando:

class Canvas {
    
    var birds: [Bird]
    var drawer: BirdDrawer
 
    init(birds: [Bird], drawer: BirdDrawer) {
        self.birds = birds
        self.drawer = drawer
    }
    
    // Dibuja a los pajaros volando a la altura actual 
    func draw() {
        for bird in birds  {
            bird.fly()
        }
        
        for bird in birds {
            drawer.drawBirdAt(altitude: bird.currentAltitude)
        }
    }
}

El juego es un éxito con miles de descargas, y poco a poco se van añadiendo más y más especies de aves.

En la versión 2.0, una nueva especie de ave causa serios problemas a los desarrolladores: un pollo/gallina. Técnicamente, los pollos son pájaros, así que se crea una nueva subclase:

class Chicken: Bird {

}

Y aquí nos encontramos el problema: al heredar de la clase Bird, Chicken hereda todos sus métodos y propiedades. El problema es que los pollos no vuelan…

¿Cómo podrían los desarrolladores del juego asegurarse que no se llama al método fly() en una instancia de tipo Chicken?

Podrían dejar el método vacío y que no hiciese nada, o podrían lanzar un error para asegurarse que este método no es utilizado en tipos de aves que no pueden volar:

class Chicken: Bird {
    
    override func fly() {
        fatalError("Chickens can NOT fly. Do not use this method.")
    }
}

Elijas la opción que elijas, ambas violan el Principio de Liskov, ya que el programa ya no puede utilizar cualquier subclase de Bird en el Canvas y que el programa siga siendo válido.

Tan pronto como haya una instancia de tipo Chicken en el canvas, el programa lanzaría un crash en la línea bird.fly(), ya que las gallinas no pueden volar.

¿Cómo solucionarlo y hacer que cumpla el Liskov Substitution Principe?

Aquí tienes varias opciones para hacer que tu app no tenga un crash.

La primera, como te comento en la sección anterior, sería dejar el método fly() vacío en aquellas aves que no vuelen. Esto no parece una opción muy robusta.

La segunda opción es aplicar un Downcasting en el método draw() del canvas. Un downcasting consiste en realizar una transformación de de una clase padre hacia una de las clases hijas: en nuestro caso, convertiríamos Bird en Chicken, y si es así no llamaríamos al método fly():

class Canvas {

    ...
    func draw() {
        for bird in birds  {
            // Si el `bird` no es `Chicken`, entonces llama al método `fly()`
            if !(bird is Chicken) {
                bird.fly()
            }
        }
         // Dibujar los pájaros en el canvas
        for bird in birds {
            drawer.drawBirdAt(altitude: bird.currentAltitude)
        }
    }   
}

Esta solución puede parecer correcta a simple vista. Sin embargo, si ya has leído mi artículo sobre el Principio Open/Closed te darás cuenta que lo estarías violando con esta solución.

En el momento en el que tu juego necesite dar soporte a otro ave que tampoco vuele, por ejemplo, los pingüinos (subclase Penguin) tendríamos que hacer un nuevo downcasting:

class Canvas {

    ...
    func draw() {
        for bird in birds  {
            // Si el `bird` no es `Chicken` ni TAMPOCO `Penguin`, entonces llama al método `fly()`
            if !(bird is Chicken) && !(bird is Penguin) {
                bird.fly()
            }
        }
        
        // Dibujar los pájaros en el canvas
        for bird in birds {
            drawer.drawBirdAt(altitude: bird.currentAltitude)
        }
    }   
}

La solución definitiva es replantearse la jerarquía de clases desde el principio. No asumir que las clases y su herencia son un mecanismo para modelar directamente la realidad.

La norma principal que deben seguir tus clases y subclases es la siguiente: «cualquier cosa que la clase padre pueda hacer, todas las clases hijas deben poder hacer lo mismo, y algo más»

Siguiendo esa máxima, podemos crear una subclase FlyableBird que sirva como clase padre a todas las aves que son capaces de volar, mientras Bird es la clase padre de todas las que no vuelan:

class Bird {
    var currentAltitude = 0
}

class FlyableBird: Bird {
    func fly() {
        currentAltitude = Int.random(in: 1...300)
    }
}

Ahora es turno de crear las subclases para el juego:

// Los pollos no vuelan, así que son subclases de `Bird`

class Chicken: Bird { }

// Las aves que pueden volar heredan de `FlyableBird`
// en lugar de, simplemente, `Bird`

class Swift: FlyableBird { }

class Raven: FlyableBird { }

class Pigeon: FlyableBird {
    override func fly() {
        currentAltitude = Int.random(in: 1...100)
    }
}

Por último, modificamos ligeramente el objeto Canvas para que sólo llame al método fly() para las aves voladoras:

class Canvas {
    
    ...

    func draw() {
        // Filtramos todos los pájaros y nos quedamos
        // sólamente con los voladores
        let filteredBirds = birds.filter{ bird in
            bird is FlyableBird
        }
        
        // Desempaquetamos el downcasting opcional
        if let flyablaBirds = filteredBirds as? [FlyableBird] {
            for bird in flyablaBirds {
                bird.fly()
            }
        }
        
        // Dibujamos todos los pájaros, incluidos los no voladores
        for bird in birds {
            drawer.drawBirdAt(altitude: bird.currentAltitude)
        }
    }
}

¿Cómo detectar que estás violando el Liskov’s Substitution Principle?

Detectar que estás violando el Principio de Liskov o Liskov Substitution Principle es más sencillo de lo que puede parecer:

  • Si estás creando subclases y a éstas le sobran algún método de su clase padre, y decides dejarlo vacío o lanzar un error, entonces probablemente estarás violando el LSP
  • Si abusas del downcasting porque tu programa necesita saber las diferencias entre la implementación de los subtipos, es probable que también te lo estés saltando.
  • Si en un test unitario, cambias una instancia de una clase por otro subtipo, y el test falla, entonces seguro que estás violando el Principio de Sustitución de Liskov.

Recuerda el ejemplo de Apple en UIKit. Gracias a que los ingenieros de Cupertino han seguido el Principio de Substitución de Liskov, tú puedes utilizar el método addSubview() con cualquier subclase de UIView sin tener que preocuparte de las diferencias entre ellas.

Únete a la newsletter para no perderte ningún nuevo tutorial: recibe un email cuando se publiquen nuevos artículos o vídeos y no pierdas la oportunidad de seguir aprendiendo. 

Conclusión

El Principio de Liskov define la manera en la que se debe utilizar la herencia en la programación orientada a objetos. A diferencia de lo que muchos desarrolladores piensan, la herencia no es el mecanismo para modelar la realidad al 100%. Como muestra, el ejemplo de los pájaros de este artículo o el problema de la elipse y el círculo, o del rectángulo y el cuadrado.

Hay que tener cuidado a la hora de extender las clases, y replantearse la jerarquía de vistas cada vez que notas que un subtipo modifica el comportamiento de la clase padre.

Una clase hija debe conservar el comportamiento original de su clase padre. Si su superclase no lanza un error o permite tener un método vacío, entonces la subclase tampoco debería.

Yo lo resumo en: «cualquier cosa que la clase padre pueda hacer, todas las clases hijas deben poder hacer lo mismo, y algo más»

¿A dónde puedes ir después de este artículo?

¿Conocías este principio? Cuéntame qué te ha parecido en los comentarios, y si te ha resultado útil, ¿me ayudas a compartir?

👇👇👇

¿Me ayudas a compartir en redes sociales?
Share on Facebook
Facebook
Pin on Pinterest
Pinterest
Tweet about this on Twitter
Twitter
Share on LinkedIn
Linkedin
Buffer this page
Buffer
Share on Reddit
Reddit

0 comentarios

Enviar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *