Aprende los principios SOLID con LEGO
Descubre cómo los principios de diseño de software se vuelven simples si te los explican con bloques.
¿Te has topado con un código tan complejo que pensar en modificar cualquier parte parecía una tarea imposible? Seguramente sí y quizá más de una vez. En estos casos es donde entran en juego los principios SOLID, que son un conjunto de directrices diseñadas para combatir los dolores de cabeza más comunes en la programación orientada a objetos.
Introducción a SOLID
SOLID es un acrónimo formado por cinco principios fundamentales que guían a los desarrolladores en la creación de software robusto y flexible. Estos principios fueron popularizados por Rober C. Martin y aún ahora se consideran una piedra angular en el diseño de software.
Si no estás familiarizado con el tema, comprender los principios SOLID puede ser similar a ensamblar una compleja figura de LEGO, como el castillo de Hogwarts de Harry Potter. Así como cada LEGO tiene un lugar especifico y un propósito en la construcción de la figura, los desarrolladores utilizamos estos principios para que el código que escribimos sea coherente, funcional y elegante.
Al igual que en la construcción de un LEGO, donde cada bloque debe encajar perfectamente para obtener el resultado deseado, en programación, cada componente debe de estar bien definido y alineado con los principios SOLID para asegurar que la aplicación sea robusta y eficiente.
A continuación, veremos cada uno de estos principios, te explicaré su significado y de que forma los puedes implementar en tus proyectos, ya que comprender y aplicar SOLID es un paso crucial hacia el desarrollo de un código de calidad.
Los principios SOLID
Los principios SOLID son fundamentales en la programación orientada a objetos, ya que proporcionan una guía para escribir código que sea fácil de mantener y expandir. No solo ayudan a evitar que el código se vuelva rígido y frágil, sino que también facilitan la creación de sistemas comprensibles y flexibles.
S - Principio de Responsabilidad Única (Single Responsibility Principle)
Este principio establece que una clase debe tener solo una razón para cambiar, lo que significa que debe de tener solo una responsabilidad, o sea, una sola funcionalidad.
Imagina que estás construyendo un modelo de LEGO, como un coche. En el mundo de LEGO, hay diferentes piezas, cada una con su propio propósito: algunas forman las ruedas, otras el chasis, y otras las puertas. Si una pieza de LEGO intentara ser al mismo tiempo una rueda y una puerta, sería complicado y no funcionaría bien.
El Principio de Responsabilidad Única en programación es similar. Piensa en cada clase en tu código como una pieza de LEGO. Cada clase, como cada pieza de LEGO, debe tener una única responsabilidad o propósito. Por ejemplo, en un programa de computadora, podrías tener una clase diseñada solo para manejar los datos del usuario y otra clase exclusivamente para mostrar esos datos en la pantalla. Si intentas que una clase haga ambas cosas (gestionar los datos y mostrarlos), es como tener una pieza de LEGO que intenta ser dos cosas a la vez, lo que puede llevar a problemas y confusión.
Así que, al igual que cada pieza de LEGO tiene una función específica en un modelo, cada clase en tu código debe tener una única responsabilidad para que todo funcione de manera más eficiente y organizada.
Ejemplo práctico
Problema
Supongamos que tienes una clase Report
en una aplicación de gestión empresarial. Inicialmente, esta clase podría tener dos responsabilidades:
Generar el contenido del reporte.
Guardar el reporte en un archivo.
class Report {
public String createReport() {
# Lógica para generar el contenido del reporte
}
public void saveReport() {
# Lógica para guardar el reporte en un archivo
}
}
Este diseño viola el Principio de Responsabilidad Única, ya que la clase Report está manejando tanto la generación de contenido como su almacenamiento. Si llegaras a necesitar cambiar la forma en que se genera contenido o cómo se guarda, tendrías que modificar la misma clase, lo cual vuelve el código susceptible a errores y difícil de mantener.
Solución
Para adherirnos a este principio, se puede dividir la clase en dos:
Una para generar el contenido del reporte.
La segunda para manejar su almacenamiento.
class ContentGenerator:
def generate_report(self):
# Lógica para generar el contenido del reporte
pass
class ReportSaver:
def save_report(self, content):
# Lógica para guardar el reporte en un archivo
pass
Con este enfoque, cada clase tiene una sola responsabilidad. Esto hace que el código sea más fácil de mantener y expandir. Por ejemplo, si en el futuro se necesita cambiar el formato del reporte o el método de almacenamiento, solo se debe de modificar la clase correspondiente, reduciendo el riesgo de errores inadvertidos.
O - Principio de Abierto/Cerrado (Open/Closed Principle)
El principio establece que las entidades de software (clases, módulos, funciones, etcétera) deben de estar abiertas para la extensión, pero cerradas para la modificación. Esto significa que deberías de poder agregar nuevas funcionalidades a tu código sin cambiar el código existente.
Para entender este principio imagina que estás armando el LEGO del Castillo de Hogwarts de Harry Potter. Ahora, piensa que quieres agregar más cosas a al castillo, como el despacho de Dumbledore o la sala de los Menesteres, pero sin desarmar lo que ya has construido.
En la programación, el Principio de Abierto/Cerrado es similar a esto. Es como si tu castillo de LEGO (tu programa) está diseñado de tal manera que puedes añadir nuevas partes (funcionalidades) sin tener que desmontar o cambiar las partes que ya existen. Por ejemplo, si tienes el set del Despacho de Dumbledore, y más tarde consigues el set Sala de los Menesteres, podrás ir juntándolos, con incluso más sets hasta tener el Gran Castillo de Hogwarts de LEGO.
Aplicando esto al código, significa que si tienes una parte de tu programa que hace algo, y quieres añadir nuevas características, deberías poder hacerlo añadiendo nuevo código, no cambiando el que ya está. De esta manera, tu código original permanece intacto (cerrado para la modificación), pero aún así puedes ampliar sus capacidades (abierto para la extensión).
Ejemplo práctico
Problema
Imagina que tienes una clase para dibujar diferentes figuras. En un inicio, solo puedes dibujar círculos.
class Shape:
def __init__(self, type):
self.type = type
class Circle(Shape):
def __init__(self):
super().__init__("circle")
def draw_shape(shape):
if shape.type == "circle":
# Dibuja un círculo
pass
Si quieres agregar más formas, como cuadrados o triángulos, deberás de modificar la función draw_shape
, lo cual viola este principio.
Solución
Una mejor manera es permitir que las formas se dibujen a sí mismas, lo que significa que la clase base Shape puede ser extendida pero no se necesita modificarla para agregar nuevas formas.
class Shape:
def draw(self):
pass
class Circle(Shape):
def draw(self):
# Lógica para dibujar un círculo
pass
class Square(Shape):
def draw(self):
# Lógica para dibujar un cuadrado
pass
def draw_shapes(shapes):
for shape in shapes:
shape.draw()
Con este diseño, se pueden agregar nuevas formas (como Square
), simplemente extendiendo la clase Shape
y sin modificar ninguna de las clases o funciones existentes.
Cumplir con esto, es adherirse al principio de abierto/cerrado, ya que tu código es abierto para la extensión (se pueden agregar nuevas formas) pero cerrado a la modificación (no se necesita cambiar ninguna función existente).
L – Principio de Sustitución de Liskov (Liskov Substitution Principle)
El principio de Sustitución de Liskov establece que las clases derivadas o subclases deben ser intercambiables con sus clases base o superclases sin afectar el correcto funcionamiento de la aplicación.
Imagina que tienes un juego de LEGO con varias figuras, como un robot y un superhéroe, ambos construidos sobre una base común que permite a las figuras conectarse a diferentes partes del juego de LEGO, como un vehículo o una casa. Si puedes quitar el robot y poner el superhéroe en su lugar, y todo sigue funcionando bien (el superhéroe se adapta perfectamente al vehículo o a la casa), entonces esto es similar al Principio de Sustitución de Liskov en programación.
Este principio significa que si tienes una clase base (como una figura genérica de LEGO) y varias subclases derivadas de ella (como diferentes figuras de LEGO: robot o superhéroe), deberías poder usar cualquiera de esas subclases en lugar de la clase base sin que tu programa deje de funcionar correctamente. Por ejemplo, si tu código espera una figura de LEGO genérica, deberías poder usar el robot o el superhéroe indistintamente sin problemas.
Si al reemplazar la figura de LEGO genérica por el robot o el superhéroe tu LEGO no encaja o tu juego se rompe, entonces estarías violando el Principio de Sustitución de Liskov. Piénsalo como asegúrate de que los hijos de una clase se comporten adecuadamente como sus padres.
Ejemplo práctico
Problema
Supongamos que tienes una clase Bird
y una subclase Duck
que extiende de Bird. Sin embargo, decides agregar otra subclase Ostrich
que también extiende de Bird
, pero las avestruces no pueden volar, lo que viola el principio de Liskov si la clase Bird
tiene un método para volar.
class Bird:
def fly(self):
# Implementación general de volar
pass
class Duck(Bird):
def fly(self):
# Implementación específica de cómo vuela un pato
pass
class Ostrich(Bird):
def fly(self):
raise Exception("No puedo volar")
Si intentas hacer volar a una avestruz, el programa lanzara una excepción, lo que indica que no puedes usar Ostrich
y Duck
de manera intercambiable, aunque ambas sean subclases de Bird
.
Solución
Una mejor solución es reestructurar las clases para asegurarse de que todas las subclases de Bird
puedan reemplazar a la clase base si problemas. Una posible solución es separar los comportamientos en diferentes interfaces o clases base.
class Bird:
pass
class FlyingBird(Bird):
def fly(self):
# Implementación general de volar
pass
class Duck(FlyingBird):
def fly(self):
# Implementación específica de cómo vuela un pato
pass
class Ostrich(Bird):
pass
En este diseño Duck es una subclase de FlyingBird, que es una subclase de Bird. Ostrich es directamente una subclase de Bird y no de Flying Bird, lo que refleja que no puede volar. De esta manera, puedes usar Duck, en lugar de FlyingBird, pero no estás obligado a implementar un comportamiento de vuelo en Ostrich, cumpliendo así el Principio de Sustitución de Liskov.
I – Principio de Segregación de Interfaces (Interface Segregation Principle)
Este principio sugiere que es mejor tener interfaces específicas, en lugar de una única interfaz general para diferentes propósitos. La segregación de interfaces ayuda a evitar que una clase implemente métodos que no utiliza, lo que puede llevar a un diseño de software más limpio y mantenible.
Ahora imagina que tienes un gran conjunto de LEGO con diferentes tipos de bloques. Algunos bloques son para construir casas, otros para crear coches y algunos para hacer árboles. Si todos estos bloques vinieran en un solo paquete grande, con instrucciones para construir casas, coches y árboles, todo mezclado. Sería complicado y confuso, especialmente si solo quieres construir una casa.
El Principio de Segregación de Interfaces LEGO lo aplica en sus sets al venir cada sección en bolsas diferentes, cada uno con su propio conjunto de instrucciones específicas: uno para casas, otro para coches, y otro para árboles. Si solo quieres construir una casa, usas el paquete de casa. Esto hace que todo sea más simple y organizado.
Este principio sugiere que en lugar de tener una gran interfaz que haga muchas cosas diferentes, es mejor tener interfaces más pequeñas y específicas. Así, si tienes un código que necesita hacer algo específico, como manejar datos de usuarios, no tiene que preocuparse también por, digamos, procesar imágenes o manejar conexiones a internet. Cada parte de tu código usa solo las interfaces que necesita, lo que hace que tu programa sea más ordenado y fácil de manejar.
Ejemplo práctico
Problema
Imagina que tienes una interfaz llamada MultiFunctionPrinter
que incluye una variedad de funciones. Incluso las impresoras que solo necesitan imprimir deben implementar todos los métodos, lo cual no es lo ideal.
class MultiFunctionPrinter:
def print_document(self, document):
pass
def scan_document(self, document):
pass
def fax_document(self, document):
pass
class BasicPrinter(MultiFunctionPrinter):
def print_document(self, document):
# Implementación de la impresión
pass
def scan_document(self, document):
raise NotImplementedError("Scan not supported")
def fax_document(self, document):
raise NotImplementedError("Fax not supported")
En este ejemplo, BasicPrinter
se ve obligada a implementar métodos que no utiliza, lo que va en contra del Principio de Segregación de Interfaces.
Solución
Una mejor manera de abordar esto es dividir la interfaz MultiFunctionPrinter
en interfaces más pequeñas y específicas.
class Printer:
def print_document(self, document):
pass
class Scanner:
def scan_document(self, document):
pass
class Fax:
def fax_document(self, document):
pass
class BasicPrinter(Printer):
def print_document(self, document):
# Implementación de la impresión
pass
class MultiFunctionMachine(Printer, Scanner, Fax):
def print_document(self, document):
# Implementación de la impresión
pass
def scan_document(self, document):
# Implementación del escaneo
pass
def fax_document(self, document):
# Implementación del fax
pass
En esta solución BasicPrinter
solo implementa la interfaz Printer, mientras que MultiFunctionPrinter implementa Printer
, Scanner
y Fax
. De esta forma, cada clase implementa solo las interfaces que necesita, cumpliendo con este principio.
D – Principio de Inversión de Dependencias (Dependency Inversion Principle)
El Principio de Inversión de Dependencias indica que las clases de alto nivel no deben depender directamente de clases de bajo nivel, sino de abstracciones. Así mismo, las abstracciones no deben depender de los detalles, sino que los detalles deben depender de las abstracciones.
Para entenderlo mejor imagina que estás construyendo una gran estructura de LEGO, como un castillo. En lugar de construir cada parte del castillo (torres, paredes, puertas) directamente sobre una base específica de LEGO, primero creas una especie de plataforma universal sobre la cual todas estas partes pueden ser construidas y conectadas. Esta plataforma universal es como la abstracción en el Principio de Inversión de Dependencias.
En la programación, las clases de alto nivel son como las partes grandes del castillo (torres, paredes), y las "clases de bajo nivel" son como los bloques de LEGO individuales. Sin la plataforma universal, cada parte del castillo tendría que estar diseñada para encajar con bloques específicos, lo que sería muy limitante y haría difícil cambiar algo más tarde. Pero con la plataforma universal, se puede construir o cambiar cualquier parte del castillo de manera independiente.
Así, el Principio de Inversión de Dependencias en programación significa que en lugar de que las grandes partes del programa dependan directamente de los pequeños detalles, ambos dependen de reglas o interfaces generales. Esto hace que sea mucho más fácil cambiar o actualizar partes del programa sin desmontar todo el castillo.
Ejemplo práctico
Problema
Imagina una aplicación donde una clase de alto nivel DataAnalysis
depende directamente de una clase de bajo nivel ExcelDataReader
.
class ExcelDataReader:
def read_data(self):
# Lee datos de un archivo Excel
return "Datos de Excel"
class DataAnalysis:
def __init__(self):
self.reader = ExcelDataReader()
def analyze_data(self):
data = self.reader.read_data()
# Análisis de los datos
En este diseño, DataAnalysis
está fuertemente acoplada a ExcelDataReader
. Si decides cambiar a otro tipo de lector de datos, tendrías que modificar DataAnalysis
.
Solución
Una mejor solución es introducir una abstracción (una interfaz o clase base) que DataAnalysis
pueda utilizar, y hacer que ExcelDataReader
sea una implementación de esta abstracción.
class DataReader:
def read_data(self):
pass
class ExcelDataReader(DataReader):
def read_data(self):
# Lee datos de un archivo Excel
return "Datos de Excel"
class CsvDataReader(DataReader):
def read_data(self):
# Lee datos de un archivo CSV
return "Datos de CSV"
class DataAnalysis:
def __init__(self, reader: DataReader):
self.reader = reader
def analyze_data(self):
data = self.reader.read_data()
# Análisis de los datos
En esta solución, DataAnalysis
depende de la abstracción DataReader
, no de una implementación específica. Puedes cambiar fácilmente entre ExcelDataReader
, CsvDataReader
, o cualquier otra implementación de DataReader
sin necesidad de modificar la clase DataAnalysis
. Esto hace que el código sea más flexible y fácil de mantener.
Beneficios de Aplicar SOLID
Aplicar los principios SOLID en el desarrollo de software conlleva una serie de ventajas significativas que mejoran tanto la calidad del código como la eficiencia del proceso de desarrollo. Algunos de estas ventajas son:
Mantenibilidad Mejorada
Con el principio de Responsabilidad Única, cada clase o módulo en tu código se va a ocupar de una sola tarea. Esto es como el ejemplo del LEGO donde cada pieza tiene un propósito específico y claro.
La claridad en la función de cada clase hace que sea mucho más fácil realizar cambios y mantenimiento, ya que sabes exactamente donde y cómo realizar ajustes sin afectar otras partes del sistema.
Flexibilidad y Escalabilidad
El principio de Abierto/Cerrado promueve una arquitectura donde se vuelve fácil añadir nuevas funcionalidades sin cambiar el código existente. Recuerda el ejemplo del Gran Castillo de Hogwarts que te permite ir añadiendo secciones sin afectar las secciones ya construidas. De manera similar en programación, este principio permite que los sistemas o aplicaciones evolucionen y crezcan con facilidad.
Reemplazo y Extensión Facilitados
El principio de Sustitución de Liskov asegura que las subclases sean intercambiables en sus clases base. Retomando el ejemplo de LEGO, si tienes una figura de un caballero o astronauta, estas deberían de poder utilizarse en diferentes escenarios sin problemas. Esto significa que al cumplir este principio puedes reemplazar o ampliar partes del sistema con la confianza de que no se romperá el funcionamiento general.
Código más Limpio y Organizado
Seguir el principio de Segregación de Interfaces lleva a tener un diseño donde las clases no están sobrecargadas con responsabilidades que no necesitan. Entendamos esto como los set de LEGO organizados en diferentes paquetes según la sección que se va armando para que sea más fácil encontrar y utilizar las piezas correctas.
En el código, esto se traduce en una organización más limpia y una mayor claridad, lo que facilita la comprensión y el trabajo en equipo.
Reducción de Dependencias y Acoplamiento
El principio de Inversión de Dependencias ayuda a reducir significativamente el acoplamiento del código, lo que significa que los diferentes módulos y clases son menos dependientes unos de otros. Esto puede ser ejemplificado con la construcción de un LEGO en el que se puede cambiar una sección sin desmontar otras secciones. En la práctica, esto facilita las pruebas, el mantenimiento y la actualización del software.
Desafíos y Consideraciones
Ahora ya sabes los beneficios que trae aplicar SOLID en el desarrollo, pero como todo su implementación también conlleva ciertos desafíos y consideraciones que deben de tenerse en cuenta. Los más comunes suelen ser:
Complejidad Inicial
Aplicar los principios SOLID puede aumentar la complejidad inicial del diseño del software. Es como tener un set avanzado de LEGO que puede llegar a tener más de diez mil piezas; al principio, puede parecer abrumador construir. En la programación, esto significa que se necesita un entendimiento profundo de estos principios y cómo aplicarlos, lo que puede ralentizar el desarrollo inicial.
Sobreingeniería
Existe el riesgo de caer en la sobreingeniería al tratar de seguir estrictamente SOLID. Imagina que, al diseñar un nuevo set LEGO, el diseñador se enfoca en seguir ciertas consideraciones al pie de la letra que termina con algo demasiado complicado para que sea armado por cualquier consumidor de LEGO. En programación, esto se traduce en crear demasiadas clases y abstracciones, lo que puede hacer que el código sea más difícil de leer y mantener.
Flexibilidad vs. Rendimiento
SOLID promueve la flexibilidad y mantenibilidad, lo cual en algunos casos, puede impactar negativamente en el rendimiento. Por ejemplo, si cada pieza de un LEGO está diseñada para ser intercambiable y extensible, el modelo final podría ser menos robusto. De igual forma, una aplicación o sistema con demasiadas capas de abstracción puede dar como resultado un rendimiento reducido, especialmente donde la eficiencia es crítica.
Comprensión y Formación del Equipo
Al trabajar con SOLID en un proyecto, todos los miembros que conformar el equipo de desarrollo deben comprender y estar de acuerdo con estos principios para que se apliquen de forma efectiva. Si solo algunos miembros lo aplican y otros no, el resultado podría no ser coherente.
Equilibrio con otras Prácticas de Desarrollo
Los principios SOLID deben de equilibrarse con otras prácticas y requerimientos del desarrollo de software, como los patrones de diseño, la simplicidad y las necesidades específicas del proyecto. Es como utilizar un set de LEGO para construir algo que también necesita integrarse y funcionar con otros juegos o piezas.
Como puedes ver, los principios SOLID sirven como guía para cualquier desarrollador que busque mejorar la calidad y mantenibilidad de su código. Con la comprensión y aplicación de estos principios, se puede lograr un diseño de software más robusto, flexible y eficiente. Empezar a aplicarlos puede representar un desafío inicial, los beneficios a largo plazo son significativos tanto para proyectos individuales como para tu crecimiento profesional. Te animo a incorporar estos principios en tus próximos proyectos 💜
No olvides suscribirte al newsletter si aún no lo estas y nos leemos el próximo jueves con más contenido sobre desarrollo de software.