Metodología de Sistemas 2
Análisis y Diseño Avanzado de Sistemas
Esta materia continúa con los conceptos introducidos en Metodología de Sistemas 1, profundizando en técnicas avanzadas de análisis y diseño de sistemas de información empresariales.
Objetivos del Curso
- Dominar metodologías ágiles de desarrollo
- Gestionar proyectos de software complejos
- Aplicar técnicas de análisis de requerimientos avanzadas
- Diseñar arquitecturas de sistemas escalables
Clase 1: Patrones de Diseño
¿Qué es un Patrón de Diseño?
Un patrón de diseño de software es una solución probada y reutilizable para un problema recurrente en el diseño de sistemas orientados a objetos.
Definición formal: “Un patrón de diseño describe un problema que ocurre una y otra vez en nuestro entorno, y luego describe la esencia de su solución, de forma que puede ser utilizada muchas veces.”
Características principales
- Reutilizables: aplicables a múltiples problemas similares.
- Abstracciones de soluciones: no son código específico, sino esquemas o ideas aplicables.
- Documentados: tienen una estructura estándar que facilita su comprensión y aplicación.
Estructura típica de un patrón
- Nombre: etiqueta clara que lo identifica (ej.: Singleton).
- Problema: describe la situación que da origen al patrón.
- Solución: el enfoque general propuesto para resolver el problema.
- Consecuencias: efectos positivos y posibles limitaciones del patrón.
Clasificación de Patrones de Diseño
Los patrones de diseño se clasifican en tres categorías principales:
1. Patrones Creacionales
Estos patrones proporcionan mecanismos de creación de objetos que incrementan la flexibilidad y la reutilización del código existente.
2. Patrones Estructurales
Estos patrones explican cómo ensamblar objetos y clases en estructuras más grandes, mientras se mantiene la flexibilidad y eficiencia de la estructura.
3. Patrones de Comportamiento
Estos patrones tratan con algoritmos y la asignación de responsabilidades entre objetos.
¿Por qué son importantes los Patrones de Diseño?
Ventajas:
- Mejoran la calidad y mantenimiento del código.
- Promueven la reutilización y coherencia en el diseño.
- Ayudan a resolver problemas comunes con soluciones bien comprendidas.
- Facilitan el trabajo en equipo al estandarizar enfoques.
Relación con el desarrollo profesional:
- Muchos problemas reales del desarrollo de software ya han sido resueltos por otros equipos.
- Conocer patrones permite evitar “reinventar la rueda”.
- Son ampliamente utilizados en frameworks, bibliotecas y arquitecturas modernas.
Clase 2: Patrones Creacionales
¿Qué son los Patrones Creacionales?
Los patrones creacionales abordan la manera en la que se crean los objetos en un sistema. Su propósito es desacoplar el proceso de creación del objeto del código que lo utiliza, brindando mayor flexibilidad, control y reutilización en la arquitectura del software.
Beneficios principales:
- Ocultan los detalles de instanciación de los objetos.
- Permiten crear estructuras de objetos más flexibles y dinámicas.
- Facilitan la implementación de arquitecturas orientadas a la expansión (extensibles).
Singleton (Instancia única)
Singleton asegura que una clase tenga una única instancia y proporciona un punto de acceso global a ella.
Casos de uso comunes:
- Conexiones a base de datos.
- Gestión de logs o configuración de la aplicación.
- Sistemas de impresión o recursos compartidos.
Singleton - Problema
El patrón Singleton resuelve dos problemas al mismo tiempo, vulnerando el Principio de responsabilidad única:
1) Garantizar que una clase tenga una única instancia.
¿Por qué querría alguien controlar cuántas instancias tiene una clase? El motivo más habitual es controlar el acceso a algún recurso compartido, por ejemplo, una base de datos o un archivo.
- Funciona así: imagina que has creado un objeto y al cabo de un tiempo decides crear otro nuevo. En lugar de recibir un objeto nuevo, obtendrás el que ya habías creado.
- Ten en cuenta que este comportamiento es imposible de implementar con un constructor normal, ya que una llamada al constructor siempre debe devolver un nuevo objeto por diseño.
Puede ser que los clientes ni siquiera se den cuenta de que trabajan con el mismo objeto todo el tiempo.
Hoy en día el patrón Singleton se ha popularizado tanto que la gente suele llamar singleton a cualquier patrón, incluso si solo resuelve uno de los problemas antes mencionados.
2) Proporcionar un punto de acceso global a dicha instancia.
¿Recuerdas esas variables globales que utilizaste para almacenar objetos esenciales? Aunque son muy útiles, también son poco seguras, ya que cualquier código podría sobrescribir el contenido de esas variables y descomponer la aplicación.
Al igual que una variable global, el patrón Singleton nos permite acceder a un objeto desde cualquier parte del programa. No obstante, también evita que otro código sobreescriba esa instancia.
Este problema tiene otra cara: no queremos que el código que resuelve el primer problema se encuentre disperso por todo el programa. Es mucho más conveniente tenerlo dentro de una clase, sobre todo si el resto del código ya depende de ella.
Singleton - Solución
Todas las implementaciones del patrón Singleton tienen estos dos pasos en común:
- Hacer privado el constructor por defecto para evitar que otros objetos utilicen el operador
newcon la clase Singleton. - Crear un método de creación estático que actúe como constructor. Este método invoca al constructor privado para crear un objeto y lo guarda en un campo estático. Las siguientes llamadas a este método devuelven el objeto almacenado en caché.
Si tu código tiene acceso a la clase Singleton, podrá invocar su método estático. De esta manera, cada vez que se invoque este método, siempre se devolverá el mismo objeto.
Analogía en el mundo real:
El gobierno es un ejemplo excelente del patrón Singleton. Un país sólo puede tener un gobierno oficial. Independientemente de las identidades personales de los individuos que forman el gobierno, el título “Gobierno de X” es un punto de acceso global que identifica al grupo de personas a cargo.
Singleton - Estructura
- La clase Singleton declara el método estático
getInstanceque devuelve la misma instancia de su propia clase. - El constructor del Singleton debe ocultarse del código cliente. La llamada al método
getInstancedebe ser la única manera de obtener el objeto de Singleton.
Singleton - Ventajas y desventajas
Ventajas:
- Control completo sobre la instancia única.
- Facilita el acceso centralizado a ciertos recursos.
Desventajas:
- Puede ocultar dependencias.
- Dificulta las pruebas unitarias si no se gestiona correctamente.
Factory Method (Método fábrica, Constructor virtual)
Factory Method es un patrón de diseño creacional que proporciona una interfaz para crear objetos en una superclase, mientras permite a las subclases alterar el tipo de objetos que se crearán.
Propósito:
- Define una interfaz para crear un objeto, pero permite que las subclases decidan qué clase instanciar.
- Encapsula la creación de objetos dentro de un método.
Ventajas:
- Aumenta la flexibilidad al permitir instanciar distintas clases según el contexto.
- Se integra fácilmente con el principio de “abierto/cerrado” (Open/Closed).
Principio Abierto/Cerrado: Es una regla de diseño de software que establece que las entidades de software (clases, módulos, funciones) deben estar abiertas a la extensión, pero cerradas a la modificación. Esto significa que se puede añadir nuevo comportamiento o funcionalidad al código sin tener que modificar el código existente, lo que promueve sistemas más flexibles, mantenibles y adaptables. Se logra principalmente a través del uso de abstracciones, interfaces y herencia.
Factory Method - Problema
Imagina que estás creando una aplicación de gestión logística. La primera versión de tu aplicación sólo es capaz de manejar el transporte en camión, por lo que la mayor parte de tu código se encuentra dentro de la clase Camión.
Al cabo de un tiempo, tu aplicación se vuelve bastante popular. Cada día recibes decenas de peticiones de empresas de transporte marítimo para que incorpores la logística por mar a la aplicación.
Añadir una nueva clase al programa no es tan sencillo si el resto del código ya está acoplado a clases existentes. En este momento, la mayor parte de tu código está acoplado a la clase Camión. Para añadir barcos a la aplicación habría que hacer cambios en toda la base del código. Además, si más tarde decides añadir otro tipo de transporte a la aplicación, probablemente tendrás que volver a hacer todos estos cambios.
Al final acabarás con un código bastante sucio, plagado de condicionales que cambian el comportamiento de la aplicación dependiendo de la clase de los objetos de transporte.
Factory Method - Solución
El patrón Factory Method sugiere que, en lugar de llamar al operador new para construir objetos directamente, se invoque a un método fábrica (create) especial. No te preocupes: los objetos se siguen creando a través del operador new, pero se invocan desde el método fábrica. Los objetos devueltos por el método fábrica a menudo se denominan productos.
Las subclases pueden alterar la clase de los objetos devueltos por el método fábrica.
A simple vista, puede parecer que este cambio no tiene sentido, ya que tan solo hemos cambiado el lugar desde donde invocamos al constructor. Sin embargo, piensa en esto: ahora puedes sobrescribir el método fábrica en una subclase y cambiar la clase de los productos creados por el método.
No obstante, hay una pequeña limitación: las subclases sólo pueden devolver productos de distintos tipos si dichos productos tienen una clase base o interfaz común. Además, el método fábrica en la clase base debe tener su tipo de retorno declarado como dicha interfaz.
Todos los productos deben seguir la misma interfaz.
Por ejemplo, tanto la clase Camión como la clase Barco deben implementar la interfaz Transporte, que declara un método llamado entrega. Cada clase implementa este método de forma diferente: los camiones entregan su carga por tierra, mientras que los barcos lo hacen por mar.
El método fábrica dentro de la clase LogísticaTerrestre devuelve objetos de tipo camión, mientras que el método fábrica de la clase LogísticaMarítima devuelve barcos.
Siempre y cuando todas las clases de producto implementen una interfaz común, podrás pasar sus objetos al código cliente sin descomponerlo.
El código que utiliza el método fábrica (a menudo denominado código cliente) no encuentra diferencias entre los productos devueltos por varias subclases, y trata a todos los productos como la clase abstracta Transporte. El cliente sabe que todos los objetos de transporte deben tener el método entrega, pero no necesita saber cómo funciona exactamente.
Factory Method - Estructura
-
El Producto declara la interfaz, que es común a todos los objetos que puede producir la clase creadora y sus subclases.
-
Los Productos Concretos son distintas implementaciones de la interfaz de producto.
-
La clase Creadora declara el método fábrica que devuelve nuevos objetos de producto. Es importante que el tipo de retorno de este método coincida con la interfaz de producto. Puedes declarar el patrón Factory Method como abstracto para forzar a todas las subclases a implementar sus propias versiones del método. Como alternativa, el método fábrica base puede devolver algún tipo de producto por defecto.
-
Los Creadores Concretos sobrescriben el Factory Method base, de modo que devuelva un tipo diferente de producto. Observa que el método fábrica no tiene que crear nuevas instancias todo el tiempo. También puede devolver objetos existentes de una memoria caché, una agrupación de objetos, u otra fuente.
Nota:
doStuff()es un nombre genérico de una función o método en diseño de software que indica que la función debe “hacer algo”; no es un término técnico específico sino una forma de indicar que esa parte del código tiene una tarea que realizar sin especificar cuál es esa tarea. Su uso es común como un marcador de posición o un ejemplo de código, donde el desarrollador debe reemplazarlo con la implementación real de la funcionalidad deseada.
Factory Method - ¿Cuándo se puede aplicar?
✓ Cuando no conozcas de antemano las dependencias y los tipos exactos de los objetos con los que deba funcionar tu código.
✓ Cuando quieras ofrecer a los usuarios de tu biblioteca o framework, una forma de extender sus componentes internos.
✓ Cuando quieras ahorrar recursos del sistema mediante la reutilización de objetos existentes en lugar de reconstruirlos cada vez.
Abstract Factory (Fábrica Abstracta)
Abstract Factory - Solución
Lo primero que sugiere el patrón Abstract Factory es que declaremos de forma explícita interfaces para cada producto diferente de la familia de productos (por ejemplo, silla, sofá o mesilla). Después podemos hacer que todas las variantes de los productos sigan esas interfaces. Por ejemplo, todas las variantes de silla pueden implementar la interfaz Silla, así como todas las variantes de mesilla pueden implementar la interfaz Mesilla, y así sucesivamente.
Todas las variantes del mismo objeto deben moverse a una única jerarquía de clase.
El siguiente paso consiste en declarar la Fábrica abstracta: una interfaz con una lista de métodos de creación para todos los productos que son parte de la familia de productos (por ejemplo, crearSilla, crearSofá y crearMesilla). Estos métodos deben devolver productos abstractos representados por las interfaces que extrajimos previamente: Silla, Sofá, Mesilla, etc.
Ahora bien, ¿qué hay de las variantes de los productos? Para cada variante de una familia de productos, creamos una clase de fábrica independiente basada en la interfaz FábricaAbstracta. Una fábrica es una clase que devuelve productos de un tipo particular. Por ejemplo, la FábricadeMueblesModernos sólo puede crear objetos modernos.
El código cliente tiene que funcionar con fábricas y productos a través de sus respectivas interfaces abstractas. Esto nos permite cambiar el tipo de fábrica que pasamos al código cliente, así como la variante del producto que recibe el código cliente, sin descomponer el propio código cliente.
Al cliente no le debe importar la clase concreta de la fábrica con la que funciona. Ya sea un modelo moderno o una silla de estilo victoriano, el cliente debe tratar a todas las sillas del mismo modo, utilizando la interfaz abstracta Silla. Con este sistema, lo único que sabe el cliente sobre la silla es que implementa de algún modo el método sentarse. Además, sea cual sea la variante de silla devuelta, siempre combinará con el tipo de sofá o mesilla producida por el mismo objeto de fábrica.
Abstract Factory - Estructura
-
Los Productos Abstractos declaran interfaces para un grupo de productos diferentes pero relacionados que forman una familia de productos.
-
Los Productos Concretos son implementaciones distintas de productos abstractos agrupados por variantes. Cada producto abstracto (silla/sofá) debe implementarse en todas las variantes dadas (victoriano/moderno).
-
La interfaz Fábrica Abstracta declara un grupo de métodos para crear cada uno de los productos abstractos.
-
Las Fábricas Concretas implementan métodos de creación de la fábrica abstracta. Cada fábrica concreta se corresponde con una variante específica de los productos y crea tan solo dichas variantes de los productos.
-
Aunque las fábricas concretas instancian productos concretos, las firmas de sus métodos de creación deben devolver los productos abstractos correspondientes. De este modo, el código cliente que utiliza una fábrica no se acopla a la variante específica del producto que obtiene de una fábrica. El Cliente puede funcionar con cualquier variante fábrica/producto concreta, siempre y cuando se comunique con sus objetos a través de interfaces abstractas.
Abstract Factory - ¿Cuándo se puede aplicar?
✓ Cuando tu código deba funcionar con varias familias de productos relacionados, pero no desees que dependa de las clases concretas de esos productos, ya que puede ser que no los conozcas de antemano o sencillamente quieras permitir una futura extensibilidad.
✓ Cuando tengas una clase con un grupo de métodos de fábrica que nublen su responsabilidad principal.
Builder (Constructor)
Builder es un patrón de diseño creacional que nos permite construir objetos complejos paso a paso. El patrón nos permite producir distintos tipos y representaciones de un objeto empleando el mismo código de construcción.
Builder - Problema
Imagina un objeto complejo que requiere una inicialización laboriosa, paso a paso, de muchos campos y objetos anidados. Normalmente, este código de inicialización está sepultado dentro de un monstruoso constructor con una gran cantidad de parámetros. O, peor aún: disperso por todo el código cliente.
Crear una subclase por cada configuración posible de un objeto puede complicar demasiado el programa. Por ejemplo, pensemos en cómo crear un objeto Casa. Para construir una casa sencilla, debemos construir cuatro paredes y un piso, así como instalar una puerta, colocar un par de ventanas y ponerle un tejado. Pero ¿qué pasa si quieres una casa más grande y luminosa, con un jardín y otros extras (como sistema de calefacción, instalación de fontanería y cableado eléctrico)?
La solución más sencilla es extender la clase base Casa y crear un grupo de subclases que cubran todas las combinaciones posibles de los parámetros. Pero, en cualquier caso, acabarás con una cantidad considerable de subclases. Cualquier parámetro nuevo, como el estilo del porche, exigirá que incrementes esta jerarquía aún más.
Existe otra posibilidad que no implica generar subclases. Puedes crear un enorme constructor dentro de la clase base Casa con todos los parámetros posibles para controlar el objeto casa. Aunque es cierto que esta solución elimina la necesidad de las subclases, genera otro problema.
Un constructor con un montón de parámetros tiene su inconveniente: no todos los parámetros son necesarios todo el tiempo. En la mayoría de los casos, gran parte de los parámetros no se utilizará, lo que provocará que las llamadas al constructor sean bastante feas. Por ejemplo, solo una pequeña parte de las casas tiene piscina, por lo que los parámetros relacionados con piscinas serán inútiles en nueve de cada diez casos.
Builder - Solución
El patrón Builder sugiere que saques el código de construcción del objeto de su propia clase y lo coloques dentro de objetos independientes llamados constructores. El patrón organiza la construcción de objetos en una serie de pasos (construirParedes, construirPuerta, etc.). Para crear un objeto, se ejecuta una serie de estos pasos en un objeto constructor.
Puede ser que algunos pasos de la construcción necesiten una implementación diferente cuando tengamos que construir distintas representaciones del producto. Por ejemplo, las paredes de una cabaña pueden ser de madera, pero las paredes de un castillo tienen que ser de piedra.
El patrón Builder te permite construir objetos complejos paso a paso. El patrón Builder no permite a otros objetos acceder al producto mientras se construye.
En este caso, podemos crear varias clases constructoras distintas que implementen la misma serie de pasos de construcción, pero de forma diferente. Entonces podemos utilizar estos constructores en el proceso de construcción (por ejemplo, una serie ordenada de llamadas a los pasos de construcción) para producir distintos tipos de objetos.
Los distintos constructores ejecutan la misma tarea de formas distintas. Por ejemplo, imagina un constructor que construye todo de madera y vidrio, otro que construye todo con piedra y hierro y un tercero que utiliza oro y diamantes. Al invocar la misma serie de pasos, obtenemos una casa normal del primer constructor, un pequeño castillo del segundo y un palacio del tercero. Sin embargo, esto sólo funcionaría si el código cliente que invoca los pasos de construcción es capaz de interactuar con los constructores mediante una interfaz común.
Builder - Estructura
-
La interfaz Constructora declara pasos de construcción de producto que todos los tipos de objetos constructores tienen en común.
-
Los Constructores Concretos ofrecen distintas implementaciones de los pasos de construcción. Los constructores concretos pueden crear productos que no siguen la interfaz común.
-
Los Productos son los objetos resultantes. Los productos construidos por distintos objetos constructores no tienen que pertenecer a la misma jerarquía de clases o interfaz.
-
La clase Directora define el orden en el que se invocarán los pasos de construcción, por lo que puedes crear y reutilizar configuraciones específicas de los productos.
-
El Cliente debe asociar uno de los objetos constructores con la clase directora. Normalmente, se hace una sola vez mediante los parámetros del constructor de la clase directora, que utiliza el objeto constructor para el resto de la construcción.
Builder - ¿Cuándo se puede aplicar?
✓ Utiliza el patrón Builder para evitar un “constructor telescópico”. Cuando tengas un constructor con diez parámetros opcionales. Invocar a semejante bestia es poco práctico.
✓ Cuando quieras que el código sea capaz de crear distintas representaciones de ciertos productos (por ejemplo, casas de piedra y madera).
Clase 3: Patrones Estructurales
Fundamentos de los Patrones Estructurales
Estos patrones explican cómo ensamblar objetos y clases en estructuras más grandes, mientras se mantiene la flexibilidad y eficiencia de la estructura. Los patrones estructurales se enfocan en cómo se organizan y componen las clases y objetos para formar estructuras más complejas.
Estos patrones facilitan la reutilización, flexibilizan la estructura del software y reducen el acoplamiento entre componentes. Su propósito principal es simplificar y mejorar la organización interna del código sin alterar su funcionalidad externa.
Adapter (Adaptador)
Adapter es un patrón de diseño estructural que permite la colaboración entre objetos con interfaces incompatibles.
Adapter - Problema
Imagina que estás creando una aplicación de monitoreo del mercado de valores. La aplicación descarga la información de bolsa desde varias fuentes en formato XML para presentarla al usuario con bonitos gráficos y diagramas.
En cierto momento, decides mejorar la aplicación integrando una inteligente biblioteca de análisis de una tercera persona. Pero hay una trampa: la biblioteca de análisis solo funciona con datos en formato JSON.
No puedes utilizar la biblioteca de análisis “tal cual” porque ésta espera los datos en un formato que es incompatible con tu aplicación. Podrías cambiar la biblioteca para que funcione con XML. Sin embargo, esto podría descomponer parte del código existente que depende de la biblioteca. Y, lo que es peor, podrías no tener siquiera acceso al código fuente de la biblioteca, lo que hace imposible esta solución.
Adapter - Solución
Puedes crear un adaptador. Se trata de un objeto especial que convierte la interfaz de un objeto, de forma que otro objeto pueda comprenderla.
Un adaptador envuelve uno de los objetos para esconder la complejidad de la conversión que tiene lugar tras bambalinas. El objeto envuelto ni siquiera es consciente de la existencia del adaptador. Por ejemplo, puedes envolver un objeto que opera con metros y kilómetros con un adaptador que convierte todos los datos al sistema anglosajón, es decir, pies y millas.
Los adaptadores no solo convierten datos a varios formatos, sino que también ayudan a objetos con distintas interfaces a colaborar. Funciona así:
- El adaptador obtiene una interfaz compatible con uno de los objetos existentes.
- Utilizando esta interfaz, el objeto existente puede invocar con seguridad los métodos del adaptador.
- Al recibir una llamada, el adaptador pasa la solicitud al segundo objeto, pero en un formato y orden que ese segundo objeto espera.
En ocasiones se puede incluso crear un adaptador de dos direcciones que pueda convertir las llamadas en ambos sentidos.
Regresemos a nuestra aplicación del mercado de valores. Para resolver el dilema de los formatos incompatibles, puedes crear adaptadores de XML a JSON para cada clase de la biblioteca de análisis con la que trabaje tu código directamente. Después ajustas tu código para que se comunique con la biblioteca únicamente a través de estos adaptadores. Cuando un adaptador recibe una llamada, traduce los datos XML entrantes a una estructura JSON y pasa la llamada a los métodos adecuados de un objeto de análisis envuelto.
Analogía en el mundo real:
Cuando viajas de Europa a Estados Unidos por primera vez, puede ser que te lleves una sorpresa cuanto intentes cargar tu computadora portátil. Los tipos de enchufe son diferentes en cada país, por lo que un enchufe español no sirve en Estados Unidos. El problema puede solucionarse utilizando un adaptador que incluya el enchufe americano y el europeo.
Adapter - Estructura
-
La clase Cliente contiene la lógica de negocio existente del programa.
-
La Interfaz con el Cliente describe un protocolo que otras clases deben seguir para poder colaborar con el código cliente.
-
Servicio es alguna clase útil (normalmente de una tercera parte o heredada). El cliente no puede utilizar directamente esta clase porque tiene una interfaz incompatible.
-
La clase Adaptadora es capaz de trabajar tanto con la clase cliente como con la clase de servicio: implementa la interfaz con el cliente, mientras envuelve el objeto de la clase de servicio. La clase adaptadora recibe llamadas del cliente a través de la interfaz de cliente y las traduce en llamadas al objeto envuelto de la clase de servicio, pero en un formato que pueda comprender.
-
El código cliente no se acopla a la clase adaptadora concreta siempre y cuando funcione con la clase adaptadora a través de la interfaz con el cliente. Gracias a esto, puedes introducir nuevos tipos de adaptadores en el programa sin descomponer el código cliente existente. Esto puede resultar útil cuando la interfaz de la clase de servicio se cambia o sustituye, ya que puedes crear una nueva clase adaptadora sin cambiar el código cliente.
Adapter - ¿Cuándo se puede aplicar?
✓ Utiliza la clase adaptadora cuando quieras usar una clase existente, pero cuya interfaz no sea compatible con el resto del código.
✓ Utiliza el patrón cuando quieras reutilizar varias subclases existentes que carezcan de alguna funcionalidad común que no pueda añadirse a la superclase.
Ventajas:
✓ Permite la integración de clases existentes sin modificarlas.
✓ Promueve la reutilización de código heredado o de terceros.
Bridge (Puente)
Bridge es un patrón de diseño estructural que te permite dividir una clase grande, o un grupo de clases estrechamente relacionadas, en dos jerarquías separadas (abstracción e implementación) que pueden desarrollarse independientemente la una de la otra.
Bridge - Problema
¿Abstracción? ¿Implementación? ¿Asusta? Veamos un ejemplo sencillo.
Digamos que tienes una clase geométrica Forma con un par de subclases: Círculo y Cuadrado. Deseas extender esta jerarquía de clase para que incorpore colores, por lo que planeas crear las subclases de forma Rojo y Azul. Sin embargo, como ya tienes dos subclases, tienes que crear cuatro combinaciones de clase, como CírculoAzul y CuadradoRojo.
El número de combinaciones de clase crece en progresión geométrica. Añadir nuevos tipos de forma y color a la jerarquía hará que ésta crezca exponencialmente. Por ejemplo, para añadir una forma de triángulo deberás introducir dos subclases, una para cada color. Y, después, para añadir un nuevo color habrá que crear tres subclases, una para cada tipo de forma. Cuanto más avancemos, peor será.
Bridge - Solución
Este problema se presenta porque intentamos extender las clases de forma en dos dimensiones independientes: por forma y por color. Es un problema muy habitual en la herencia de clases.
El patrón Bridge intenta resolver este problema pasando de la herencia a la composición del objeto. Esto quiere decir que se extrae una de las dimensiones a una jerarquía de clases separada, de modo que las clases originales referencian un objeto de la nueva jerarquía, en lugar de tener todo su estado y sus funcionalidades dentro de una clase.
Puedes evitar la explosión de una jerarquía de clase transformándola en varias jerarquías relacionadas.
Con esta solución, podemos extraer el código relacionado con el color y colocarlo dentro de su propia clase, con dos subclases: Rojo y Azul. La clase Forma obtiene entonces un campo de referencia que apunta a uno de los objetos de color. Ahora la forma puede delegar cualquier trabajo relacionado con el color al objeto de color vinculado. Esa referencia actuará como un puente entre las clases Forma y Color. En adelante, añadir nuevos colores no exigirá cambiar la jerarquía de forma y viceversa.
Bridge - Estructura
-
La Abstracción ofrece lógica de control de alto nivel. Depende de que el objeto de la implementación haga el trabajo de bajo nivel.
-
La Implementación declara la interfaz común a todas las implementaciones concretas. Una abstracción sólo se puede comunicar con un objeto de implementación a través de los métodos que se declaren aquí. La abstracción puede enumerar los mismos métodos que la implementación, pero normalmente la abstracción declara funcionalidades complejas que dependen de una amplia variedad de operaciones primitivas declaradas por la implementación.
-
Las Implementaciones Concretas contienen código específico de plataforma.
-
Las Abstracciones Refinadas proporcionan variantes de lógica de control. Como sus padres, trabajan con distintas implementaciones a través de la interfaz general de implementación.
-
Normalmente, el Cliente sólo está interesado en trabajar con la abstracción. No obstante, el cliente tiene que vincular el objeto de la abstracción con uno de los objetos de la implementación.
Bridge - ¿Cuándo se puede aplicar?
✓ Utiliza el patrón Bridge cuando quieras dividir y organizar una clase monolítica que tenga muchas variantes de una sola funcionalidad (por ejemplo, si la clase puede trabajar con diversos servidores de bases de datos).
✓ Utiliza el patrón Bridge cuando necesites poder cambiar implementaciones durante el tiempo de ejecución.
✓ Cuando quiera crear clases y aplicaciones independientes de plataforma.
Composite (Objeto compuesto, Object tree)
Composite es un patrón de diseño estructural que te permite componer objetos en estructuras de árbol y trabajar con esas estructuras como si fueran objetos individuales.
Composite - Problema
El uso del patrón Composite sólo tiene sentido cuando el modelo central de tu aplicación puede representarse en forma de árbol.
Por ejemplo, imagina que tienes dos tipos de objetos: Productos y Cajas. Una Caja puede contener varios Productos así como cierto número de Cajas más pequeñas. Estas Cajas pequeñas también pueden contener algunos Productos o incluso Cajas más pequeñas, y así sucesivamente.
Digamos que decides crear un sistema de pedidos que utiliza estas clases. Los pedidos pueden contener productos sencillos sin envolver, así como cajas llenas de productos… y otras cajas. ¿Cómo determinarás el precio total de ese pedido?
Un pedido puede incluir varios productos empaquetados en cajas, que a su vez están empaquetados en cajas más grandes y así sucesivamente. La estructura se asemeja a un árbol boca abajo.
Puedes intentar la solución directa: desenvolver todas las cajas, repasar todos los productos y calcular el total. Esto sería viable en el mundo real; pero en un programa no es tan fácil como ejecutar un bucle. Tienes que conocer de antemano las clases de Productos y Cajas a iterar, el nivel de anidación de las cajas y otros detalles desagradables. Todo esto provoca que la solución directa sea demasiado complicada, o incluso imposible.
Composite - Solución
El patrón Composite sugiere que trabajes con Productos y Cajas a través de una interfaz común que declara un método para calcular el precio total.
¿Cómo funcionaría este método? Para un producto, sencillamente devuelve el precio del producto. Para una caja, recorre cada artículo que contiene la caja, pregunta su precio y devuelve un total por la caja. Si uno de esos artículos fuera una caja más pequeña, esa caja también comenzaría a repasar su contenido y así sucesivamente, hasta que se calcule el precio de todos los componentes internos. Una caja podría incluso añadir costos adicionales al precio final, como costos de empaquetado.
El patrón Composite te permite ejecutar un comportamiento de forma recursiva sobre todos los componentes de un árbol de objetos.
La gran ventaja de esta solución es que no tienes que preocuparte por las clases concretas de los objetos que componen el árbol. No tienes que saber si un objeto es un producto simple o una sofisticada caja. Puedes tratarlos a todos por igual a través de la interfaz común. Cuando invocas un método, los propios objetos pasan la solicitud a lo largo del árbol.
Composite - Estructura
-
La interfaz Componente describe operaciones que son comunes a elementos simples y complejos del árbol.
-
La Hoja es un elemento básico de un árbol que no tiene subelementos. Normalmente, los componentes de la hoja acaban realizando la mayoría del trabajo real, ya que no tienen a nadie a quien delegarle el trabajo.
-
El Contenedor (también llamado compuesto) es un elemento que tiene subelementos: hojas u otros contenedores. Un contenedor no conoce las clases concretas de sus hijos. Funciona con todos los subelementos únicamente a través de la interfaz componente. Al recibir una solicitud, un contenedor delega el trabajo a sus subelementos, procesa los resultados intermedios y devuelve el resultado final al cliente.
-
El Cliente funciona con todos los elementos a través de la interfaz componente. Como resultado, el cliente puede funcionar de la misma manera tanto con elementos simples como complejos del árbol.
Composite - ¿Cuándo se puede aplicar?
✓ Utiliza el patrón Composite cuando tengas que implementar una estructura de objetos con forma de árbol. El patrón Composite te proporciona dos tipos de elementos básicos que comparten una interfaz común: hojas simples y contenedores complejos. Un contenedor puede estar compuesto por hojas y por otros contenedores. Esto te permite construir una estructura de objetos recursivos anidados parecida a un árbol.
✓ Utiliza el patrón cuando quieras que el código cliente trate elementos simples y complejos de la misma forma.
Facade (Fachada)
Facade es un patrón de diseño estructural que proporciona una interfaz simplificada a una biblioteca, un framework o cualquier otro grupo complejo de clases. Proporciona una interfaz simplificada a un conjunto complejo de subsistemas. Es ideal para ocultar la complejidad interna de una librería o módulo y presentar al cliente una forma más sencilla de interactuar.
Facade - Problema
Imagina que debes lograr que tu código trabaje con un amplio grupo de objetos que pertenecen a una sofisticada biblioteca o framework. Normalmente, debes inicializar todos esos objetos, llevar un registro de las dependencias, ejecutar los métodos en el orden correcto y así sucesivamente.
Como resultado, la lógica de negocio de tus clases se vería estrechamente acoplada a los detalles de implementación de las clases de terceros, haciéndola difícil de comprender y mantener.
Facade - Solución
Una fachada es una clase que proporciona una interfaz simple a un subsistema complejo que contiene muchas partes móviles. Una fachada puede proporcionar una funcionalidad limitada en comparación con trabajar directamente con el subsistema. Sin embargo, tan solo incluye las funciones realmente importantes para los clientes.
Tener una fachada resulta útil cuando tienes que integrar tu aplicación con una biblioteca sofisticada con decenas de funciones, de la cual sólo necesitas una pequeña parte.
Por ejemplo, una aplicación que sube breves vídeos divertidos de gatos a las redes sociales, podría potencialmente utilizar una biblioteca de conversión de vídeo profesional. Sin embargo, lo único que necesita en realidad es una clase con el método simple codificar(nombreDelArchivo, formato). Una vez que crees dicha clase y la conectes con la biblioteca de conversión de vídeo, tendrás tu primera fachada.
Facade - Analogía en el mundo real
Haciendo pedidos por teléfono.
Cuando llamas a una tienda para hacer un pedido por teléfono, un operador es tu fachada a todos los servicios y departamentos de la tienda. El operador te proporciona una sencilla interfaz de voz al sistema de pedidos, pasarelas de pago y varios servicios de entrega.
Facade - Estructura
-
El patrón Facade proporciona un práctico acceso a una parte específica de la funcionalidad del subsistema. Sabe a dónde dirigir la petición del cliente y cómo operar todas las partes móviles.
-
Puede crearse una clase Fachada Adicional para evitar contaminar una única fachada con funciones no relacionadas que podrían convertirla en otra estructura compleja. Las fachadas adicionales pueden utilizarse por clientes y por otras fachadas.
-
El Subsistema Complejo consiste en decenas de objetos diversos. Para lograr que todos hagan algo significativo, debes profundizar en los detalles de implementación del subsistema, que pueden incluir inicializar objetos en el orden correcto y suministrarles datos en el formato adecuado. Las clases del subsistema no conocen la existencia de la fachada. Operan dentro del sistema y trabajan entre sí directamente.
-
El Cliente utiliza la fachada en lugar de invocar directamente los objetos del subsistema.
Facade - ¿Cuándo se puede aplicar?
✓ Utiliza el patrón Facade cuando necesites una interfaz limitada pero directa a un subsistema complejo.
✓ Utiliza el patrón Facade cuando quieras estructurar un subsistema en capas.
Decorator (Decorador, Wrapper, Envoltorio)
Decorator es un patrón de diseño estructural que te permite añadir funcionalidades a objetos colocando estos objetos dentro de objetos encapsuladores especiales que contienen estas funcionalidades. Permite añadir funcionalidades a un objeto de manera dinámica, sin modificar su clase original. Se basa en el principio de composición en lugar de herencia.
Decorator - Problema
Imagina que estás trabajando en una biblioteca de notificaciones que permite a otros programas notificar a sus usuarios acerca de eventos importantes.
La versión inicial de la biblioteca se basaba en la clase Notificador que solo contaba con unos cuantos campos, un constructor y un único método send. El método podía aceptar un argumento de mensaje de un cliente y enviar el mensaje a una lista de correos electrónicos que se pasaban a la clase notificadora a través de su constructor. Una aplicación de un tercero que actuaba como cliente debía crear y configurar el objeto notificador una vez y después utilizarlo cada vez que sucediera algo importante.
En cierto momento te das cuenta de que los usuarios de la biblioteca esperan algo más que unas simples notificaciones por correo. A muchos de ellos les gustaría recibir mensajes SMS sobre asuntos importantes. Otros querrían recibir las notificaciones por Facebook y, por supuesto, a los usuarios corporativos les encantaría recibir notificaciones por Slack.
No puede ser muy complicado, extendiste la clase Notificador y metiste los métodos adicionales de notificación dentro de nuevas subclases. Ahora el cliente debería instanciar la clase notificadora deseada y utilizarla para el resto de notificaciones.
Pero entonces alguien te hace una pregunta razonable: “¿Por qué no se pueden utilizar varios tipos de notificación al mismo tiempo? Si tu casa está en llamas, probablemente quieras que te informen a través de todos los canales”.
Intentaste solucionar ese problema creando subclases especiales que combinaban varios métodos de notificación dentro de una clase. Sin embargo, enseguida resultó evidente que esta solución inflaría el código en gran medida, no sólo el de la biblioteca, sino también el código cliente. Se debe encontrar otra forma de estructurar las clases de las notificaciones para no alcanzar cifras muy grandes.
Herencia vs. Agregación
Cuando tenemos que alterar la funcionalidad de un objeto, lo primero que se viene a la mente es extender una clase. No obstante, la herencia tiene varias limitaciones importantes de las que debes ser consciente:
- La herencia es estática. No se puede alterar la funcionalidad de un objeto existente durante el tiempo de ejecución. Sólo se puede sustituir el objeto completo por otro creado a partir de una subclase diferente.
- Las subclases sólo pueden tener una clase padre. En la mayoría de lenguajes, la herencia no permite a una clase heredar comportamientos de varias clases al mismo tiempo.
Una de las formas de superar estas limitaciones es empleando la Agregación o la Composición en lugar de la Herencia. Ambas alternativas funcionan prácticamente del mismo modo: un objeto tiene una referencia a otro y le delega parte del trabajo, mientras que con la herencia, el propio objeto puede realizar ese trabajo, heredando el comportamiento de su superclase.
“Wrapper” (envoltorio, en inglés) es el sobrenombre alternativo del patrón Decorator, que expresa claramente su idea principal. Un wrapper es un objeto que puede vincularse con un objeto objetivo. El wrapper contiene el mismo grupo de métodos que el objetivo y le delega todas las solicitudes que recibe. No obstante, el wrapper puede alterar el resultado haciendo algo antes o después de pasar la solicitud al objetivo.
Decorator - Solución
¿Cuándo se convierte un simple wrapper en el verdadero decorador? El wrapper implementa la misma interfaz que el objeto envuelto. Éste es el motivo por el que, desde la perspectiva del cliente, estos objetos son idénticos. Haz que el campo de referencia del wrapper acepte cualquier objeto que siga esa interfaz. Esto te permitirá envolver un objeto en varios wrappers, añadiéndole el comportamiento combinado de todos ellos.
En nuestro ejemplo de las notificaciones, dejemos la sencilla funcionalidad de las notificaciones por correo electrónico dentro de la clase base Notificador, pero convirtamos el resto de los métodos de notificación en decoradores.
Decorator - Analogía en el mundo real
Obtienes un efecto combinado vistiendo varias prendas de ropa.
Vestir ropa es un ejemplo del uso de decoradores. Cuando tienes frío, te cubres con un suéter. Si sigues teniendo frío a pesar del suéter, puedes ponerte una chaqueta encima. Si está lloviendo, puedes ponerte un impermeable. Todas estas prendas “extienden” tu comportamiento básico pero no son parte de ti, y puedes quitarte fácilmente cualquier prenda cuando lo desees.
Decorator - Estructura
-
El Componente declara la interfaz común tanto para wrappers como para objetos envueltos.
-
Componente Concreto es una clase de objetos envueltos. Define el comportamiento básico, que los decoradores pueden alterar.
-
La clase Decoradora Base tiene un campo para referenciar un objeto envuelto. El tipo del campo debe declararse como la interfaz del componente para que pueda contener tanto los componentes concretos como los decoradores. La clase decoradora base delega todas las operaciones al objeto envuelto.
-
Los Decoradores Concretos definen funcionalidades adicionales que se pueden añadir dinámicamente a los componentes. Los decoradores concretos sobrescriben métodos de la clase decoradora base y ejecutan su comportamiento, ya sea antes o después de invocar al método padre.
Decorator - ¿Cuándo se puede aplicar?
✓ Utiliza el patrón Decorator cuando necesites asignar funcionalidades adicionales a objetos durante el tiempo de ejecución sin descomponer el código que utiliza esos objetos.
✓ Utiliza el patrón cuando resulte extraño o no sea posible extender el comportamiento de un objeto utilizando la herencia.
Ventajas:
• Evita clases hijas innecesarias.
• Proporciona gran flexibilidad en la extensión de comportamiento.
Flyweight (Peso mosca, Peso ligero, Cache)
Flyweight es un patrón de diseño estructural que te permite mantener más objetos dentro de la cantidad disponible de RAM compartiendo las partes comunes del estado entre varios objetos en lugar de mantener toda la información en cada objeto.
Flyweight - Problema
Decides crear un sencillo videojuego en el que los jugadores se tienen que mover por un mapa disparándose entre sí. Decides implementar un sistema de partículas realistas que lo distinga de otros juegos. Grandes cantidades de balas, misiles y metralla de las explosiones volarán por todo el mapa, ofreciendo una apasionante experiencia al jugador.
Al terminarlo, subes el último cambio, compilas el juego y se lo envias a un amigo para una partida de prueba. Aunque el juego funcionaba sin problemas en tu máquina, tu amigo no logró jugar durante mucho tiempo. En su computadora el juego se paraba a los pocos minutos de empezar.
Tras dedicar varias horas a revisar los registros de depuración, descubres que el juego se paraba debido a una cantidad insuficiente de RAM. Resulta que el equipo de tu amigo es mucho menos potente que tu computadora, y esa es la razón por la que el problema surgió tan rápido en su máquina.
El problema estaba relacionado con tu sistema de partículas. Cada partícula, como una bala, un misil o un trozo de metralla, estaba representada por un objeto separado que contenía gran cantidad de datos. En cierto momento, cuando la masacre alcanzaba su punto culminante en la pantalla del jugador, las partículas recién creadas ya no cabían en el resto de RAM, provocando que el programa fallara.
Estado intrínseco vs Estado extrínseco
Observando más atentamente la clase Partícula, puede ser que te hayas dado cuenta de que los campos de color y sprite consumen mucha más memoria que otros campos. Lo que es peor, esos dos campos almacenan información casi idéntica de todas las partículas. Por ejemplo, todas las balas tienen el mismo color y sprite.
Otras partes del estado de una partícula, como las coordenadas, vector de movimiento y velocidad, son únicas en cada partícula. Después de todo, los valores de estos campos cambian a lo largo del tiempo. Estos datos representan el contexto siempre cambiante en el que existe la partícula, mientras que el color y el sprite se mantienen constantes.
Esta información constante de un objeto suele denominarse su estado intrínseco. Existe dentro del objeto y otros objetos únicamente pueden leerla, no cambiarla. El resto del estado del objeto, a menudo alterado “desde el exterior” por otros objetos, se denomina el estado extrínseco.
Flyweight - Solución
El patrón Flyweight sugiere que dejemos de almacenar el estado extrínseco dentro del objeto. En lugar de eso, debes pasar este estado a métodos específicos que dependen de él. Tan solo el estado intrínseco se mantiene dentro del objeto, permitiendo que lo reutilices en distintos contextos. Como resultado, necesitarás menos de estos objetos, ya que sólo se diferencian en el estado intrínseco, que cuenta con muchas menos variaciones que el extrínseco.
Regresemos a nuestro juego. Dando por hecho que hemos extraído el estado extrínseco de la clase de nuestra partícula, únicamente tres objetos diferentes serán suficientes para representar todas las partículas del juego: una bala, un misil y un trozo de metralla. Como probablemente habrás adivinado, un objeto que sólo almacena el estado intrínseco se denomina Flyweight (peso mosca).
Almacenamiento del estado extrínseco:
¿A dónde se mueve el estado extrínseco? Alguna clase tendrá que almacenarlo, en la mayoría de los casos, se mueve al objeto contenedor, que reúne objetos antes de que apliquemos el patrón.
En nuestro caso, se trata del objeto principal Juego, que almacena todas las partículas en su campo partículas. Para mover el estado extrínseco a esta clase, debes crear varios campos matriz para almacenar coordenadas, vectores y velocidades de cada partícula individual. Pero eso no es todo. Necesitas otra matriz para almacenar referencias a un objeto flyweight específico que represente una partícula. Estas matrices deben estar sincronizadas para que puedas acceder a toda la información de una partícula utilizando el mismo índice.
Una solución más elegante sería crear una clase de contexto separada que almacene el estado extrínseco junto con la referencia al objeto flyweight. Esta solución únicamente exigiría tener una matriz en la clase contenedora.
Flyweight - Estructura
-
El patrón Flyweight es simplemente una optimización. Antes de aplicarlo, asegúrate de que tu programa tenga un problema de consumo de RAM provocado por tener una gran cantidad de objetos similares en la memoria al mismo tiempo. Asegúrate de que este problema no se pueda solucionar de otra forma sensata.
-
La clase Flyweight contiene la parte del estado del objeto original que pueden compartir varios objetos. El mismo objeto flyweight puede utilizarse en muchos contextos diferentes. El estado almacenado dentro de un objeto flyweight se denomina intrínseco, mientras que al que se pasa a sus métodos se le llama extrínseco.
-
La clase Contexto contiene el estado extrínseco, único en todos los objetos originales. Cuando un contexto se empareja con uno de los objetos flyweight, representa el estado completo del objeto original.
-
Normalmente, el comportamiento del objeto original permanece en la clase flyweight. En este caso, quien invoque un método del objeto flyweight debe también pasar las partes adecuadas del estado extrínseco dentro de los parámetros del método. Por otra parte, el comportamiento se puede mover a la clase de contexto, que utilizará el objeto flyweight vinculado como mero objeto de datos.
-
El Cliente calcula o almacena el estado extrínseco de los objetos flyweight. Desde la perspectiva del cliente, un flyweight es un objeto plantilla que puede configurarse durante el tiempo de ejecución pasando información contextual dentro de los parámetros de sus métodos.
-
La Fábrica flyweight gestiona un grupo de objetos flyweight existentes. Con la fábrica, los clientes no crean objetos flyweight directamente. En lugar de eso, invocan a la fábrica, pasándole partes del estado intrínseco del objeto flyweight deseado. La fábrica revisa objetos flyweight creados previamente y devuelve uno existente que coincida con los criterios de búsqueda, o bien crea uno nuevo si no encuentra nada.
Flyweight - Ventajas y desventajas
Ventajas:
✓ Puedes ahorrar mucha RAM, siempre que tu programa tenga toneladas de objetos similares.
Desventajas:
✗ Puede que estés cambiando RAM por ciclos CPU cuando deba calcularse de nuevo parte de la información de contexto cada vez que alguien invoque un método flyweight.
✗ El código se complica mucho. Los nuevos miembros del equipo siempre estarán preguntándose por qué el estado de una entidad se separó de tal manera.
Proxy (Apoderado)
Proxy - Problema
Imagina que tienes un objeto enorme que consume una gran cantidad de recursos del sistema. Lo necesitas de vez en cuando, pero no siempre.
Puedes llevar a cabo una implementación diferida, es decir, crear este objeto sólo cuando sea realmente necesario. Todos los clientes del objeto tendrán que ejecutar algún código de inicialización diferida. Lamentablemente, esto seguramente generará una gran cantidad de código duplicado.
En un mundo ideal, querríamos meter este código directamente dentro de la clase de nuestro objeto, pero eso no siempre es posible. Por ejemplo, la clase puede ser parte de una biblioteca cerrada de un tercero.
Proxy - Solución
El patrón Proxy sugiere que crees una nueva clase proxy con la misma interfaz que un objeto de servicio original. Después actualizas tu aplicación para que pase el objeto proxy a todos los clientes del objeto original. Al recibir una solicitud de un cliente, el proxy crea un objeto de servicio real y le delega todo el trabajo.
Pero, ¿cuál es la ventaja? Si necesitas ejecutar algo antes o después de la lógica primaria de la clase, el proxy te permite hacerlo sin cambiar esa clase. Ya que el proxy implementa la misma interfaz que la clase original, puede pasarse a cualquier cliente que espere un objeto de servicio real.
Proxy - Analogía con el mundo real
Una tarjeta de crédito es un proxy de una cuenta bancaria, que, a su vez, es un proxy de un manojo de billetes. Ambos implementan la misma interfaz, por lo que pueden utilizarse para realizar un pago. El consumidor se siente genial porque no necesita llevar un montón de efectivo encima. El dueño de la tienda también está contento porque los ingresos de la transacción se añaden electrónicamente a la cuenta bancaria de la tienda sin el riesgo de perder el depósito o sufrir un robo de camino al banco.
Proxy - Estructura
-
La Interfaz de Servicio declara la interfaz del Servicio. El proxy debe seguir esta interfaz para poder camuflarse como objeto de servicio.
-
Servicio es una clase que proporciona una lógica de negocio útil.
-
La clase Proxy tiene un campo de referencia que apunta a un objeto de servicio. Cuando el proxy finaliza su procesamiento (por ejemplo, inicialización diferida, registro, control de acceso, almacenamiento en caché, etc.), pasa la solicitud al objeto de servicio. Normalmente los proxies gestionan el ciclo de vida completo de sus objetos de servicio.
-
El Cliente debe funcionar con servicios y proxies a través de la misma interfaz. De este modo puedes pasar un proxy a cualquier código que espere un objeto de servicio.
Proxy - Ventajas y desventajas
Ventajas:
✓ Puedes controlar el objeto de servicio sin que los clientes lo sepan.
✓ Puedes gestionar el ciclo de vida del objeto de servicio cuando a los clientes no les importa.
✓ El proxy funciona incluso si el objeto de servicio no está listo o no está disponible.
✓ Principio de abierto/cerrado. Puedes introducir nuevos proxies sin cambiar el servicio o los clientes.
Desventajas:
✗ El código puede complicarse ya que debes introducir gran cantidad de clases nuevas.
✗ La respuesta del servicio puede retrasarse.
Comparación entre los patrones estructurales
| Patrón | Propósito | Ejemplo típico |
|---|---|---|
| Adapter | Adaptar interfaces incompatibles | Adaptador USB |
| Composite | Tratar objetos individuales y compuestos de igual forma | Árbol de directorios |
| Decorator | Agregar responsabilidades dinámicamente | Café con leche y azúcar |
| Facade | Simplificar el acceso a sistemas complejos | Control remoto de TV |
Buenas prácticas al usar patrones estructurales
- Usar Adapter para integrar código heredado o externo.
- Aplicar Composite cuando haya estructuras recursivas jerárquicas.
- Preferir Decorator antes que heredar múltiples combinaciones de clases.
- Implementar Facade en sistemas con múltiples dependencias internas.
Clase 4
Fundamentos de los Patrones de Comportamiento
Patrones de comportamiento
Estos patrones tratan con algoritmos y la asignación de responsabilidades entre objetos. Los patrones de comportamiento definen cómo interactúan los objetos entre sí y cómo se reparten las responsabilidades. Se enfocan en facilitar la comunicación, colaboración y flexibilidad entre componentes sin acoplarlos fuertemente. Permiten modificar el comportamiento del sistema sin alterar su estructura.
Observer (Observador)
Observer es un patrón de diseño de comportamiento que te permite definir un mecanismo de suscripción para notificar a varios objetos sobre cualquier evento que le suceda al objeto que están observando.
Permite que múltiples objetos (observadores) reciban una notificación automática cuando otro objeto (sujeto) cambia su estado. Es un patrón ideal para sistemas de eventos, interfaces gráficas o sistemas de notificaciones.
Participantes del patrón:
- Subject (Sujeto): objeto observado que mantiene una lista de observadores y les notifica cambios.
- Observer (Observador): objeto que quiere recibir las actualizaciones del sujeto.
Observer - Problema
Imagina que tienes dos tipos de objetos: un objeto Cliente y un objeto Tienda. El cliente está muy interesado en una marca particular de producto (digamos, un nuevo modelo de iPhone) que estará disponible en la tienda muy pronto.
El cliente puede visitar la tienda cada día para comprobar la disponibilidad del producto. Pero, mientras el producto está en camino, la mayoría de estos viajes serán en vano.
Por otro lado, la tienda podría enviar cientos de correos (lo cual se podría considerar spam) a todos los clientes cada vez que hay un nuevo producto disponible. Esto ahorraría a los clientes los interminables viajes a la tienda, pero, al mismo tiempo, molestaría a otros clientes que no están interesados en los nuevos productos.
Nos encontramos ante un conflicto. O el cliente pierde tiempo comprobando la disponibilidad del producto, o bien la tienda desperdicia recursos notificando a los clientes equivocados.
Observer - Solución
El objeto que tiene un estado interesante suele denominarse sujeto, pero, como también va a notificar a otros objetos los cambios en su estado, le llamaremos notificador (en ocasiones también llamado publicador). El resto de los objetos que quieren conocer los cambios en el estado del notificador, se denominan suscriptores.
Un mecanismo de suscripción permite a los objetos individuales suscribirse a notificaciones de eventos. El patrón Observer sugiere que añadas un mecanismo de suscripción a la clase notificadora para que los objetos individuales puedan suscribirse o cancelar su suscripción a un flujo de eventos que proviene de esa notificadora.
No es tan complicado como parece. En realidad, este mecanismo consiste en:
- Un campo matriz para almacenar una lista de referencias a objetos suscriptores
- Varios métodos públicos que permiten añadir suscriptores y eliminarlos de esa lista
Ahora, cuando le sucede un evento importante al notificador, recorre sus suscriptores y llama al método de notificación específico de sus objetos.
Las aplicaciones reales pueden tener decenas de clases suscriptoras diferentes interesadas en seguir los eventos de la misma clase notificadora. No querrás acoplar la notificadora a todas esas clases. Además, puede que no conozcas algunas de ellas de antemano si se supone que otras personas pueden utilizar tu clase notificadora.
Por eso es fundamental que todos los suscriptores implementen la misma interfaz y que el notificador únicamente se comunique con ellos a través de esa interfaz. Esta interfaz debe declarar el método de notificación junto con un grupo de parámetros que el notificador puede utilizar para pasar cierta información contextual con la notificación.
Observer - Analogía en el mundo real
Suscripciones a revistas y periódicos.
Si te suscribes a un periódico o una revista, ya no necesitarás ir a la tienda a comprobar si el siguiente número está disponible. En lugar de eso, el notificador envía nuevos números directamente a tu buzón justo después de la publicación, o incluso antes.
El notificador mantiene una lista de suscriptores y sabe qué revistas les interesan. Los suscriptores pueden abandonar la lista en cualquier momento si quieren que el notificador deje de enviarles nuevos números.
Observer - Estructura
-
El Notificador envía eventos de interés a otros objetos. Esos eventos ocurren cuando el notificador cambia su estado o ejecuta algunos comportamientos. Los notificadores contienen una infraestructura de suscripción que permite a nuevos y antiguos suscriptores abandonar la lista.
-
Cuando sucede un nuevo evento, el notificador recorre la lista de suscripción e invoca el método de notificación declarado en la interfaz suscriptora en cada objeto suscriptor.
-
La interfaz Suscriptora declara la interfaz de notificación. En la mayoría de los casos, consiste en un único método actualizar. El método puede tener varios parámetros que permitan al notificador pasar algunos detalles del evento junto a la actualización.
-
Los Suscriptores Concretos realizan algunas acciones en respuesta a las notificaciones emitidas por el notificador. Todas estas clases deben implementar la misma interfaz de forma que el notificador no esté acoplado a clases concretas.
-
Normalmente, los suscriptores necesitan cierta información contextual para manejar correctamente la actualización. Por este motivo, a menudo los notificadores pasan cierta información de contexto como argumentos del método de notificación. El notificador puede pasarse a sí mismo como argumento, dejando que los suscriptores extraigan la información necesaria directamente.
-
El Cliente crea objetos tipo notificador y suscriptor por separado y después registra a los suscriptores para las actualizaciones del notificador.
Observer - Ventajas y desventajas
Ventajas:
✓ Promueve desacoplamiento entre el emisor y los receptores de eventos.
✓ Permite flexibilidad y escalabilidad al agregar o quitar observadores dinámicamente.
Desventajas:
✗ Puede volverse complejo si hay muchos observadores.
✗ La gestión de dependencias debe ser cuidadosa.
Strategy (Estrategia)
Strategy es un patrón de diseño de comportamiento que te permite definir una familia de algoritmos, colocar cada uno de ellos en una clase separada y hacer sus objetos intercambiables.
Permite definir una familia de algoritmos, encapsular cada uno y hacerlos intercambiables en tiempo de ejecución, sin cambiar el código del cliente. Es útil cuando queremos elegir entre múltiples variantes de comportamiento sin usar estructuras condicionales complejas.
Referencias:
- Contexto: mantiene una referencia a un objeto Strategy.
- Strategy (estrategia): interfaz común para los algoritmos.
- ConcreteStrategy: implementación específica del algoritmo.
Strategy - Problema
Un día decidiste crear una aplicación de navegación para viajeros ocasionales. La aplicación giraba alrededor de un bonito mapa que ayudaba a los usuarios a orientarse rápidamente en cualquier ciudad.
Una de las funciones más solicitadas para la aplicación era la planificación automática de rutas. Un usuario debía poder introducir una dirección y ver la ruta más rápida a ese destino mostrado en el mapa.
El código del navegador se saturó.
La primera versión de la aplicación sólo podía generar las rutas sobre carreteras. Las personas que viajaban en coche estaban locas de alegría. Pero, no a todo el mundo le gusta conducir durante sus vacaciones. De modo que, en la siguiente actualización, añadiste una opción para crear rutas a pie. Después, añadiste otra opción para permitir a las personas utilizar el transporte público en sus rutas.
Sin embargo, esto era sólo el principio. Más tarde planeaste añadir la generación de rutas para ciclistas, y más tarde, otra opción para trazar rutas por todas las atracciones turísticas de una ciudad.
Cada vez que añadías un nuevo algoritmo de enrutamiento, la clase principal del navegador doblaba su tamaño y se volvió demasiado difícil de mantener. Cualquier cambio en alguno de los algoritmos, afecta a toda la clase, aumentando las probabilidades de crear un error en un código ya funcional.
Strategy - Solución
El patrón Strategy sugiere que tomes esa clase que hace algo específico de muchas formas diferentes y extraigas todos esos algoritmos para colocarlos en clases separadas llamadas estrategias.
La clase original, llamada contexto, debe tener un campo para almacenar una referencia a una de las estrategias. El contexto delega el trabajo a un objeto de estrategia vinculado en lugar de ejecutarlo por su cuenta.
Estrategia de planificación de rutas.
La clase contexto no es responsable de seleccionar un algoritmo adecuado para la tarea. En lugar de eso, el cliente pasa la estrategia deseada a la clase contexto. De hecho, la clase contexto no sabe mucho acerca de las estrategias. Funciona con todas las estrategias a través de la misma interfaz genérica, que sólo expone un único método para disparar el algoritmo encapsulado dentro de la estrategia seleccionada.
De esta forma, el contexto se vuelve independiente de las estrategias concretas, así que puedes añadir nuevos algoritmos o modificar los existentes sin cambiar el código de la clase contexto o de otras estrategias.
Aplicación práctica:
En nuestra aplicación de navegación, cada algoritmo de enrutamiento puede extraerse y ponerse en su propia clase con un único método crearRuta. El método acepta un origen y un destino y devuelve una colección de puntos de control de la ruta.
Incluso contando con los mismos argumentos, cada clase de enrutamiento puede crear una ruta diferente. A la clase navegadora principal no le importa qué algoritmo se selecciona ya que su labor principal es representar un grupo de puntos de control en el mapa. La clase tiene un método para cambiar la estrategia activa de enrutamiento, de modo que sus clientes, como los botones en la interfaz de usuario, pueden sustituir el comportamiento seleccionado de enrutamiento por otro.
Strategy - Analogía en el mundo real
Imagina que tienes que llegar al aeropuerto. Puedes tomar el autobús, pedir un taxi o ir en bicicleta. Éstas son tus estrategias de transporte. Puedes elegir una de las estrategias, dependiendo de factores como el presupuesto o los límites de tiempo.
Ejemplo cotidiano:
Un usuario elige cómo ordenar una lista de productos: por precio, por nombre o por popularidad.
Strategy - Estructura
-
La clase Contexto mantiene una referencia a una de las estrategias concretas y se comunica con este objeto únicamente a través de la interfaz estrategia.
-
La interfaz Estrategia es común a todas las estrategias concretas. Declara un método que la clase contexto utiliza para ejecutar una estrategia.
-
Las Estrategias Concretas implementan distintas variaciones de un algoritmo que la clase contexto utiliza.
-
La clase contexto invoca el método de ejecución en el objeto de estrategia vinculado cada vez que necesita ejecutar el algoritmo. La clase contexto no sabe con qué tipo de estrategia funciona o cómo se ejecuta el algoritmo.
-
El Cliente crea un objeto de estrategia específico y lo pasa a la clase contexto. La clase contexto expone un modificador set que permite a los clientes sustituir la estrategia asociada al contexto durante el tiempo de ejecución.
Strategy - ¿Cuándo se puede aplicar?
✓ Utiliza el patrón Strategy cuando quieras utilizar distintas variantes de un algoritmo dentro de un objeto y poder cambiar de un algoritmo a otro durante el tiempo de ejecución.
✓ Utiliza el patrón Strategy cuando tengas muchas clases similares que sólo se diferencien en la forma en que ejecutan cierto comportamiento.
✓ Utiliza el patrón cuando tu clase tenga un enorme operador condicional que cambie entre distintas variantes del mismo algoritmo.
Strategy - Ventajas y desventajas
Ventajas:
✓ Elimina estructuras condicionales complejas (if/else, switch).
✓ Promueve el principio de abierto/cerrado: se pueden agregar estrategias sin modificar código existente.
✓ Facilita pruebas unitarias y separación de responsabilidades.
Desventajas:
✗ Puede generar muchas clases.
✗ Aumenta la complejidad si no se necesita la flexibilidad.
Command (Comando, Orden)
Command es un patrón de diseño de comportamiento que convierte una solicitud en un objeto independiente que contiene toda la información sobre la solicitud. Esta transformación te permite parametrizar los métodos con diferentes solicitudes, retrasar o poner en cola la ejecución de una solicitud y soportar operaciones que no se pueden realizar.
Encapsula una solicitud, acción o comando como un objeto, lo que permite parametrizar clientes, gestionar acciones en cola, deshacer operaciones, etc. Ideal para sistemas con operaciones reversibles, menús, botones de interfaz gráfica, tareas programadas.
Participantes del patrón:
- Command (comando): interfaz con un método ejecutar().
- ConcreteCommand: implementación específica del comando.
- Invoker (invocador): objeto que lanza el comando.
- Receiver (receptor): objeto que ejecuta la acción real.
Command - Problema
Todos los botones de la aplicación provienen de la misma clase.
Imagina que estás trabajando en una nueva aplicación de edición de texto. Tu tarea actual consiste en crear una barra de herramientas con unos cuantos botones para varias operaciones del editor. Creaste una clase Botón muy limpia que puede utilizarse para los botones de la barra de herramientas y también para botones genéricos en diversos diálogos.
Aunque todos estos botones se parecen, se supone que hacen cosas diferentes. ¿Dónde pondrías el código para los varios gestores de clics de estos botones? La solución más simple consiste en crear cientos de subclases para cada lugar donde se utilice el botón.
Estas subclases contendrían el código que deberá ejecutarse con el clic en un botón.
El problema se agrava:
Pronto te das cuenta de que esta solución es muy deficiente. En primer lugar, tienes una enorme cantidad de subclases, lo cual no supondría un problema si no corrieras el riesgo de descomponer el código de esas subclases cada vez que modifiques la clase base Botón. Dicho de forma sencilla, tu código GUI depende torpemente del volátil código de la lógica de negocio.
Y aquí está la parte más desagradable. Algunas operaciones, como copiar/pegar texto, deben ser invocadas desde varios lugares. Por ejemplo, un usuario podría hacer clic en un pequeño botón “Copiar” de la barra de herramientas, o copiar algo a través del menú contextual, o pulsar Ctrl+C en el teclado.
Inicialmente, cuando tu aplicación solo tenía la barra de herramientas, no había problema en colocar la implementación de varias operaciones dentro de las subclases de botón. En otras palabras, tener el código para copiar texto dentro de la subclase BotónCopiar estaba bien. Sin embargo, cuando implementas menús contextuales, atajos y otros elementos, debes duplicar el código de la operación en muchas clases, o bien hacer menús dependientes de los botones, lo cual es una opción aún peor.
Command - Solución
El buen diseño de software a menudo se basa en el principio de separación de responsabilidades, lo que suele tener como resultado la división de la aplicación en capas. El ejemplo más habitual es tener una capa para la interfaz gráfica de usuario (GUI) y otra capa para la lógica de negocio.
La capa GUI es responsable de representar una bonita imagen en pantalla, capturar entradas y mostrar resultados de lo que el usuario y la aplicación están haciendo. Sin embargo, cuando se trata de hacer algo importante, como calcular la trayectoria de la luna o componer un informe anual, la capa GUI delega el trabajo a la capa subyacente de la lógica de negocio.
Los objetos GUI pueden acceder directamente a los objetos de la lógica de negocio. El código puede tener este aspecto: un objeto GUI invoca a un método de un objeto de la lógica de negocio, pasándole algunos argumentos. Este proceso se describe habitualmente como un objeto que envía a otro una solicitud.
La propuesta del patrón:
El patrón Command sugiere que los objetos GUI no envíen estas solicitudes directamente. En lugar de ello, debes extraer todos los detalles de la solicitud, como el objeto que está siendo invocado, el nombre del método y la lista de argumentos, y ponerlos dentro de una clase comando separada con un único método que activa esta solicitud.
Los objetos de comando sirven como vínculo entre varios objetos GUI y de lógica de negocio. De ahora en adelante, el objeto GUI no tiene que conocer qué objeto de la lógica de negocio recibirá la solicitud y cómo la procesará. El objeto GUI activa el comando, que gestiona todos los detalles.
Acceso a la capa de lógica de negocio a través de un comando.
El siguiente paso es hacer que tus comandos implementen la misma interfaz. Normalmente tiene un único método de ejecución que no acepta parámetros. Esta interfaz te permite utilizar varios comandos con el mismo emisor de la solicitud, sin acoplarla a clases concretas de comandos. Adicionalmente, ahora puedes cambiar objetos de comando vinculados al emisor, cambiando efectivamente el comportamiento del emisor durante el tiempo de ejecución.
Gestión de parámetros:
Puede que hayas observado que falta una pieza del rompecabezas, que son los parámetros de la solicitud. Un objeto GUI puede haber proporcionado al objeto de la capa de negocio algunos parámetros. Ya que el método de ejecución del comando no tiene parámetros, ¿cómo pasaremos los detalles de la solicitud al receptor?
Resulta que el comando debe estar preconfigurado con esta información o ser capaz de conseguirla por su cuenta.
Los objetos GUI delegan el trabajo a los comandos.
Regresemos a nuestro editor de textos. Tras aplicar el patrón Command, ya no necesitamos todas esas subclases de botón para implementar varios comportamientos de clic. Basta con colocar un único campo dentro de la clase base Botón que almacene una referencia a un objeto de comando y haga que el botón ejecute ese comando en un clic.
Implementarás un puñado de clases de comando para toda operación posible y las vincularás con botones particulares, dependiendo del comportamiento pretendido de los botones.
Command - Analogía en el mundo real
Realizando un pedido en un restaurante.
Tras un largo paseo por la ciudad, entras en un buen restaurante y te sientas a una mesa junto a la ventana. Un amable camarero se acerca y toma tu pedido rápidamente, apuntándolo en un papel. El camarero se va a la cocina y pega el pedido a la pared. Al cabo de un rato, el pedido llega al chef, que lo lee y prepara la comida. El cocinero coloca la comida en una bandeja junto al pedido. El camarero descubre la bandeja, comprueba el pedido para asegurarse de que todo está como lo querías, y lo lleva todo a tu mesa.
El pedido en papel hace la función de un comando. Permanece en una cola hasta que el chef está listo para servirlo. Este pedido contiene toda la información relevante necesaria para preparar la comida. Permite al chef empezar a cocinar de inmediato, en lugar de tener que correr de un lado a otro aclarando los detalles del pedido directamente contigo.
Command - Estructura
-
La clase Emisora (o invocadora) es responsable de inicializar las solicitudes. Esta clase debe tener un campo para almacenar una referencia a un objeto de comando. El emisor activa este comando en lugar de enviar la solicitud directamente al receptor. Ten en cuenta que el emisor no es responsable de crear el objeto de comando. Normalmente, obtiene un comando precreado de parte del cliente a través del constructor.
-
La interfaz Comando normalmente declara un único método para ejecutar el comando.
-
Los Comandos Concretos implementan varios tipos de solicitudes. Un comando concreto no se supone que tenga que realizar el trabajo por su cuenta, sino pasar la llamada a uno de los objetos de la lógica de negocio. Sin embargo, para lograr simplificar el código, estas clases se pueden fusionar. Los parámetros necesarios para ejecutar un método en un objeto receptor pueden declararse como campos en el comando concreto. Puedes hacer inmutables los objetos de comando permitiendo la inicialización de estos campos únicamente a través del constructor.
-
La clase Receptora contiene cierta lógica de negocio. Casi cualquier objeto puede actuar como receptor. La mayoría de los comandos solo gestiona los detalles sobre cómo se pasa una solicitud al receptor, mientras que el propio receptor hace el trabajo real.
-
El Cliente crea y configura los objetos de comando concretos. El cliente debe pasar todos los parámetros de la solicitud, incluyendo una instancia del receptor, dentro del constructor del comando. Después de eso, el comando resultante puede asociarse con uno o varios emisores.
Command - ¿Cuándo se puede aplicar?
✓ Utiliza el patrón Command cuando quieras parametrizar objetos con operaciones.
✓ Utiliza el patrón Command cuando quieras poner operaciones en cola, programar su ejecución, o ejecutarlas de forma remota.
Command - Ventajas y desventajas
Ventajas:
✓ Desacopla el objeto que invoca la operación del que la ejecuta.
✓ Permite implementar deshacer, macrocomandos, logs de operaciones, etc.
✓ Facilita la extensión y reutilización de comandos.
Desventajas:
✗ Incrementa el número de clases en el sistema.
✗ Puede aumentar la complejidad para operaciones simples.
State (Estado)
State es un patrón de diseño de comportamiento que permite a un objeto alterar su comportamiento cuando su estado interno cambia. Parece como si el objeto cambiara su clase.
Permite que un objeto cambie su comportamiento cuando cambia su estado interno, pareciendo cambiar de clase. Útil para máquinas de estados, flujos de trabajo, validadores o sistemas con múltiples modos de operación.
Participantes del patrón:
- Contexto: objeto cuyo comportamiento cambia según su estado.
- Estado (State): interfaz para los comportamientos.
- Estados concretos: implementaciones específicas.
State - Problema
Máquina de estados finitos.
El patrón State está estrechamente relacionado con el concepto de la Máquina de estados finitos.
La idea principal es que, en cualquier momento dado, un programa puede encontrarse en un número finito de estados. Dentro de cada estado único, el programa se comporta de forma diferente y puede cambiar de un estado a otro instantáneamente. Sin embargo, dependiendo de un estado actual, el programa puede cambiar o no a otros estados. Estas normas de cambio llamadas transiciones también son finitas y predeterminadas.
Las Máquinas de estados finitos se componen de un conjunto de estados, un conjunto de entradas y un conjunto de transiciones. Los estados representan los distintos modos o condiciones en que puede encontrarse el sistema, mientras que las entradas son los sucesos o señales que desencadenan el cambio de un estado a otro. Las transiciones describen las reglas que rigen el paso del sistema de un estado a otro.
Aplicación a objetos:
También puedes aplicar esta solución a los objetos. Imagina que tienes una clase Documento. Un documento puede encontrarse en uno de estos tres estados: Borrador, Moderación y Publicado. El método publicar del documento funciona de forma ligeramente distinta en cada estado:
- En Borrador: mueve el documento a moderación.
- En Moderación: hace público el documento, pero sólo si el usuario actual es un administrador.
- En Publicado: no hace nada en absoluto.
Las máquinas de estado se implementan normalmente con muchos operadores condicionales (if o switch) que seleccionan el comportamiento adecuado dependiendo del estado actual del objeto. Normalmente, este “estado” es tan solo un grupo de valores de los campos del objeto. Aunque nunca hayas oído hablar de máquinas de estados finitos, probablemente hayas implementado un estado al menos alguna vez. ¿Te suena esta estructura de código?
El problema se agrava:
La mayor debilidad de una máquina de estado basada en condicionales se revela una vez que empezamos a añadir más y más estados y comportamientos dependientes de estados a la clase Documento. La mayoría de los métodos contendrán condicionales monstruosos que eligen el comportamiento adecuado de un método de acuerdo con el estado actual.
Un código así es muy difícil de mantener, porque cualquier cambio en la lógica de transición puede requerir cambiar los condicionales de estado de cada método. El problema tiende a empeorar con la evolución del proyecto. Es bastante difícil predecir todos los estados y transiciones posibles en la etapa de diseño. Por ello, una máquina de estados esbelta, creada con un grupo limitado de condicionales, puede crecer y convertirse en un desastre con el tiempo.
State - Solución
El patrón State sugiere que crees nuevas clases para todos los estados posibles de un objeto y extraigas todos los comportamientos específicos del estado para colocarlos dentro de esas clases.
En lugar de implementar todos los comportamientos por su cuenta, el objeto original, llamado contexto, almacena una referencia a uno de los objetos de estado que representa su estado actual y delega todo el trabajo relacionado con el estado a ese objeto.
Documento delega el trabajo a un objeto de estado.
Para la transición del contexto a otro estado, sustituye el objeto de estado activo por otro objeto que represente ese nuevo estado. Esto sólo es posible si todas las clases de estado siguen la misma interfaz y el propio contexto funciona con esos objetos a través de esa interfaz.
Esta estructura puede resultar similar al patrón Strategy, pero hay una diferencia clave. En el patrón State, los estados particulares pueden conocerse entre sí e iniciar transiciones de un estado a otro, mientras que las estrategias casi nunca se conocen.
State - Analogía en el mundo real
Los botones e interruptores de tu smartphone se comportan de forma diferente dependiendo del estado actual del dispositivo:
- Cuando el teléfono está desbloqueado, al pulsar botones se ejecutan varias funciones.
- Cuando el teléfono está bloqueado, pulsar un botón desbloquea la pantalla.
- Cuando la batería del teléfono está baja, pulsar un botón muestra la pantalla de carga.
State - Estructura
-
La clase Contexto almacena una referencia a uno de los objetos de estado concreto y le delega todo el trabajo específico del estado. El contexto se comunica con el objeto de estado a través de la interfaz de estado. El contexto expone un modificador (setter) para pasarle un nuevo objeto de estado.
-
La interfaz Estado declara los métodos específicos del estado. Estos métodos deben tener sentido para todos los estados concretos, porque no querrás que uno de tus estados tenga métodos inútiles que nunca son invocados.
-
Los Estados Concretos proporcionan sus propias implementaciones para los métodos específicos del estado. Para evitar la duplicación de código similar a través de varios estados, puedes incluir clases abstractas intermedias que encapsulen algún comportamiento común. Los objetos de estado pueden almacenar una referencia inversa al objeto de contexto. A través de esta referencia, el estado puede extraer cualquier información requerida del objeto de contexto, así como iniciar transiciones de estado.
Tanto el estado de contexto como el concreto pueden establecer el nuevo estado del contexto y realizar la transición de estado sustituyendo el objeto de estado vinculado al contexto.
State - ¿Cuándo se puede aplicar?
✓ Utiliza el patrón State cuando tengas un objeto que se comporta de forma diferente dependiendo de su estado actual, el número de estados sea enorme y el código específico del estado cambie con frecuencia.
✓ Utiliza el patrón cuando tengas una clase contaminada con enormes condicionales que alteran el modo en que se comporta la clase de acuerdo con los valores actuales de los campos de la clase.
✓ Utiliza el patrón State cuando tengas mucho código duplicado por estados similares y transiciones de una máquina de estados basada en condiciones.
State - Ventajas y desventajas
Ventajas:
✓ Elimina condicionales extensos (if, switch) relacionados al estado.
✓ Permite agregar nuevos estados sin modificar el código del contexto.
✓ Promueve el principio de responsabilidad única.
Desventajas:
✗ Puede ser excesivo si hay pocos estados o raramente cambian.
✗ Aumenta el número de clases en el sistema.
Patrones de comportamiento - Comparación general
| Patrón | Propósito | Aplicación típica |
|---|---|---|
| Observer | Notificar cambios a múltiples objetos. Define una relación uno a muchos: cuando un objeto cambia de estado, todos los que dependen de él son notificados automáticamente. | Sistema de notificaciones, eventos GUI |
| Strategy | Encapsular algoritmos intercambiables. Permite definir un conjunto de algoritmos intercambiables y elegir cuál usar en tiempo de ejecución. | Métodos de ordenamiento, cálculo de precios |
| Command | Encapsular una acción como un objeto. Encapsula una solicitud como un objeto, permitiendo deshacer operaciones, encolarlas o ejecutarlas más tarde. | Control remoto, menús, deshacer |
| State | Cambiar comportamiento según estado interno. Permite que un objeto cambie su comportamiento cuando cambia su estado interno, como si fuera de otra clase. | Máquinas de estados, validadores |
Clase 5
Examen
Espacio reservado para la evaluación de Patrones de Diseño.
Clase 6
Buenas prácticas de Desarrollo de software
Introducción
El desarrollo de software no se trata únicamente de que el código funcione, sino también de que sea legible, mantenible y escalable. Las buenas prácticas permiten que los proyectos crezcan sin volverse inestables o difíciles de modificar.
Algunas buenas prácticas en desarrollo de software que ayudan a mejorar la calidad del código son:
- Patrones de refactorización
- Diseño guiado por el dominio (DDD)
- Principios SOLID/DRY
- Patrones de diseño
- Clean Code
Patrones de Refactorización
La Refactorización es el proceso de mejorar la estructura interna del código sin cambiar su comportamiento externo.
¿Cuándo refactorizar?
- Cuando el código es difícil de entender.
- Cuando se repite lógica en varios lugares.
- Después de agregar nuevas funcionalidades.
1. Extract Method (Extraer Método)
Imagina que tienes una función extensa que realiza múltiples tareas. El patrón “Extract Method” consiste en identificar partes del código que se pueden agrupar y moverlas a una nueva función con un nombre descriptivo.
¿Por qué usarlo?
- Legibilidad: Funciones más cortas y enfocadas son más fáciles de entender.
- Reutilización: Puedes utilizar el nuevo método en diferentes partes de tu código.
- Mantenimiento: Es más sencillo probar y mantener funciones pequeñas y específicas.
Ejemplo:
// Antes de refactorizar
function procesarPedido(pedido: Pedido) {
// Validar pedido
if (!pedido.cliente || !pedido.items.length) {
throw new Error('Pedido inválido');
}
// Calcular total
let total = 0;
for (const item of pedido.items) {
total += item.precio * item.cantidad;
}
// Aplicar descuento
if (pedido.cliente.esVIP) {
total *= 0.9;
}
// Procesar pago
console.log(`Procesando pago de ${total}`);
}
// Después de refactorizar con Extract Method
function procesarPedido(pedido: Pedido) {
validarPedido(pedido);
const total = calcularTotal(pedido);
procesarPago(total);
}
function validarPedido(pedido: Pedido) {
if (!pedido.cliente || !pedido.items.length) {
throw new Error('Pedido inválido');
}
}
function calcularTotal(pedido: Pedido): number {
let total = 0;
for (const item of pedido.items) {
total += item.precio * item.cantidad;
}
if (pedido.cliente.esVIP) {
total *= 0.9;
}
return total;
}
function procesarPago(total: number) {
console.log(`Procesando pago de ${total}`);
}2. Inline Method (Integrar Método)
Es el opuesto a “Extract Method”. Si tienes un método que es muy simple y solo se utiliza en un lugar, puedes integrar su contenido directamente en donde se llama.
¿Por qué usarlo?
- Simplicidad: Reduces la cantidad de métodos que necesitas rastrear.
- Claridad: Evitas saltos innecesarios en el código.
- Mantenimiento: Menos abstracciones pueden simplificar la comprensión.
Nota: Asegúrate de que al integrar el método no estás duplicando código en múltiples lugares.
Ejemplo:
// Antes de refactorizar
function obtenerNombreCompleto(persona: Persona): string {
return getNombre(persona);
}
function getNombre(persona: Persona): string {
return `${persona.nombre} ${persona.apellido}`;
}
// Después de refactorizar con Inline Method
function obtenerNombreCompleto(persona: Persona): string {
return `${persona.nombre} ${persona.apellido}`;
}Diseño Guiado por el Dominio (DDD)
El Diseño Guiado por el Dominio es una forma de desarrollar software centrada en el dominio del negocio y su lógica. Se trata de modelar el código basándose en cómo funciona realmente el negocio, utilizando términos y estructuras que son familiares para los expertos en la materia.
Componentes clave:
- Entidades: Objetos con identidad única que persiste en el tiempo.
- Objetos de Valor: Descritos por sus atributos; no tienen identidad propia.
- Agregados: Grupos de entidades y objetos de valor que se consideran una unidad.
- Repositorios: Interfaces para almacenar y recuperar agregados.
- Servicios de Dominio: Lógica que no encaja en una entidad u objeto de valor.
Ejemplo de DDD
Supongamos que estamos creando un sistema de gestión de pedidos.
Entidades:
Cliente es una entidad porque tiene una identidad única (id).
class Cliente {
constructor(
public id: number,
public nombre: string,
public email: string
) {}
}Objetos de Valor:
DireccionEnvio es un objeto de valor; se define por sus atributos y no tiene identidad propia.
class DireccionEnvio {
constructor(
public calle: string,
public ciudad: string,
public codigoPostal: string
) {}
}Agregado:
Pedido es un agregado que incluye entidades y objetos de valor.
class Pedido {
constructor(
public id: number,
public cliente: Cliente,
public items: ItemPedido[],
public direccionEnvio: DireccionEnvio
) {}
}Repositorio:
PedidoRepositorio maneja cómo almacenamos y recuperamos pedidos.
interface PedidoRepositorio {
guardar(pedido: Pedido): void;
obtenerPorId(id: number): Pedido;
}Servicio de Dominio:
ServicioPago contiene lógica que no pertenece directamente a una entidad específica.
class ServicioPago {
procesarPago(pedido: Pedido, metodoPago: MetodoPago): void {
// Lógica para procesar el pago
}
}Principios SOLID
Los principios SOLID son cinco reglas que ayudan a diseñar software orientado a objetos de manera clara, flexible, fácil de mantener y ampliar.
Los 5 principios son:
- S — Single Responsibility Principle (Responsabilidad Única)
- O — Open/Closed Principle (Abierto/Cerrado)
- L — Liskov Substitution Principle (Sustitución de Liskov)
- I — Interface Segregation Principle (Segregación de Interfaces)
- D — Dependency Inversion Principle (Inversión de Dependencias)
S - Single Responsibility Principle (Responsabilidad Única)
Una clase debe tener una única responsabilidad o razón para cambiar.
Ejemplo:
// ❌ Mal - La clase tiene múltiples responsabilidades
class Usuario {
guardarEnBaseDeDatos() { /* ... */ }
enviarEmail() { /* ... */ }
validarDatos() { /* ... */ }
}
// ✅ Bien - Cada clase tiene una única responsabilidad
class Usuario {
constructor(public nombre: string, public email: string) {}
}
class RepositorioUsuario {
guardar(usuario: Usuario) { /* ... */ }
}
class ServicioEmail {
enviar(destinatario: string, mensaje: string) { /* ... */ }
}
class ValidadorUsuario {
validar(usuario: Usuario): boolean { /* ... */ }
}O - Open/Closed Principle (Abierto/Cerrado)
Las clases deben estar abiertas para extensión pero cerradas para modificación.
Ejemplo:
// ❌ Mal - Necesitas modificar la clase para agregar nuevas formas
class CalculadoraArea {
calcular(forma: any): number {
if (forma.tipo === 'circulo') {
return Math.PI * forma.radio ** 2;
} else if (forma.tipo === 'rectangulo') {
return forma.ancho * forma.alto;
}
return 0;
}
}
// ✅ Bien - Puedes extender sin modificar
interface Forma {
calcularArea(): number;
}
class Circulo implements Forma {
constructor(public radio: number) {}
calcularArea(): number {
return Math.PI * this.radio ** 2;
}
}
class Rectangulo implements Forma {
constructor(public ancho: number, public alto: number) {}
calcularArea(): number {
return this.ancho * this.alto;
}
}
class CalculadoraArea {
calcular(forma: Forma): number {
return forma.calcularArea();
}
}L - Liskov Substitution Principle (Sustitución de Liskov)
Los objetos de una clase derivada deben poder reemplazar objetos de la clase base sin alterar el comportamiento del programa.
Ejemplo:
// ❌ Mal - El Pinguino no puede volar
class Ave {
volar() {
console.log('Volando...');
}
}
class Pinguino extends Ave {
volar() {
throw new Error('Los pingüinos no pueden volar');
}
}
// ✅ Bien - Separar comportamientos
class Ave {
comer() {
console.log('Comiendo...');
}
}
class AveVoladora extends Ave {
volar() {
console.log('Volando...');
}
}
class Pinguino extends Ave {
nadar() {
console.log('Nadando...');
}
}I - Interface Segregation Principle (Segregación de Interfaces)
Los clientes no deben estar obligados a depender de interfaces que no utilizan.
Ejemplo:
// ❌ Mal - Interfaz muy grande
interface Trabajador {
trabajar(): void;
comer(): void;
dormir(): void;
}
class Robot implements Trabajador {
trabajar() { /* ... */ }
comer() { throw new Error('Los robots no comen'); }
dormir() { throw new Error('Los robots no duermen'); }
}
// ✅ Bien - Interfaces segregadas
interface Trabajable {
trabajar(): void;
}
interface Alimentable {
comer(): void;
}
interface Descansable {
dormir(): void;
}
class Humano implements Trabajable, Alimentable, Descansable {
trabajar() { /* ... */ }
comer() { /* ... */ }
dormir() { /* ... */ }
}
class Robot implements Trabajable {
trabajar() { /* ... */ }
}D - Dependency Inversion Principle (Inversión de Dependencias)
Las clases de alto nivel no deben depender de clases de bajo nivel. Ambas deben depender de abstracciones.
Ejemplo:
// ❌ Mal - Dependencia directa de implementación concreta
class MySQLDatabase {
guardar(data: string) {
console.log('Guardando en MySQL');
}
}
class UsuarioService {
private db = new MySQLDatabase();
guardarUsuario(usuario: string) {
this.db.guardar(usuario);
}
}
// ✅ Bien - Dependencia de abstracción
interface Database {
guardar(data: string): void;
}
class MySQLDatabase implements Database {
guardar(data: string) {
console.log('Guardando en MySQL');
}
}
class MongoDatabase implements Database {
guardar(data: string) {
console.log('Guardando en MongoDB');
}
}
class UsuarioService {
constructor(private db: Database) {}
guardarUsuario(usuario: string) {
this.db.guardar(usuario);
}
}Principio DRY (Don’t Repeat Yourself)
DRY significa “No te repitas”. Este principio establece que cada pieza de conocimiento debe tener una representación única, inequívoca y autoritativa dentro del sistema.
Ejemplo:
// ❌ Mal - Código duplicado
function calcularPrecioConIVA_Producto1(precio: number): number {
return precio * 1.21;
}
function calcularPrecioConIVA_Producto2(precio: number): number {
return precio * 1.21;
}
// ✅ Bien - Código reutilizable
function calcularPrecioConIVA(precio: number): number {
return precio * 1.21;
}Clean Code - Principios Fundamentales
Clean Code (Código Limpio) es una filosofía de desarrollo que promueve escribir código claro, simple y fácil de entender.
Principios básicos de Clean Code:
1. Nombres significativos
Los nombres de variables, funciones y clases deben ser descriptivos y revelar su intención.
// ❌ Mal
const d = 30;
function calc(x: number) { return x * 1.21; }
// ✅ Bien
const diasDelMes = 30;
function calcularPrecioConImpuesto(precioBase: number) {
return precioBase * 1.21;
}2. Funciones pequeñas
Las funciones deben hacer una sola cosa y hacerla bien.
// ❌ Mal - Función que hace demasiadas cosas
function procesarUsuario(usuario: Usuario) {
// Validar
if (!usuario.email.includes('@')) throw new Error('Email inválido');
// Guardar
database.save(usuario);
// Enviar email
emailService.send(usuario.email, 'Bienvenido');
// Registrar log
logger.log('Usuario procesado');
}
// ✅ Bien - Funciones pequeñas y específicas
function validarEmail(email: string): boolean {
return email.includes('@');
}
function guardarUsuario(usuario: Usuario): void {
database.save(usuario);
}
function enviarEmailBienvenida(email: string): void {
emailService.send(email, 'Bienvenido');
}
function procesarUsuario(usuario: Usuario) {
if (!validarEmail(usuario.email)) {
throw new Error('Email inválido');
}
guardarUsuario(usuario);
enviarEmailBienvenida(usuario.email);
logger.log('Usuario procesado');
}3. Comentarios solo cuando sea necesario
El código debe ser auto-explicativo. Los comentarios deben usarse para explicar el “por qué”, no el “qué”.
// ❌ Mal - Comentario innecesario
// Incrementar el contador en 1
contador++;
// ✅ Bien - Comentario que explica el "por qué"
// Aplicamos un retraso de 100ms para evitar sobrecarga del servidor
await sleep(100);4. Evitar números mágicos
Usa constantes con nombres significativos en lugar de números literales.
// ❌ Mal
if (edad >= 18) {
// ...
}
// ✅ Bien
const EDAD_MINIMA_LEGAL = 18;
if (edad >= EDAD_MINIMA_LEGAL) {
// ...
}5. Manejo de errores claro
// ❌ Mal
try {
// operación
} catch (e) {
console.log(e);
}
// ✅ Bien
try {
procesarPago(pedido);
} catch (error) {
if (error instanceof PagoRechazadoError) {
notificarClientePagoRechazado(pedido.cliente);
registrarErrorPago(error);
} else {
throw error;
}
}Clase 7
Mejora continua en el desarrollo de software (CI/CD)
1. Mejora continua en el desarrollo de software
La mejora continua es un enfoque filosófico y técnico que busca mejorar procesos, herramientas, calidad del producto y colaboración entre personas de forma incremental, sistemática y permanente.
Su origen se remonta a principios de gestión de calidad en la industria japonesa, y ha sido adoptado ampliamente en metodologías ágiles y DevOps.
En el contexto del desarrollo de software:
- Se analiza lo que funciona y lo que no después de cada entrega o iteración.
- Se introducen pequeñas mejoras técnicas, organizativas o de comunicación.
- El aprendizaje es parte del proceso, no algo externo o adicional.
Importancia de la mejora continua
El desarrollo de software es una actividad dinámica. Cambian los requisitos, tecnologías, equipos, herramientas. Un equipo que no mejora continuamente se estanca, acumula deuda técnica y pierde capacidad de innovación.
Ejemplos prácticos de mejora continua:
- Adoptar revisiones de código regulares para detectar errores antes de integrar.
- Agregar nuevas pruebas automatizadas para cubrir errores no detectados.
- Optimizar tiempos de ejecución del pipeline de integración.
- Recortar procesos burocráticos que generan demoras innecesarias.
2. Integración Continua - CI (Continuous Integration)
La Integración Continua es una práctica de desarrollo en la que los desarrolladores integran su código frecuentemente (idealmente, varias veces al día) en un repositorio compartido, utilizando herramientas que automáticamente:
- Verifican que el código compile correctamente.
- Ejecutan pruebas automatizadas.
- Detectan errores de integración (conflictos, incompatibilidades, etc.).
Objetivos:
- Asegurar que el código compartido siempre esté en un estado funcional.
- Prevenir errores que se acumulan si los cambios se integran esporádicamente.
- Facilitar el feedback temprano y continuo sobre el estado del proyecto.
Características:
- Uso de sistemas de control de versiones (como Git) para centralizar el código.
- Automatización mediante scripts o herramientas CI (Jenkins, GitHub Actions, Travis CI, etc.).
- Cada commit o pull request desencadena un pipeline automático.
- Los errores se detectan inmediatamente después del cambio.
Ejemplo de flujo CI:
- El desarrollador hace un commit y un push al repositorio remoto.
- Jenkins detecta el cambio y ejecuta:
- Compilación del código.
- Análisis estático (ej. revisión de estilo con ESLint o SonarQube).
- Pruebas unitarias.
- Si todo es exitoso, el cambio puede ser revisado y aprobado.
- Si algo falla, el desarrollador recibe un aviso y lo corrige.
3. Entrega Continua - CD (Continuous Delivery)
La Entrega Continua extiende la CI automatizando el proceso de despliegue del software hacia entornos de prueba, preproducción o incluso producción (en este caso, hablamos de Continuous Deployment).
Objetivos:
- Tener el software siempre listo para ser entregado.
- Minimizar el tiempo entre desarrollo y disponibilidad del producto.
- Realizar despliegues rápidos, frecuentes, y confiables.
Diferencia entre Delivery y Deployment:
- Continuous Delivery: el código está listo para producción, pero el despliegue se hace manualmente o con aprobación humana.
- Continuous Deployment: el código validado se despliega automáticamente sin intervención manual.
4. Herramientas de CI/CD - Jenkins
Jenkins es una herramienta de automatización de código abierto muy popular, especialmente utilizada para la Integración Continua (CI) y la Entrega Continua (CD) en el desarrollo de software.
Permite automatizar tareas como la compilación, las pruebas y la implementación, lo que agiliza el proceso de desarrollo y reduce la posibilidad de errores.
Jenkins actúa como un servidor central que ejecuta tareas repetitivas de forma automática, basadas en eventos como la llegada de nuevo código al repositorio. Esto permite a los equipos de desarrollo detectar errores rápidamente.
Ventajas:
✓ Extensible mediante plugins.
✓ Muy personalizable.
✓ Ideal para entornos empresariales complejos.
Desventajas:
✗ Requiere configuración y mantenimiento.
✗ La curva de aprendizaje puede ser elevada para proyectos pequeños.
5. Herramientas de CI/CD - GitHub Actions
GitHub Actions es una plataforma de automatización integrada en GitHub que permite a los desarrolladores automatizar flujos de trabajo de desarrollo de software, como la integración continua (CI) y la entrega continua (CD).
Básicamente, permite crear flujos de trabajo (workflows) personalizados que se activan por eventos en tu repositorio de GitHub, como la creación de una solicitud de cambios o la fusión de ramas. Tiene actions predefinidas para construir, probar, desplegar, analizar código, etc.
Cómo funciona:
- Eventos: Los flujos de trabajo se desencadenan por eventos en tu repositorio (por ejemplo, push, solicitud de extracción, creación de problema).
- Flujos de trabajo: Son archivos YAML que definen los pasos a ejecutar en respuesta a un evento.
- Trabajos: Un flujo de trabajo puede contener uno o más trabajos, que son conjuntos de pasos que se ejecutan en un entorno determinado (por ejemplo, un sistema operativo específico).
- Pasos: Cada trabajo se compone de pasos, que pueden ser comandos o acciones.
- Acciones: Son unidades reutilizables de código que realizan tareas específicas (por ejemplo, clonar el repositorio, ejecutar pruebas, desplegar la aplicación).
- Runners: Son máquinas que ejecutan los pasos de los trabajos.
Ventajas de usar GitHub Actions:
✓ Automatización: Reduce la necesidad de realizar tareas manualmente, ahorrando tiempo y esfuerzo.
✓ Integración: Se integra directamente con GitHub, lo que facilita su uso y gestión.
✓ Flexibilidad: Permite personalizar los flujos de trabajo para adaptarse a las necesidades específicas de cada proyecto.
✓ Comunidad: Existe una gran comunidad y una amplia variedad de acciones pre-construidas disponibles para reutilizar.
✓ Gratuito para código abierto: Ofrece minutos de ejecución gratuitos para repositorios de código abierto y una cantidad generosa para repositorios privados.
6. Herramientas de CI/CD - ESLint
ESLint es una herramienta esencial para desarrolladores JavaScript que buscan escribir código más limpio, consistente y libre de errores.
Se utiliza para identificar problemas y patrones inconsistentes en el código, mejorando así la calidad y la mantenibilidad del mismo. ESLint ayuda a detectar errores, errores de estilo y otras anomalías antes de que el código se ejecute, lo que puede prevenir errores en tiempo de ejecución y mejorar la consistencia del código.
Características principales:
✓ Análisis estático: ESLint analiza el código sin ejecutarlo, lo que permite detectar errores y problemas potenciales sin necesidad de ejecutar el programa.
✓ Personalizable: ESLint es altamente configurable y permite a los usuarios definir reglas y estándares específicos para su proyecto, adaptándose a las necesidades del equipo y del proyecto.
✓ Integración con IDEs: ESLint se puede integrar con editores de código como Visual Studio Code, lo que permite obtener retroalimentación en tiempo real mientras se escribe el código.
✓ Comunidad activa: ESLint cuenta con una gran comunidad de usuarios y desarrolladores que contribuyen con reglas, complementos y soporte.
✓ Mejora de la calidad del código: Al identificar patrones inconsistentes y errores potenciales, ESLint ayuda a mantener un código más legible, mantenible y libre de errores.
✓ Previene errores: Al detectar problemas antes de que el código se ejecute, ESLint ayuda a prevenir errores en tiempo de ejecución y a reducir la cantidad de errores en el código.
✓ Herramienta de linting estándar: ESLint se ha convertido en la herramienta de linting estándar para JavaScript, siendo utilizada por empresas como Microsoft, Airbnb y Netflix.
7. Herramientas de CI/CD - SonarQube
SonarQube es una plataforma de código abierto para el análisis estático de código fuente que evalúa la calidad y la seguridad del código, detectando errores, vulnerabilidades y problemas de estilo.
Ofrece información en tiempo real sobre problemas de código directamente en el entorno de desarrollo.
Analiza la calidad del código: Busca problemas como errores potenciales, vulnerabilidades de seguridad, código duplicado, falta de comentarios, complejidad excesiva, incumplimiento de estándares de codificación, y falta de pruebas unitarias.
Características principales:
✓ Proporciona retroalimentación: Ofrece informes detallados sobre la calidad del código con sugerencias para mejorar.
✓ Es una herramienta de código abierto: Hay versiones disponibles para diferentes necesidades, incluyendo una edición comunitaria gratuita.
✓ Admite múltiples lenguajes de programación: A través de complementos, SonarQube puede analizar código en más de 20 lenguajes, incluyendo Java, JavaScript, Python, C#, PHP y C++.
✓ Se integra con flujos de trabajo existentes: Permite analizar el código en diferentes etapas del ciclo de desarrollo, incluso en la etapa de prueba y auditoría.
✓ Apoya la inspección continua: SonarQube puede integrarse con sistemas de integración continua como Jenkins, Azure DevOps, entre otros.
✓ Ayuda a mejorar la seguridad del código: Detecta vulnerabilidades de seguridad y ayuda a los desarrolladores a corregirlas.
✓ Facilita la detección de “code smells”: Identifica patrones de código que podrían indicar problemas futuros de mantenibilidad.
✓ Puede usarse para análisis de código generado por IA: Permite asegurar la calidad del código generado por herramientas de inteligencia artificial.
Pipeline de CI/CD: etapas típicas
| Etapa | Objetivo |
|---|---|
| Checkout | Obtener el código fuente del repositorio |
| Build | Compilar el código (si es necesario) |
| Test | Ejecutar pruebas unitarias y de integración |
| Static Analysis | Verificar calidad del código (linter, cobertura) |
| Package | Generar paquetes listos para entrega |
| Deploy (opcional) | Enviar el software a un entorno (QA, staging, etc.) |
Beneficios de la mejora continua
1. Detección temprana de errores
El código defectuoso no llega a etapas avanzadas del desarrollo.
2. Despliegue frecuente y predecible
La automatización reduce riesgos y permite entregas más rápidas.
3. Calidad y estabilidad del producto
Se refuerza la validación en cada cambio.
4. Mayor colaboración
Los equipos comparten la responsabilidad del código.
5. Reducción del costo de corrección
Es más barato corregir un error en desarrollo que en producción.
Riesgos si no se aplica mejora continua
✗ Se acumula deuda técnica.
✗ Se generan entregas infrecuentes y poco confiables.
✗ Mayor probabilidad de fallos en producción.
✗ Falta de visibilidad sobre la calidad del producto.
✗ Desmotivación del equipo por procesos rígidos o ineficientes.
Conclusión
La mejora continua y la automatización mediante CI/CD no son solo prácticas técnicas: son parte de una cultura de calidad, responsabilidad compartida y aprendizaje continuo. Implementarlas eficazmente transforma no solo el producto final, sino también la forma en que los equipos trabajan, colaboran y evolucionan.
Cuadro comparativo de herramientas de CI/CD
| Herramienta | Tipo / Licencia | Ventajas principales | Desventajas principales | Ideal para… |
|---|---|---|---|---|
| GitHub Actions | Integrado en GitHub, gratuito para proyectos públicos | Integración nativa con GitHub (repos, issues, PRs, secrets). Facilidad de uso (no requiere servidores propios). Marketplace con miles de acciones listas. YAML simple, ejecución en runners Linux/Windows/macOS. Buen soporte para proyectos open source y educativos. | Limitado fuera del ecosistema GitHub. Menos personalizable que Jenkins en entornos empresariales. Ejecuciones gratuitas limitadas en repos privados. | Proyectos alojados en GitHub, cursos, startups o pequeños equipos que buscan agilidad. |
| Jenkins | Software libre (open source), autoalojado | Altamente configurable y extensible (más de 1800 plugins). Compatible con cualquier VCS (Git, SVN, Mercurial). Permite pipelines complejos y multiagente. Control total del entorno y la seguridad. | Requiere instalación y mantenimiento del servidor. Interfaz menos intuitiva. Actualizaciones de plugins pueden generar incompatibilidades. Curva de aprendizaje alta. | Grandes organizaciones o entornos donde se necesita control total y personalización. |
| Travis CI | Servicio en la nube, gratuito para open source | Configuración simple (archivo .travis.yml). Integración directa con GitHub. Buen soporte para múltiples lenguajes y entornos. | Plan gratuito limitado. Menos soporte para repos privados. Menos activo en desarrollo desde 2022. Integración reducida fuera de GitHub. | Proyectos open source pequeños o medianos que buscan simplicidad. |
| CircleCI | SaaS (nube o self-hosted) | Rápido y eficiente (paralelismo, caching inteligente). Buen soporte para Docker y microservicios. Configuración declarativa y flexible en YAML. Integración con GitHub y Bitbucket. | Interfaz más técnica. Plan gratuito con límites de uso. Aprendizaje inicial algo más complejo. Algunas funciones avanzadas requieren pago. | Equipos DevOps o proyectos basados en contenedores que buscan rendimiento. |
| GitLab CI/CD | Integrado en GitLab (SaaS o self-hosted) | Pipeline integrado con repositorios, issues y merge requests. Excelente trazabilidad y control de versiones. Permite runners propios o en la nube. Compatible con Docker, Kubernetes, etc. | Requiere usar GitLab o migrar repos. Menos intuitivo para principiantes. Algunos runners en la nube son pagos. | Organizaciones que usan GitLab como su plataforma principal de desarrollo. |
Clase 8
Repositorios de software y control de versiones
1. Introducción
En esta clase abordaremos el uso de herramientas de control de versiones, con especial énfasis en Git y GitHub, elementos fundamentales en el trabajo colaborativo moderno de desarrollo de software.
Su aplicación adecuada permite mantener un registro ordenado y seguro del código fuente, así como facilitar el trabajo en equipo en proyectos de cualquier escala.
¿Qué es el control de versiones?
El control de versiones es una práctica que permite gestionar los cambios realizados sobre los archivos de un proyecto, especialmente el código fuente.
Permite:
- Registrar cada modificación de forma cronológica.
- Comparar versiones y revertir cambios si es necesario.
- Trabajar en paralelo sin sobrescribir el trabajo de otros desarrolladores.
2. Git: Sistema de control de versiones distribuido
Git es el sistema de control de versiones más utilizado actualmente.
Características principales:
- Distribuido: cada desarrollador posee una copia completa del repositorio.
- Velocidad: operaciones locales rápidas (commits, diffs, branches).
- Integridad: cada versión del archivo está registrada con una firma (hash SHA-1).
- Branching: permite múltiples líneas o ramas de desarrollo.
Conceptos clave:
- Repositorio: almacén donde se guarda el código y su historial.
- Commit: registro de cambios realizados.
- Branch: una rama de desarrollo independiente.
- Merge: fusión de cambios entre ramas.
- Staging area: área de preparación para los commits.
3. GitHub: Plataforma para alojar repositorios
GitHub es una plataforma basada en la web que permite almacenar repositorios Git y colaborar en línea.
Facilita:
- Trabajo colaborativo con otras personas en un mismo proyecto.
- Control de cambios, revisión de código y manejo de tareas.
- Automatización mediante GitHub Actions.
4. Flujo básico de trabajo en Git y GitHub
Comandos básicos
| Comando | Descripción |
|---|---|
git clone | Clona un repositorio remoto a la máquina local. |
git branch | Crea una nueva rama. |
git checkout | Cambia a la rama indicada. |
git add | Agrega cambios al área de preparación. |
git commit -m "mensaje" | Registra los cambios localmente. |
git push | Envía los cambios al repositorio remoto. |
git pull | Trae los cambios más recientes desde el repositorio remoto. |
git merge | Fusiona una rama con la rama actual. |
5. Buenas prácticas en el trabajo colaborativo
✓ Crear ramas por funcionalidad: evita conflictos trabajando en ramas separadas por tarea.
✓ Commits frecuentes y significativos: cada cambio importante debe registrarse con un mensaje claro.
✓ Pull frecuente: mantener el repositorio local actualizado para evitar conflictos.
✓ Resolución de conflictos: revisar cuidadosamente antes de hacer merges.
✓ Pull requests: antes de integrar cambios a la rama principal, usar PRs para revisión y validación.
✓ Documentación: acompañar el código con documentación mínima (README, comentarios, issues).
6. ¿Por qué usar control de versiones?
El control de versiones y el uso de plataformas como GitHub son habilidades fundamentales para cualquier desarrollador. Más allá del manejo técnico, fomentan el trabajo en equipo, la responsabilidad compartida y la mejora continua del código.
Incorporar estas herramientas en el desarrollo diario es una señal de madurez profesional.
7. ¿Qué es un hash SHA-1?
Un hash es una cadena de texto alfanumérica generada por una función matemática que toma una entrada (en este caso, el contenido de un commit) y produce un resultado de 40 caracteres hexadecimales.
Ejemplo:
e83c5163316f89bfbde7d9ab23ca2e25604af290SHA-1 (Secure Hash Algorithm 1) es el algoritmo que usa Git para generar ese identificador. Aunque SHA-1 ha sido reemplazado por algoritmos más seguros (como SHA-256), Git todavía lo utiliza por compatibilidad. Sin embargo, ya existen versiones de Git que soportan SHA-256 para mayor seguridad.
¿Cómo usa Git el hash SHA-1?
Cada vez que haces un commit, Git calcula el hash SHA-1 en base a:
- El contenido de los archivos
- La fecha y hora del commit
- El autor
- El mensaje del commit
- Y el hash del commit anterior (para mantener la cadena de historial)
Por eso, si cambias, aunque sea un solo carácter en un archivo o mensaje, el hash cambia completamente.
¿Qué funciones cumple el hash SHA-1?
1. Identificación única:
Cada commit tiene su propio hash, que Git usa como “DNI” del commit.
Ejemplo: git show e83c516 muestra la información del commit cuyo hash empieza con esos caracteres.
2. Integridad del historial:
Git usa el hash del commit anterior al calcular el actual, formando una cadena inmutable de cambios. Esto hace casi imposible modificar el historial sin dejar rastro.
3. Referencia en GitHub:
En GitHub, verás esos hashes abreviados (por ejemplo: a1b2c3d) en los commits, ramas o pull requests, y podés copiarlos para referirte a un commit específico.
8. Resumen de comandos que manejan hash
| Comando | Descripción | Muestra |
|---|---|---|
git log | Lista todos los commits con hash completo | Hash + mensaje + autor |
git log --oneline | Muestra un resumen abreviado | Hash corto + mensaje |
git rev-parse HEAD | Devuelve el hash completo del commit actual | Solo el hash |
git show <hash> | Muestra detalles del commit indicado | Hash, autor, cambios |
git revert <hash> | Crea un nuevo commit que revierte los cambios introducidos por el commit indicado. No borra el historial, sino que agrega un nuevo commit inverso. | Genera un nuevo commit que “deshace” los cambios de ese hash |
git commit --amend -m "Nuevo mensaje" | Corrige el mensaje del último commit, si todavía no se ejecutó push. | Se reemplaza el mensaje anterior |
git commit -m "Aclara mensaje anterior" | Si ya se realizó push, lo más conveniente es ejecutar otro commit para aclarar el error del mensaje. | Se agrega un nuevo commit |
Contenido a desarrollar
Este contenido será ampliado próximamente con más material de la materia.