Los patrones de diseño son soluciones reutilizables para problemas comunes que surgen en el desarrollo de software. Estos patrones encapsulan buenas prácticas y ofrecen un enfoque probado para resolver problemas recurrentes, facilitando el diseño de sistemas más robustos y mantenibles Gamma (2002).
Los patrones no son exclusivos de las ciencias informáticas, sino que también se encuentran en otras disciplinas como la arquitectura, el diseño industrial y la ingeniería. En cada caso, los patrones representan soluciones probadas que se pueden adaptar a diferentes contextos, manteniendo su esencia y efectividad.
Por ejemplo, en arquitectura, un patrón podría ser el diseño de una plaza central en una ciudad, que fomenta la interacción social y el flujo de personas. De manera similar, en el desarrollo de software, los patrones de diseño buscan resolver problemas recurrentes de manera eficiente, promoviendo la reutilización y la estandarización.
Es importante destacar que los patrones no son recetas estrictas, sino guías flexibles que deben ser adaptadas según las necesidades específicas del proyecto. Comprender el contexto y los requisitos es fundamental para aplicar un patrón de manera efectiva y evitar un uso inadecuado que pueda complicar el diseño en lugar de simplificarlo.
“Cada patrón describe un problema que ocurre una y otra vez en nuestro entorno, y luego describe la esencia de la solución de ese problema, de tal manera en que se puede utilizar esta solución más de un millón de veces sin hacerlo igual siquiera dos veces”
1Características principales de los patrones de diseño¶
- Reutilizabilidad
- Los patrones permiten aplicar soluciones existentes a nuevos problemas, ahorrando tiempo y esfuerzo.
- Flexibilidad
- Se pueden personalizar para adaptarse a las necesidades específicas de un proyecto o contexto.
- Comunicación
- Proveen un lenguaje común entre desarrolladores, facilitando la colaboración y el entendimiento del diseño.
2Clasificación de los patrones de diseño¶
Los patrones de diseño se dividen en tres categorías principales:
- Patrones creacionales
- Se centran en la creación de objetos, asegurando que el sistema sea independiente de cómo se crean, componen y representan los objetos. Ejemplos: :
Singleton
Factory Method
Abstract Factory
- Patrones estructurales
- Se ocupan de la composición de clases y objetos para formar estructuras más grandes. Ejemplos: :
Adapter
Composite
Decorator
- Patrones de comportamiento
- Se enfocan en la interacción y responsabilidad entre objetos. Ejemplos: :
Observer
Strategy
Command
Veremos con más profundidad algunos de estos patrones.
3Patrón Adapter¶
Figure 1:Patrón Adapter
El patrón adapter permite reutilizar código heredado o legacy cuya interfaz no coincide con la esperada por el sistema en el que estamos trabajando. Este patrón actúa como un puente entre la interfaz existente y la requerida, permitiendo que componentes incompatibles trabajen juntos sin modificar su código original heredado.
En la figura a continuación se observan los siguientes componentes.
Diagrama de Clases del Patrón Adapter
Diagrama de Clases del Patrón Adapter
- Cliente
- Representa el sistema nuevo que espera una interfaz específica. En este ejemplo se observa que el cliente espera una interfaz que tiene el método
request(). - Interfaz
- Define la interfaz esperada por el sistema nuevo.
- Adaptado
- Representa la clase existente con la interfaz incompatible. En este ejemplo se observa que cuenta con el método
specificRequest(). - Adaptador
- Convierte la interfaz del Adaptado. Dentro del método
request()en el Adaptador se invoca el método específico del AdaptadospecificRequest(). Eventualmente puede realizar alguna transformación de datos o invocar otros métodos del Adaptado para conseguir querequest()cumpla con la interfaz esperada.
3.1Cómo Proceder¶
Identificar los actores en juego: el Cliente y el Adaptado (componente legacy).
Identificar la Interfaz que requiere el Cliente.
Verificar que el Adaptado que se quiere utilizar puede cumplir con la Interfaz solicitada.
Diseñar un envoltorio (Adaptador) que va a contener al Adaptado.
Implementar el Adaptador para que cumpla con la Interfaz esperada por el Cliente.
El Cliente interactúa con el Adaptador como si fuera el Adaptado.
3.2Ejemplo¶
Supongamos que tenemos un robot que realiza mediciones, cuyo sistema de control proporciona los métodos Medir() que devuelve un par de enteros, donde el primer número representa la distancia en metros y el segundo número la distancia en centímetros. Por ejemplo si la última medición fue de 10,5 m, entonces Medir() devolverá el par (10, 50).
Nuestra empresa ha concretado la venta del robot a un cliente que necesita incorporar el robot a su sistema de producción, pero el sistema de control del cliente espera que el método Medir devuelva un solo número que represente la distancia en pulgadas.
Identificar los actores en juego: el Cliente y el Adaptado (componente legacy).
Cliente: Sistema de control del cliente.
Adaptado: Robot que realiza mediciones.
Identificar la Interfaz que requiere el Cliente.
Interfaz: Método
Medir()que devuelve la distancia en pulgadas.
Verificar que el Adaptado que se quiere utilizar puede cumplir con la Interfaz solicitada.
Adaptado: Robot que realiza mediciones con el método
Medir()que devuelve la distancia en metros y centímetros y se puede convertir a pulgadas.
Diseñar un envoltorio (Adaptador) que va a contener al Adaptado.
1 2 3 4// Adaptador que convierte la interfaz del Adaptado a la interfaz esperada type RobotAdaptado struct { adaptado *Robot }Implementar el Adaptador para que cumpla con la Interfaz esperada por el Cliente.
1 2 3 4 5 6 7// Implementación del método requerido por la interfaz esperada func (r *RobotAdaptado) Medir() float64 { metros, centimetros := r.adaptado.Medir() totalCentimetros := (metros * 100) + centimetros pulgadas := float64(totalCentimetros) / 2.54 return pulgadas }El Cliente interactúa con el Adaptador como si fuera el Adaptado.
1 2 3 4 5// Cliente robot := &Robot{} adaptado := &RobotAdaptado{adaptado: robot} distancia := adaptado.Medir() fmt.Println(distancia) // distancia en pulgadasEn este ejemplo, el Adaptador
RobotAdaptadoconvierte la interfaz delRoboten la interfaz requerida por el Cliente, permitiendo que el sistema de control del cliente pueda utilizar el robot para realizar mediciones sin modificar el código original del robot.
4Patrón Composite¶
Figure 4:Patrón Composite
El patrón composite permite tratar tanto a objetos individuales como a composiciones de objetos de manera uniforme. Esto significa que se pueden tratar tanto a un objeto simple como a un grupo de objetos de la misma manera, sin tener que distinguir entre ellos. Esto simplifica el diseño y la implementación de estructuras jerárquicas de objetos.
Diagrama de Clase del Patrón Composite
Diagrama de Clase del Patrón Composite
- Componente
- Define la interfaz común para todos los elementos de la estructura.
- Simple
- Representa los elementos individuales de la estructura.
- Compuesto
- Representa los elementos que contienen otros elementos. Puede contener tanto objetos Simples como Compuestos. Se debe prever un método para agregar elementos a la colección, ya sea elementos Simple o Compuesto.
4.1Cómo Proceder¶
Definir una interfaz común para todos los elementos de la estructura (Componente).
Implementar los tipos de datos que representen los elementos individuales (Simple), asegurándose de que cumplan con la interfaz común (Componente).
Implementar los tipos de datos que representen los elementos compuestos (Compuesto), que contienen una colección de elementos (Componente), asegurándose de que cumplan con la interfaz común (Componente) y contemplen la posibilidad de agregar elementos a la colección.
Tratar tanto a los elementos simples como a los compuestos de manera uniforme, sin tener que distinguir entre ellos.
4.2Ejemplo¶
Supongamos que queremos modelar un sistema de archivos. Cada elemento del sistema —ya sea un archivo individual o una carpeta que contiene otros elementos— debe poder reportar su tamaño total en bytes. Queremos calcular el tamaño de una carpeta que puede contener archivos y otras carpetas, sin tener que distinguir entre ellos.
Definir una interfaz común para todos los elementos de la estructura (Componente).
1 2 3 4// Componente define la operación común a archivos y carpetas type Componente interface { Tamanio() int64 }Implementar los tipos de datos que representen los elementos individuales (Simple), asegurándose de que cumplan con la interfaz común (Componente).
1 2 3 4 5 6 7 8 9// Archivo representa un elemento simple (hoja) del sistema type Archivo struct { nombre string bytes int64 } func (a *Archivo) Tamanio() int64 { return a.bytes }Implementar los tipos de datos que representen los elementos compuestos (Compuesto), que contienen una colección de elementos (Componente), asegurándose de que cumplan con la interfaz común (Componente) y contemplen la posibilidad de agregar elementos a la colección.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17// Carpeta representa un elemento compuesto que contiene otros componentes type Carpeta struct { nombre string componentes []Componente } func (c *Carpeta) Tamanio() int64 { var total int64 for _, comp := range c.componentes { total += comp.Tamanio() } return total } func (c *Carpeta) Agregar(comp Componente) { c.componentes = append(c.componentes, comp) }Tratar tanto a los elementos simples como a los compuestos de manera uniforme, sin tener que distinguir entre ellos.
Por ejemplo, queremos calcular el tamaño total de un directorio de proyecto que contiene archivos y subcarpetas:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17readme := &Archivo{nombre: "README.md", bytes: 2048} licencia := &Archivo{nombre: "LICENSE", bytes: 1024} src := &Carpeta{nombre: "src"} src.Agregar(&Archivo{nombre: "main.go", bytes: 4096}) src.Agregar(&Archivo{nombre: "utils.go", bytes: 1536}) docs := &Carpeta{nombre: "docs"} docs.Agregar(&Archivo{nombre: "manual.pdf", bytes: 524288}) proyecto := &Carpeta{nombre: "mi-proyecto"} proyecto.Agregar(readme) proyecto.Agregar(licencia) proyecto.Agregar(src) proyecto.Agregar(docs) fmt.Println(proyecto.Tamanio()) // 532992proyecto.Tamanio()recorre recursivamente todos los componentes —archivos y carpetas— sin necesidad de saber si cada uno es simple o compuesto. El métodoAgregarrecibe unComponente, por lo que acepta tantoArchivocomoCarpeta.
5Patrón Iterator¶
Figure 7:Patrón Iterator
El patrón Iterator o Iterador permite recorrer los elementos de una colección cualquiera sin exponer su estructura interna. El Iterador provee una interfaz uniforme con dos métodos:
Siguiente() bool- Avanza el iterador al siguiente elemento y devuelve
true. Si no quedan más elementos por recorrer, devuelvefalse. La primera llamada aSiguiente()posiciona el iterador en el primer elemento de la colección. Valor() int- Devuelve el valor del elemento sobre el cual está posicionado el iterador. Solo debe llamarse después de que
Siguiente()haya devueltotrue.
El patrón de uso típico en Go es:
1 2 3 4it := coleccion.Iterador() for it.Siguiente() { fmt.Println(it.Valor()) }
Cada llamada a Siguiente() cumple dos funciones: verifica si hay un elemento disponible y avanza la posición interna. Valor() simplemente retorna el elemento actual sin modificar el estado del iterador.
5.1Cómo Proceder¶
Definir el comportamiento del Iterador con los métodos
Siguiente()yValor(). Si se necesita recorrer en ambos sentidos, se puede agregar un métodoAnterior().Dentro de la colección definir un método fábrica
Iterador()que devuelva un iterador nuevo apuntando al inicio.Implementar el Iterador vinculado siempre a una única colección.
Recorrer la colección con
for it.Siguiente().
5.2Ejemplo¶
Supongamos que tenemos una lista enlazada simple y queremos recorrerla con un iterador. Por simplicidad la lista contiene solo números enteros.
Primero definimos la estructura de la lista y su operación de inserción al final:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21type Nodo struct { valor int sig *Nodo } type Lista struct { cabeza *Nodo } func (l *Lista) AgregarAlFinal(valor int) { nuevo := &Nodo{valor: valor} if l.cabeza == nil { l.cabeza = nuevo return } actual := l.cabeza for actual.sig != nil { actual = actual.sig } actual.sig = nuevo }
El iterador se implementa como un struct que mantiene una referencia al nodo actual:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27type Iterador struct { actual *Nodo } func (l *Lista) Iterador() *Iterador { return &Iterador{actual: l.cabeza} } // Siguiente avanza al próximo nodo. // La primera llamada posiciona el iterador en la cabeza. // Devuelve false cuando no hay más elementos. func (it *Iterador) Siguiente() bool { if it.actual == nil { return false } // La primera vez, no avanza: devuelve la cabeza. // Las siguientes, avanza al nodo siguiente. if it.actual != nil { // guardamos el actual y avanzamos } return true } // Valor devuelve el valor del nodo actual. func (it *Iterador) Valor() int { return it.actual.valor }
El código anterior es una simplificación. La implementación real necesita un mecanismo para distinguir la primera llamada de las siguientes. A continuación la versión completa:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26type Iterador struct { actual *Nodo primera bool } func (l *Lista) Iterador() *Iterador { return &Iterador{actual: l.cabeza, primera: true} } func (it *Iterador) Siguiente() bool { if it.actual == nil { return false } if !it.primera { it.actual = it.actual.sig if it.actual == nil { return false } } it.primera = false return true } func (it *Iterador) Valor() int { return it.actual.valor }
La bandera primera evita que la llamada inicial a Siguiente() avance más allá del primer elemento. A partir de la segunda llamada, Siguiente() avanza al nodo siguiente antes de verificar si hay elemento.
Finalmente, el uso:
1 2 3 4 5 6 7 8 9 10 11 12 13lista := &Lista{} lista.AgregarAlFinal(1) lista.AgregarAlFinal(2) lista.AgregarAlFinal(3) it := lista.Iterador() for it.Siguiente() { fmt.Println(it.Valor()) } // Salida: // 1 // 2 // 3
6Ejercicios¶
Los ejercicios de este capítulo cubren los tres patrones vistos: Adapter, Composite e Iterator. Están en el directorio
02-patrones-de-diseno/ejercicios/
del repositorio
taller-algoritmos.
Cada ejercicio tiene un esqueleto con // Completar y su correspondiente batería de tests.
Para resolverlos, clonar el repositorio, completar las funciones y ejecutar go test ./....
- Gamma, E. (2002). Patrones de diseño: elementos de software orientado a objetos reutilizable. Pearson Education. https://books.google.com.ar/books?id=gap_AAAACAAJ