Con el tiempo, los desarrolladores de software adquieren experiencia como, por ejemplo, la capacidad de evaluar la calidad del código y detectar problemas de diseño y errores. Es una cosa extraña, un “sexto sentido” que has desarrollado, sientes que algo no está bien, pero para poder explicarlo, debes hacer memoria. Esto es algo parecido a la capacidad de los mecánicos de los coches que simplemente por escuchar el sonido del motor saben cuál puede ser el problema del coche.
En este artículo vemos qué es lo bueno, lo feo y lo malo en el diseño de software, o dicho de otra manera: qué debemos respetar y qué debemos evitar mientras estamos diseñando el software.
Ya hemos hablado sobre los principios SOLID y las 5 reglas del diseño de software simple que son principios que se deben tomar en cuenta cuando diseñamos el software, pero no debemos olvidarnos sobre los patrones de diseño.
Los patrones de diseño son soluciones probadas para problemas comunes que, relacionados entre sí en orden de posición y utilidad, forman un lenguaje de patrones para describir tanto el problema como las soluciones para diferentes contextos. El término lenguaje de patrones fue acuñado por el arquitecto Christopher Alexander y popularizado por su libro de 1977 A Pattern Language. Después, la idea de utilizar patrones para resolver problemas comunes se entendió en otras disciplinas.
En 1994, Erich Gamma, Richard Helm, Ralph Johnson y John Vlissides, conocidos como "Gang of Four" (GoF), publicaron el libro “Design Patterns: Elements of Reusable Object-Oriented Software”, el cual contiene 23 patrones de diseño de software y se convirtió en referente en la programación orientada a objetos.
Los patrones de diseño se dividen en tres categorías:
- Patrones creacionales.
- Patrones estructurales.
- Patrones de comportamiento.
Los patrones creacionales están enfocados a la creación de objetos de manera eficiente y flexible en diferentes situaciones, reduciendo la complejidad del código y mejorando la reutilización de código.
Los patrones creacionales son:
- Abstract Factory: proporciona una interfaz para crear familias de objetos relacionados o dependientes sin especificar sus clases concretas.
- Builder: se utiliza para construir objetos complejos paso a paso, separando el proceso de construcción del objeto de su representación final. Es muy recomendable cuando el constructor tiene más de 4 parámetros y no se pueden reducir, por ejemplo en los objetos de entidad (entity) o DTO (Data Transfer Object).
- Factory Method: se utiliza cuando se desea delegar la creación de objetos a subclases, en lugar de crear objetos directamente.
- Object Pool: si el costo de inicializar una instancia de una clase es alto, es mejor utilizar un pool para reutilizar los objetos en vez de crear nuevos, por ejemplo el pool de las conexiones de la base de datos.
- Prototype: la creación de nuevos objetos utilizando un objeto existente como plantilla. En lugar de crear objetos desde cero, se clona un objeto existente y se modifica según sea necesario que es muy útil cuando estamos trabajando con objetos inmutables.
- Singleton: garantiza que una clase tenga solo una instancia y proporciona un punto de acceso global a ella, por ejemplo la clase que gestiona las operaciones con la base de datos.
Los patrones estructurales proporcionan soluciones para simplificar la estructura de los objetos y las relaciones entre ellos, lo que facilita la comprensión y la modificación del sistema de software.
Los patrones estructurales son:
- Adapter: cuando una clase necesita interactuar con otra clase que tiene una interfaz incompatible, el Adapter actúa como intermediario entre las dos clases.
- Bridge: separa la interfaz de un objeto de su implementación permitiendo que se creen nuevas implementaciones sin modificar el código existente.
- Composite: se utiliza para representar jerarquías de objetos en los que los objetos individuales y los grupos de objetos se tratan de la misma manera, por ejemplo, la representación de una estructura de árbol, en la que cada nodo del árbol puede tener varios hijos, que a su vez pueden tener más hijos, y así sucesivamente.
- Decorator: modifica dinámicamente el comportamiento de un método existente de un objeto.
- Facade: proporciona una interfaz simplificada a un subsistema complejo.
- Flyweight: utiliza objetos compartidos para reducir la memoria utilizada por muchos objetos similares.
- Proxy: actúa como un sustituto de otro objeto, controlando el acceso a él, por ejemplo, cuando se usa lazy loading se crea un proxy que se encarga de cargar los datos de un objeto solo cuando se necesita, y no antes.
Los patrones de comportamiento se utilizan para resolver problemas relacionados con la interacción y comunicación entre objetos.
Los patrones de comportamiento son:
- Chain of responsibility: una forma de pasar una petición entre una cadena de objetos, por ejemplo, en una aplicación web que recibe peticiones de los usuarios y cada petición debe pasar por varios procesos de validación y autorización antes de ser procesada.
- Command: encapsula una petición de comando como un objeto. Por ejemplo, en un mando de televisión tenemos muchos botones, cada uno de los cuales puede representar un comando que realiza una acción específica, como cambiar de canal, subir o bajar el volumen, encender o apagar la televisión, entre otras.
- Interpreter: se utiliza para definir un lenguaje de dominio específico (DSL) y proporcionar una forma de interpretar y ejecutar expresiones en ese lenguaje, por ejemplo, el intérprete de SQL convierte la consulta en un plan de ejecución que se puede utilizar para recuperar los datos de la base de datos.
- Iterator: proporciona una forma de acceder secuencialmente a los elementos de una colección.
- Mediator: define un objeto que encapsula cómo interactúa un conjunto de objetos reduciendo el acoplamiento. Por ejemplo, en un sistema de control de tráfico aéreo, varios aviones pueden estar en el aire al mismo tiempo y necesitan coordinarse entre sí para evitar colisiones, pero en lugar de permitir que los aviones se comuniquen directamente entre sí, el sistema utiliza un objeto Mediator para coordinar la ruta y la velocidad de los aviones para evitar colisiones.
- Memento: captura y externaliza el estado interno de un objeto para que el objeto pueda volver a este estado más tarde, por ejemplo en un editor de texto se pueden deshacer y rehacer los cambios del documento.
- Observer: una forma de notificar el cambio a todos los objetos que dependen de él, un ejemplo es una sala de chat, cada vez que un usuario envía un mensaje, la sala de chat emite un evento de "mensaje enviado" que notifica a todos los usuarios conectados.
- State: permite que un objeto modifique su comportamiento cuando cambie su estado interno, el objeto aparecerá como una clase diferente, por esta razón se necesita una clase por cada estado del objeto. Por ejemplo, un interruptor tiene dos estados: "encendido" o "apagado". Cuando el botón está en el estado "encendido", la luz está encendida y cuando el botón está en el estado "apagado", la luz está apagada.
- Strategy: encapsula un algoritmo dentro de una clase. Por ejemplo, en una aplicación de procesamiento de pagos que tiene diferentes estrategias de procesamiento de pagos como "tarjeta de crédito", "PayPal" y "transferencia bancaria", cada una de estas estrategias se puede representar como un objeto de estrategia diferente. La aplicación de procesamiento de pagos seleccionaría la estrategia correcta dependiendo del tipo de pago y las preferencias del usuario.
- Template method: define el esqueleto de un algoritmo en una clase base y delega la implementación de algunos pasos a las subclases.
- Visitor: representar una operación a realizar sobre los elementos de una estructura de objeto, permitiendo definir una nueva operación sin cambiar las clases de los elementos sobre los que opera. Es muy útil en situaciones en las que se requieren operaciones complejas en una estructura de clases existente que no se pueden agregar fácilmente a cada clase en la estructura.
A veces, mientras caminamos podemos percibir un olor desagradable sin ver su origen, lo que nos indica que algo no está bien, pero no sabemos qué. Lo mismo ocurre con los code smells (también conocidos como un código que huele o apesta), síntomas en el código que vienen a indicar que tal vez no se están haciendo las cosas de una forma del todo correcta, lo que puede derivar en problemas futuros.
Martin Fowler y Kent Beck, reconocidas autoridades en el tema de refactoring, en su libro titulado “Refactoring: Improving the Design of Existing Code”, que fue publicado en 1999, presentaron 22 patrones de diseño deficientes en aplicaciones orientadas a objetos, los cuales denominaron "bad smells in code”, y presentan las posibles guías para removerlos. Lo que no queda muy claro en su libro es cómo detectar estos bad smells. Los autores consideran que lo mejor es la inspección humana para tal fin y afirman:
“En nuestra experiencia ningún conjunto de métricas rivalizan con la información de la intuición humana”.
Los code smells son parecidos a los antipatrones de programación, pero funcionan a un nivel todavía más sutil. No describen situaciones complejas, sino indicios, que son bastante subjetivos y dependientes del lenguaje y las tecnologías concretas.
Los code smells no quieren decir qué hay errores o bugs de programación, ya que pueden no ser técnicamente incorrectos y la aplicación funcione correctamente. Los code smells indican deficiencias en el diseño y pueden hacer que se realice un desarrollo más lento, aumentando el riesgo de errores o fallos en el futuro.
Es difícil definir si el código es malo o bueno, o cuando deberíamos cambiarlo. Un buen desarrollador tiene el “olfato fino” para detectarlos.
Podemos agrupar los codes smells en 5 categorías:
- Bloaters
- Object-Orientation Abusers
- Change Preventers
- Dispensables
- Couplers
Los bloaters son métodos y clases que han aumentado a proporciones tan gigantescas que es difícil trabajar con ellos. Por lo general, estos code smells no surgen de inmediato, sino que se acumulan con el tiempo a medida que la aplicación evoluciona (y especialmente cuando nadie hace un esfuerzo por erradicarlos).
En este grupo también se incluye:
- Primitive Obsession: el uso de primitivas en lugar de objetos pequeños para tareas simples (como moneda, rangos, cadenas especiales para números de teléfono, etc.).
- Long Parameter List: cuando un método tiene más de 4 parámetros.
- Data Clumps: presencia de grupos de datos que aparecen juntos en varias partes del código, lo que indica una baja cohesión y una violación de DRY y el principio de responsabilidad única de SOLID.
Todos estos olores son una aplicación incompleta o incorrecta de los principios de programación orientada a objetos.
- Switch Statements: utilizar las declaraciones switch puede hacer que el código sea más difícil de mantener y actualizar, ya que cada vez que se añade una nueva opción, es necesario modificar el código existente. Esto se puede resolver con la técnica de refactoring Reemplazar condicional con polimorfismo.
- Temporary Field: campos temporales que obtienen sus valores (y, por lo tanto, los objetos los necesitan) solo en determinadas circunstancias. Fuera de estas circunstancias, están vacíos.
- Refused Bequest: cuando una subclase usa solo algunos de los métodos y propiedades heredados de sus padres, lo que indica baja cohesión y una posible violación del principio de sustitución de Liskov.
- Alternative Classes with Different Interfaces: dos clases que realizan funciones idénticas, pero tienen diferentes nombres de métodos. El programador que creó la segunda clase probablemente no sabía que ya existía una clase funcionalmente equivalente.
Estos code smells indican que si se requiere realizar un cambio en una parte del código, es probable que también sea necesario realizar cambios en múltiples lugares del mismo. Como resultado, el desarrollo de la aplicación se vuelve mucho más complicado y costoso.
- Divergent Change: en ocasiones, hay que modificar muchos métodos no relacionados cuando realiza cambios en una clase. Por ejemplo, al agregar un nuevo tipo de producto, se deben cambiar los métodos para buscar, mostrar y ordenar productos.
- Shotgun Surgery: hacer cualquier modificación requiere que se hagan muchos pequeños cambios en muchas clases diferentes.
- Parallel Inheritance Hierarchies: cuando tenemos composición entre dos clases, por ejemplo una clase A contiene la otra clase B y mantienen una relación especial donde la subclase de A depende de la subclase de B. Esto provoca que un cambio a una subclase de B requiere también cambiar la subclase de A correspondiente. Mientras que las dos clases son pequeñas es aceptable, pero con la adición de nuevas clases, los cambios serán cada vez más difíciles de realizar.
Un prescindible es algo sin sentido e innecesario cuya ausencia haría que el código fuera más limpio, más eficiente y más fácil de entender. en esta categoría son por ejemplos los:
- Comments: un código comentado puede considerarse similar a un chiste que necesita ser explicado: no es una práctica recomendable, ya que indica una falta de claridad y comprensión en el código. En general, es preferible escribir un código claro y autoexplicativo, en lugar de confiar en comentarios para explicar su funcionamiento.
- Duplicate Code: duplicación de estructuras completas de código en una aplicación, no respeta DRY.
- Lazy Class: comprender y mantener las clases siempre cuesta tiempo y dinero. Entonces, si una clase no hace lo suficiente para llamar su atención debe eliminarse. Quizás una clase fue diseñada para ser completamente funcional, pero después de algunas de las refactorizaciones, se ha vuelto ridículamente pequeña. O quizás fue diseñado para respaldar el trabajo de desarrollo futuro que nunca se llevó a cabo.
- Data Class: es una clase que contiene datos, pero ninguna o poca lógica para ellos. Es una clase sin responsabilidades. Aquí tengo mis dudas. Yo siempre uso DTOs para pasar datos fuera del dominio funcional, así que hay excepciones.
- Dead Code: cuando una variable, parámetro, campo, método o clase ya no se usa (generalmente, porque está obsoleta).
- Speculative Generality: código innecesario que ha sido creado con anticipación a los cambios futuros del software. Es YAGNI.
Los code smells en este grupo aumentan el acoplamiento entre clases o ilustran los problemas causados por una delegación excesiva:
- Feature Envy: cuando un método accede a los datos de otro objeto más que a sus propios datos.
- Inappropriate Intimacy: una clase usa los campos y métodos internos de otra clase.
- Message Chains: en el código, se observa una cadena de llamadas como
a.getB().getC().getD(), lo que indica una violación de la Ley de Demeter y puede aumentar el acoplamiento entre los objetos.
- Middle Man: una clase que debe realizar solo una acción, la delega a otra clase. ¿Por qué existe?
STUPID code es un acrónimo de los antipatrones de diseño:
- Singleton
- Tight Coupling
- Untestability
- Premature Optimization
- Indescriptive Naming
- Duplication
Veámoslos en detalle.
¿Cómo? ¿El singleton no era un patrón de diseño? Sí, el singleton es probablemente el patrón de diseño más conocido, pero también el más incomprendido.
¿Conocéis el síndrome de singleton? Es que se usa en todas partes. Eso definitivamente no es genial.
Los singleton son controvertidos y, a menudo, se los considera antipatrones. Deberías evitarlos. En realidad, el uso de un singleton no es el problema, sino es síntoma de un problema. Aquí hay dos razones por las que:
- Las aplicaciones que utilizan el estado global son muy difíciles de probar.
- Las aplicaciones que dependen del estado global ocultan sus dependencias.
Pero, ¿debería realmente evitarlos todo el tiempo? Yo diría que sí porque a menudo se puede reemplazar el uso de un singleton por algo mejor.
La única exclusión es la inyección de las dependencias, allí los singleton están gestionados por el contenedor de IoC (Inversión de Control) que se utiliza para implementar la inyección de dependencia.
El acoplamiento estrecho (también conocido como acoplamiento fuerte) es una generalización del problema Singleton. Básicamente, hay que reducir el acoplamiento entre las clases. El acoplamiento es el grado en el que cada clase de la aplicación se basa en cada una de las otras clases.
Si realizar un cambio en una clase de su aplicación requiere que cambie a otra clase, entonces existe acoplamiento. Por ejemplo, crear instancias de objetos en la clase de su constructor en lugar de pasar instancias como argumentos. Eso es malo porque no permite más cambios, cómo reemplazar la instancia por una instancia de una subclase, una mock o lo que sea.
Las clases estrechamente acopladas son difíciles de reutilizar y también difíciles de probar.
Testar una clase no debe ser una tarea difícil. No realmente. La mayoría de las veces, el problema de testar las clases se debe a un acoplamiento estrecho.
No sé quién ha dicho esta frase, pero me encanta:
“Siempre que no escribas unit tests porque no tienes tiempo, el problema real es que tu código es malo.”
La mejor manera de solucionar este problema es practicando TDD.
Optimizar un código que no está terminado puede ser una pérdida de tiempo.
Me encanta la frase de Donald Knuth:
“La optimización prematura es la raíz de todos los males. Solo hay costo y no beneficio”.
En realidad, las aplicaciones optimizadas son mucho más complejas que simplemente reemplazar un bucle for por stream, o usar preincremento en lugar de postincremento. Terminará con un código ilegible. Es por eso que la optimización prematura a menudo se considera un anti-patrón.
Se suele decir que hay dos reglas para optimizar una aplicación:
- no lo hagas;
- (¡solo para expertos!) no lo hagas todavía.
Esto debería ser obvio, pero aún debe decirse: nombre sus clases, métodos, atributos y variables correctamente. ¡Ah, y no abrevie! Escribe código para personas, no para ordenadores. De todos modos, no entienden lo que escribes. Los ordenadores simplemente entienden 0 y 1. Los lenguajes de programación son para humanos.
El código duplicado es malo, así que respeta DRY y KISS. Sea perezoso de la manera correcta: ¡escriba el código solo una vez!
El código con el que trabajamos es como nuestra casa, si no la limpiamos en unos cortos periodos de tiempo, se va a llenar de polvo. La mejor técnica de evitar esto es la refactorización, la cual nos permite mejorar el diseño de software existente sin cambiar su comportamiento externo. Es una práctica importante para garantizar que el código sea mantenible y escalable a largo plazo.
En los siguientes artículos veremos cómo detectar lo feo (los code smells) y aplicar lo bueno: los principios SOLID, las 5 reglas del diseño de software simple y los patrones de diseño con kata de programación.
Cuéntanos qué te parece.