26. Patrones de Diseño#

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.

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”

Christopher Alexander

26.1. Caracterí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.

26.2. Clasificació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 en más profundidad algunos de estos patrones.

26.3. Patrón Adapter#

../_images/PatronAdapter.svg

Figura 26.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 Diagrama de Clases del Patrón Adapter se observan los siguientes componentes.

../_images/PatronAdapter2.svg

Figura 26.2 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 specifirequest().

Adaptador

Convierte la interfaz del Adaptado. Dentro del método request() en el Adapatador se invoca el método específico del Adaptado specificrequest(). Eventualmente puede realizar alguna transformación de datos o invocar otros métodos del Adaptado para conseguir que request() cumpla con la interfaz esperada.

26.3.1. Cómo Proceder#

  1. Identificar los actores en juego: el Cliente y el Adaptado (componente legacy).

  2. Identificar la Interfaz que requiere el Cliente.

  3. Verificar que el Adaptado que se quiere utilizar puede cumplir con la Interfaz solicitada.

  4. Diseñar un envoltorio (Adaptador) que va a contener al Adaptado.

  5. Implementar el Adaptador para que cumpla con la Interfaz esperada por el Cliente.

  6. El Cliente interactúa con el Adaptador como si fuera el Adaptado.

26.3.2. Ejemplo#

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.

  1. Identificar los actores en juego: el Cliente y el Adaptado (componente legacy).

    • Cliente: Sistema de control del cliente.

    • Adaptado: Robot que realiza mediciones.

  2. Identificar la Interfaz que requiere el Cliente.

    • Interfaz: Método Medir() que devuelve la distancia en pulgadas.

  3. 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.

  4. Diseñar un envoltorio (Adaptador) que va a contener al Adaptado.

    1// Adaptador que convierte la interfaz del Adaptado a la interfaz esperada
    2type RobotAdaptado struct {
    3    adaptado *Robot
    4}
    
  5. Implementar el Adaptador para que cumpla con la Interfaz esperada por el Cliente.

    1// Implementación del método requerido por la interfaz esperada
    2func (r *RobotAdaptado) Medir() float64 {
    3    metros, centimetros := r.adaptado.Medir()
    4    totalCentimetros := (metros * 100) + centimetros
    5    pulgadas := float64(totalCentimetros) / 2.54
    6    return pulgadas
    7}
    
  6. El Cliente interactúa con el Adaptador como si fuera el Adaptado.

    1// Cliente
    2robot := &Robot{}
    3adaptado := &RobotAdaptado{adaptado: robot}
    4distancia := adaptado.Medir()
    5fmt.Println(distancia) // distancia en pulgadas
    

    En este ejemplo, el Adaptador RobotAdaptado convierte la interfaz del Robot en 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.

26.4. Patrón Composite#

../_images/PatronComposite.svg

Figura 26.3 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. Lo que simplifica el diseño y la implementación de estructuras jerárquicas de objetos.

../_images/PatronComposite2.svg

Figura 26.4 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.

26.4.1. Cómo Proceder#

  1. Definir una interfaz común para todos los elementos de la estructura (Componente).

  2. Implementar los tipos de datos que representen los elementos individuales (Simple), asegurándose de que cumplan con la interfaz común (Componente).

  3. 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.

  4. Tratar tanto a los elementos simples como a los compuestos de manera uniforme, sin tener que distinguir entre ellos.

26.4.2. Ejemplo#

