Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

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”

Christopher Alexander

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

Patró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

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

3.1Có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.

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.

  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
    2
    3
    4
    // Adaptador que convierte la interfaz del Adaptado a la interfaz esperada
    type RobotAdaptado struct {
        adaptado *Robot
    }
  5. 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
    }
  6. 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 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.

4Patrón Composite

Patró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

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

  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.

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.

  1. 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
    }
  2. 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
    }
  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
    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)
    }
  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 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
    17
    readme := &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()) // 532992

    proyecto.Tamanio() recorre recursivamente todos los componentes —archivos y carpetas— sin necesidad de saber si cada uno es simple o compuesto. El método Agregar recibe un Componente, por lo que acepta tanto Archivo como Carpeta.

5Patrón Iterator

Patró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, devuelve false. La primera llamada a Siguiente() 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 devuelto true.

El patrón de uso típico en Go es:

1
2
3
4
it := 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

  1. Definir el comportamiento del Iterador con los métodos Siguiente() y Valor(). Si se necesita recorrer en ambos sentidos, se puede agregar un método Anterior().

  2. Dentro de la colección definir un método fábrica Iterador() que devuelva un iterador nuevo apuntando al inicio.

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

  4. 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
21
type 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
27
type 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
26
type 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
13
lista := &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 ./....

References
  1. 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