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.
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')
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')
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]
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"
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
Conclusión
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.