Supongamos que queremos modelar una estructura jerárquica de figuras, donde los elementos pueden ser rectángulos, círculos o triángulos o grupos compuestos. Queremos poder calcular el área total de la estructura, independientemente de si se trata de un rectángulo individual o de un grupo de elementos.

  1. Definir una interfaz común para todos los elementos de la estructura (Componente).

    1// Interfaz común para todos los elementos de la estructura
    2type Figura interface {
    3    Area() float64
    4}
    
  2. Implementar los tipos de datos que representen los elementos individuales (Simple), asegurándose de que cumplan con la interfaz común (Componente).

     1// Implementación de los tipos de datos que representan los elementos individuales
     2import "math"
     3
     4type Rectangulo struct {
     5    Base   float64
     6    Altura float64
     7}
     8
     9func (r *Rectangulo) Area() float64 {
    10    return r.Base * r.Altura
    11}
    12
    13type Circulo struct {
    14    Radio float64
    15}
    16
    17func (c *Circulo) Area() float64 {
    18    return math.Pi * c.Radio * c.Radio
    19}
    20
    21type Triangulo struct {
    22    Base   float64
    23    Altura float64
    24}
    25
    26func (t *Triangulo) Area() float64 {
    27    return (t.Base * t.Altura) / 2
    28}
    
  3. 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// Implementación de los tipos de datos que representan los elementos compuestos
     2// que contienen una colección de elementos
     3type Grupo struct {
     4    Figuras []Figura
     5}
     6
     7func (g *Grupo) Area() float64 {
     8    var areaTotal float64
     9    for _, f := range g.Figuras {
    10        areaTotal += f.Area()
    11    }
    12    return areaTotal
    13}
    14
    15func (g *Grupo) Agregar(f Figura) {
    16    g.Figuras = append(g.Figuras, f)
    17}
    
  4. Tratar tanto a los elementos simples como a los compuestos de manera uniforme, sin tener que distinguir entre ellos.

    Por ejemplo queremos calcular el área de un tren compuesto por una locomotora y dos vagones, cada uno con su respectiva estructura de figuras.

    ../_images/PatronTren.svg

    Figura 26.5 Tren Compuesto de Figuras#

     1locomotora := &Grupo{}
     2locomotora.Agregar(&Rectangulo{Base: 7, Altura: 3}) //cuerpo de la locomotora
     3locomotora.Agregar(&Circulo{Radio: 1}) //rueda de la locomotora
     4locomotora.Agregar(&Circulo{Radio: 1}) //rueda de la locomotora
     5locomotora.Agregar(&Triangulo{Base: 2, Altura: 4}) //chimenea
     6locomotora.Agregar(&Rectangulo{Base: 2, Altura: 3}) //cabina
     7
     8vagon1 := &Grupo{}
     9vagon1.Agregar(&Rectangulo{Base: 7, Altura: 3}) //cuerpo del vagon
    10vagon1.Agregar(&Circulo{Radio: 1}) //rueda del vagon
    11vagon1.Agregar(&Circulo{Radio: 1}) //rueda del vagon
    12
    13vagon2 := &Grupo{}
    14vagon2.Agregar(&Rectangulo{Base: 7, Altura: 3}) //cuerpo del vagon
    15vagon2.Agregar(&Circulo{Radio: 1}) //rueda del vagon
    16vagon2.Agregar(&Circulo{Radio: 1}) //rueda del vagon
    17
    18tren := &Grupo{}
    19tren.Agregar(locomotora)
    20tren.Agregar(vagon1)
    21tren.Agregar(vagon2)
    22
    23fmt.Println("El área del tren es: ", tren.Area()) //91.84955592153875
    

26.5. Patrón Iterator#

../_images/PatronIterator.svg

Figura 26.6 Patrón Iterador#

El patrón Iterator o Iterador permite recorrer los elementos de una colección cualquiera sin exponer su estructura interna. El Iterador declara un conjunto de métodos o funciones para acceder secuencialmente a los elementos. Los métodos más comunes son:

Primero()

Se posiciona el iterador en el primer elemento de la colección

Siguiente()

Avanza el iterador al siguiente elemento

HayMas()

Devuelve true si todavía quedan elementos por recorrer en la colección o false en caso contario

Actual()

Devuelve el elemento actual donde está el iterador

26.5.1. Cómo Proceder#

  1. Definir el comportamiento del Iterador, es decir los métodos para obtener el siguiente, etc. Es posible agregar más métodos a los mencionados, por ejemplo si necesitamos un iterador que pueda avanzar y retroceder en su recorrido habrá que agregar los métodos correspondientes.

  2. Dentro de la colección definir un método para crear el Iterador

  3. Implementar el Iterador vinculado siempre a una única colección

  4. Recorrer la colección con el iterador creado

