Los principios SOLID ilustrados en ejemplos sencillos de Python

Los principios SOLID facilitan el testeo, el mantenimiento y la legibilidad del código, así como un despliegue más ágil de software, mayor reusabilidad y escalabilidad del código y mejor depuración de errores.

Introducción

En el ámbito de la programación orientada a objetos y en el diseño de software, los principios SOLID son un conjunto de 5 principios que facilitan el testeo, mantenimiento y la legibilidad del código. Los beneficios de que un equipo adopte estos principios en el desarrollo de su código incluyen un despliegue más ágil de software, una mayor reusabilidad y escalabilidad del código y una mejor depuración de errores. Estos principios son un subconjunto de los enunciados por Robert C. Martin, conocido como Uncle Bob, en su artículo Design Principles and Design Patterns.

SOLID es el acrónimo mnemónico que hace referencia a cada uno de los principios por sus siglas en inglés. Estas siglas son:

  • Single Responsibility Principle (SRP) o Principio de Responsabilidad Única
  • Open-Closed Principle (OCP) o Principio de Abierto/Cerrado
  • Liskov Substitution Principle (LSP) o Principio de Substitución de Liskov
  •  Interface Segregation Principle (ISP) o Principio de Segregación de Interfaz
  • Dependency Inversion Principle (DIP) o Principio de Inversión de Dependencias

Los principios SOLID son de los más conocidos en el mundo del diseño de software y son una buena base para poder desarrollar código en ámbitos colaborativos, como por ejemplo, en el ámbito de la ingeniería de datos. En Damavis nos gusta aplicar estas buenas prácticas de programación para hacer llegar a nuestros clientes un software de calidad. A continuación, se ilustran los principios SOLID uno por uno con unos ejemplos sencillos escritos en Python.

1. Single Responsibility Principle (SRP)

El primer principio de SOLID llamado Principio de Responsabilidad Única indica que una clase debería ser responsable de una única funcionalidad. En otras palabras, la clase solo debería tener una única razón para cambiar. En el siguiente ejemplo sencillo se define una clase Duck con 5 diferentes métodos.

class Duck:
   
    def __init__(self, name):
        self.name = name
   
    def fly(self):
        print(f"{self.name} is flying not very high")

    def swim(self):
        print(f"{self.name} swims in the lake and quacks")

    def do_sound(self) -> str:
        return "Quack"

    def greet(self, duck2: Duck):
        print(f"{self.name}: {self.do_sound()}, hello {duck2.name}")

La funcionalidad principal de esta clase es definir un pato. Si hay que cambiar esta definición, se cambiará esta clase. El problema yace en el método greet(), que se encarga de poder hablar con otros patos. Si se quisiera cambiar la funcionalidad de la conversación entre patos, se estaría cambiando también la clase Duck, es decir, habría una razón adicional para cambiar la clase. Las consecuencias de no respetar este principio pueden ser varias, como dificultar la depuración de errores, ya que varios errores apuntan al mismo sitio y las funcionalidades están más acopladas.

Para resolver este problema en el caso del ejemplo, se define una nueva clase Communicator que se encarga de toda la funcionalidad de comunicación. Esta nueva clase permite entablar una conversación entre dos patos, donde estos se saludan. De esta manera, se ha cambiado el funcionamiento de la comunicación sin que la clase Duck se haya visto afectada.

class Duck:
   
    def __init__(self, name):
        self.name = name
   
    def fly(self):
        print(f"{self.name} is flying not very high")

    def swim(self):
        print(f"{self.name} swims in the lake and quacks")

    def do_sound(self) -> str:
        return "Quack"

class Communicator:

    def __init__(self, channel):
        self.channel = channel

    def communicate(self, duck1 : Duck, duck2: Duck):
        sentence1 = f"{duck1.name}: {duck1.do_sound()}, hello {duck2.name}"
        sentence2 = f"{duck2.name}: {duck2.do_sound()}, hello {duck1.name}"
        conversation = [sentence1, sentence2]
        print(*conversation,
              f"(via {self.channel})",
              sep = '\n')

2. Open-Closed Principle (OCP)

El Principio de Abierto/Cerrado indica que las clases deberían estar abiertas para su extensión, pero cerradas para su modificación. En otros términos, el código debería estar escrito de tal manera que, a la hora de añadir nuevas funcionalidades, no se deba modificar el código escrito previamente, que pueda estar siendo utilizado por otros usuarios. 

En el ejemplo anterior, no se puede extender la funcionalidad de Communicator para añadir diferentes tipos de conversaciones sin modificar el método communicate(). Para cumplir con el segundo principio, se crea una clase AbstractConversation que se encargará de definir diferentes tipos de conversaciones en sus subclases con implementaciones de do_conversation(). De esta manera, el método communicate() de Communicator solo se regirá a llevar a cabo la comunicación a través de una canal y nunca se requerirá de su modificación (es un método final). 

from typing import final

class AbstractConversation:

    def do_conversation(self) -> list:
        pass

class SimpleConversation(AbstractConversation):

    def __init__(self, duck1: Duck, duck2: Duck):
        self.duck1 = duck1
        self.duck2 = duck2

    def do_conversation(self) -> list:
        sentence1 = f"{self.duck1.name}: {self.duck1.do_sound()}, hello {self.duck2.name}"
        sentence2 = f"{self.duck2.name}: {self.duck2.do_sound()}, hello {self.duck1.name}"
        return [sentence1, sentence2]

class Communicator:  

    def __init__(self, channel):
        self.channel = channel

    @final
    def communicate(self, conversation: AbstractConversation):
        print(*conversation.do_conversation(),
              f"(via {self.channel})",
              sep = '\n')

3. Liskov Substitution Principle (LSP)

El Principio de Substitución de Liskov establece que las clases deberían ser sustituibles por instancias de sus subclases. Para ilustrar este principio, se considera la posibilidad de que se puedan añadir nuevas aves en un futuro. Para ello, una buena práctica consiste en añadir clase abstracta Bird y que las aves como Duck implementen sus métodos. A partir de aquí también se puede definir, a partir de Bird, una subclase de cuervo Crow.

from abc import ABC, abstractmethod

class Bird(ABC):
   
    def __init__(self, name):
        self.name = name
   
    @abstractmethod
    def fly(self):
        pass

    @abstractmethod
    def swim(self):
        pass

    @abstractmethod
    def do_sound(self) -> str:
        pass

class Crow(Bird):
   
    def fly(self):
        print(f"{self.name} is flying high and fast!")

    def swim(self):
        raise NotImplementedError("Crows don't swim!")

    def do_sound(self) -> str:
        return "Caw"

class Duck(Bird):
   
    def fly(self):
        print(f"{self.name} is flying not very high")

    def swim(self):
        print(f"{self.name} swims in the lake and quacks")

    def do_sound(self) -> str:
        return "Quack"

Este pequeño cambio ha generado un problema: los métodos de SimpleConversation dependen de Duck. Esto supone que esta clase no podría aceptar, por ejemplo, instancias de la clase Crow. Por tanto, hay que cambiar la dependencia de Duck por Bird. Tras haber hecho este cambio, se puede sustituir Bird con cualquier instancia de sus subclases sin que esto origine ningún problema, es decir, se está respetando el tercer principio.

class SimpleConversation(AbstractConversation):
   
    def __init__(self, bird1: Bird, bird2: Bird):
        self.bird1 = bird1
        self.bird2 = bird2

    def do_conversation(self) -> list:
        sentence1 = f"{self.bird1.name}: {self.bird1.do_sound()}, hello {self.bird2.name}"
        sentence2 = f"{self.bird2.name}: {self.bird2.do_sound()}, hello {self.bird1.name}"
        return [sentence1, sentence2]

4. Interface Segregation Principle (ISP)

El Principio de Segregación de Interfaz establece que los clientes no deberían ser forzados a depender de métodos que no utilizan y, por tanto, sugiere la creación de interfaces o clases específicas para dichos clientes. En el apartado anterior, se ha añadido una nueva clase Crow que describe un cuervo. Pero en esa definición hay un problema: el cuervo no sabe nadar y la clase abstracta Bird nos obliga a definir swim().