26.5.2. Ejemplo#

Supongamos que tenemos una lista enlazada simple y nos interesa crear un iterador que nos permita recorrerla e imprimir cada elemento de la lista. Por simplicidad suponemos que la lista enlazada solo contiene números enteros.

 1type Nodo struct {
 2    Valor     int
 3    Siguiente *Nodo
 4}
 5
 6type ListaEnlazada struct {
 7    Primero *Nodo
 8}
 9
10// Método para insertar un elemento al inicio de la lista
11func (l *ListaEnlazada) InsertarAlInicio(valor int) {
12    if l.Primero == nil {
13        l.Primero = &Nodo{Valor: valor}
14    } else {
15        nuevoNodo := &Nodo{Valor: valor, Siguiente: l.Primero}
16        l.Primero = nuevoNodo
17    }
18}
  1. Definir el comportamiento del Iterador, es decir los métodos para obtener el siguiente, etc. Es posible agregar más métodos a los mencionados, por ejemplo si necesitamos un iterador que pueda avanzar y retroceder en su recorrido habrá que agregar los métodos correspondientes.

    1// Interfaz del Iterador
    2// Define los métodos que debe implementar el iterador
    3type Iterador interface {
    4    Primero()
    5    Siguiente()
    6    HayMas() bool
    7    Actual() int
    8}
    
  2. Dentro de la colección definir un método para crear el Iterador

     1type Nodo struct {
     2    Valor     int
     3    Siguiente *Nodo
     4}
     5
     6type ListaEnlazada struct {
     7    Primero *Nodo
     8}
     9
    10// Método para insertar un elemento al inicio de la lista
    11func (l *ListaEnlazada) InsertarAlInicio(valor int) {
    12    if l.Primero == nil {
    13        l.Primero = &Nodo{Valor: valor}
    14    } else {
    15        nuevoNodo := &Nodo{Valor: valor, Siguiente: l.Primero}
    16        l.Primero = nuevoNodo
    17    }
    18}
    19
    20// Método para crear un iterador de la lista
    21func (l *ListaEnlazada) CrearIterador() Iterador {
    22    return &IteradorLista{lista: l, actual: l.Primero}
    23}
    
  3. Implementar el Iterador vinculado siempre a una única colección

     1type IteradorLista struct {
     2    lista  *ListaEnlazada
     3    actual *Nodo
     4}
     5
     6func (i IteradorLista) Primero() {
     7    i.actual = i.lista.Primero
     8}
     9
    10func (i IteradorLista) Siguiente() {
    11    i.actual = i.actual.Siguiente
    12}
    13
    14func (i IteradorLista) HayMas() bool {
    15    return i.actual != nil
    16}
    17
    18func (i IteradorLista) Actual() int {
    19    return i.actual.Valor
    20}
    
  4. Recorrer la colección con el iterador creado

    1lista := &ListaEnlazada{}
    2lista.InsertarAlInicio(3)
    3lista.InsertarAlInicio(2)
    4lista.InsertarAlInicio(1)
    5
    6iterador := lista.CrearIterador()
    7for iterador.Primero(); iterador.HayMas(); iterador.Siguiente() {
    8    fmt.Println(iterador.Actual(), " ")
    9}
    

26.6. Ejercicios#

  1. Dada la siguiente definición de una matriz de números enteros:

    1type Matriz struct {
    2    Filas int
    3    Columnas int
    4    Datos [][]int
    5}
    
    • Implementar un iterador que permita recorrer la matriz fila por fila.

    • Implementar un iterador que permita recorrer la matriz columna por columna.

    Los iteradores deben implementar la interfaz ‘Iterador’ definida anteriormente.

  2. Implementar un iterador para recorrer una lista enlazada doblemente, es decir que permita avanzar y retroceder en el recorrido de la lista.

    1type IteradorDoble interface {
    2    Primero()
    3    Siguiente()
    4    Anterior()
    5    HayMas() bool
    6    Actual() int
    7}