Para resolver este problema hay que segregar la clase Bird. Para hacerlo, se definen dos nuevas clases abstractas para aquellos pájaros que saben nadar (SwimmingBird) y aquellos que saben volar (FlyingBird), y que extenderán la funcionalidad de Bird. De esta manera, Crow implementa la funcionalidad de FlyingBird y Duck implementa la funcionalidad de SwimmingBird y FlyingBird. Ahora, si se tuviera que implementar el pingüino, que sabe nadar pero no vuela, tan solo se debería extender SwimmingBird.

class Bird(ABC):

    def __init__(self, name):
        self.name = name

    @abstractmethod
    def do_sound(self) -> str:
        pass

class FlyingBird(Bird):

    @abstractmethod
    def fly(self):
        pass

class SwimmingBird(Bird):

    @abstractmethod
    def swim(self):
        pass

class Crow(FlyingBird):
   
    def fly(self):
        print(f"{self.name} is flying high and fast!")

    def do_sound(self) -> str:
        return "Caw"

class Duck(SwimmingBird, FlyingBird):
   
    def fly(self):
        print(f"{self.name} is flying not very high")

    def swim(self):
        print(f"{self.name} swims in the lake and quacks")

    def do_sound(self) -> str:
        return "Quack"

5. Dependency Inversion Principle (DIP)

El último principio llamado Principio de Inversión de Dependencias se puede separar en dos enunciados. Por un lado, indica que las abstracciones no deberían depender de los detalles, pues los detalles deberían depender de las abstracciones.  Por otro lado, indica que las clases de alto nivel no deberían depender de clases de bajo nivel, dado que ambas deberían depender de abstracciones. En resumen, hay que depender de las abstracciones.

Para ilustrar este principio, se decide que el canal que ahora se define en la clase de comunicador va a tener más funcionalidades en un futuro. Siguiendo el primer principio (SRP), se extrae esta nueva responsabilidad de la clase Communicator y se le asigna a una nueva clase abstracta AbstractChannel. Adicionalmente, se hace que la clase de comunicador ahora sea abstracta (AbstractCommunicator) de tal forma que define un canal concreto. Esta nueva abstracción y el método final get_channel_message() en AbstractChannel nos ayudan a cumplir el segundo principio (OCP) ya que communicate() no necesitará ser modificado para usar diferentes canales.

class AbstractChannel(ABC):

    def get_channel_message(self) -> str:
        pass

class AbstractCommunicator(ABC):

    def get_channel(self) -> AbstractChannel:
        pass

    @final
    def communicate(self, conversation: AbstractConversation):
        print(*conversation.do_conversation(),
              self.get_channel().get_channel_message(),
              sep = '\n')

A partir de aquí, se puede definir un canal y un comunicador para que nuestras inteligentes aves puedan enviarse mensajes SMS:

class SMSChannel(AbstractChannel):

    def get_channel_message(self) -> str:
        return "(via SMS)"

class SMSCommunicator(AbstractCommunicator):  

    def __init__(self):
        self._channel = SMSChannel()

    def get_channel(self) -> AbstractChannel:
        return self._channel

Desafortunadamente, esta implementación no respeta el Principio de Inversión de Dependencias. Esto es debido a que dentro de SMSCommunicator se está llamando a SMSChannel, es decir, que se está dependiendo de detalles y no de clases abstractas. Para resolver este problema, se define un comunicador que dependa directamente de la abstracción.

class SimpleCommunicator(AbstractCommunicator):  

    def __init__(self, channel : AbstractChannel):
        self._channel = channel
   
    def get_channel(self) -> str:
        return self._channel

Conclusiones

Esperamos que estos ejemplos hayan sido de utilidad para comprender las bases de los principios SOLID. Gracias a estas buenas prácticas, se obtienen códigos más limpios y reutilizables que pueden ser de mucha utilidad cuando diferentes compañeros desarrollan diferentes partes del software o vayan a trabajar sobre él en un futuro.

Si te ha parecido interesante este artículo, te animamos a visitar la categoría Software de nuestro blog para ver post similares a este y a compartirlo en redes con todos tus contactos. No olvides mencionarnos para poder conocer tu opinión @Damavisstudio. ¡Hasta pronto!

Guillermo Camps
Guillermo Camps
Artículos: 16