Design Patterns

Design Patterns: La Cassetta degli Attrezzi dello Sviluppatore 🛠️

Ah, i design pattern! Quei piccoli gioielli di saggezza che ci fanno sembrare più intelligenti di quanto siamo realmente. Ma cosa sono esattamente? E perché dovresti preoccupartene? Beh, immagina di costruire una casa senza un progetto. Certo, potresti farcela, ma probabilmente finirai con un tetto che perde e una porta che non si chiude. I design pattern sono i progetti per il tuo codice: soluzioni collaudate a problemi comuni — non ricette rigide, ma schemi adattabili.


Un Po’ di Storia 📜

Il concetto di design pattern nasce nel libro Design Patterns: Elements of Reusable Object-Oriented Software, scritto da quattro luminari del software (la famosa “Gang of Four”): Erich Gamma, Richard Helm, Ralph Johnson e John Vlissides. Questo libro ha introdotto un linguaggio comune per descrivere soluzioni a problemi ricorrenti nello sviluppo software.

Ogni design pattern identifica un problema generalizzato e associa ad esso una soluzione. Spesso, questi pattern sono rappresentati con diagrammi UML per facilitarne la comprensione.


Perché Usare i Design Pattern? 🤔

  1. Riutilizzabilità: Perché reinventare la ruota quando puoi prendere in prestito una soluzione già testata?
  2. Manutenibilità: Codice più leggibile e organizzato significa meno mal di testa per te (e per chi erediterà il tuo progetto).
  3. Comunicazione: Dire “Usiamo il Singleton” è molto più veloce che spiegare tutto il concetto da zero.
  4. Soluzioni Provate: I design pattern sono frutto di anni di esperienza e casi d’uso reali, quindi puoi fidarti che funzionano.

Le Tre Categorie Principali

Il libro della Gang of Four suddivide i design pattern in tre categorie principali (le liste sotto NON sono esaustive: mostro i più rappresentativi):

1. Pattern Creazionali 🏗️

Questi pattern si concentrano sulla creazione degli oggetti, isolando la logica di costruzione dal resto del codice.

  • Singleton: Garantisce che una classe abbia una sola istanza.
  • Factory Method: Definisce un’interfaccia per creare oggetti, lasciando alle sottoclassi la decisione su quale classe istanziare.
  • Abstract Factory: Permette di creare famiglie di oggetti correlati senza specificare le loro classi concrete.
  • Builder: Consente di costruire oggetti complessi passo dopo passo.
  • Prototype: Crea nuovi oggetti clonando un’istanza esistente.

Nota sul Singleton in Go

In Go raramente si implementa un “Singleton” classico (non ci sono classi); di solito si sfrutta l’inizializzazione a livello di package oppure sync.Once per inizializzazioni pigre e thread-safe:

1
2
3
4
5
6
7
8
9
var once sync.Once
var instance *Repo

func GetRepo() *Repo {
  once.Do(func() {
    instance = &Repo{/* ... */}
  })
  return instance
}

Esempio di Factory Method in Go

 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
28
29
30
31
32
33
34
package main

import (
  "errors"
  "fmt"
)

type Product interface { Use() string }

type ConcreteProductA struct{}
func (ConcreteProductA) Use() string { return "Using Product A" }

type ConcreteProductB struct{}
func (ConcreteProductB) Use() string { return "Using Product B" }

// FactoryMethod restituisce un Product o un errore se il tipo non è supportato.
func FactoryMethod(productType string) (Product, error) {
  switch productType {
  case "A":
    return ConcreteProductA{}, nil
  case "B":
    return ConcreteProductB{}, nil
  default:
    return nil, errors.New("tipo prodotto sconosciuto: " + productType)
  }
}

func main() {
  product, err := FactoryMethod("A")
  if err != nil {
    panic(err)
  }
  fmt.Println(product.Use()) // Using Product A
}

Variante minimalista: se vuoi evitare l’errore potresti restituire un Null Object che implementa Use() restituendo una stringa vuota.


2. Pattern Strutturali 🧱

Questi pattern si occupano della composizione delle classi e degli oggetti, rendendo il sistema più flessibile ed efficiente.

  • Adapter: Permette a due interfacce incompatibili di lavorare insieme.
  • Bridge: Divide un’astrazione dalla sua implementazione, permettendo loro di evolvere indipendentemente.
  • Composite: Compone oggetti in strutture ad albero trattando in modo uniforme singoli e composti.
  • Decorator: Aggiunge dinamicamente funzionalità a un oggetto.
  • Facade: Fornisce un’interfaccia semplificata a un sistema complesso.
  • Flyweight: Riduce il consumo di memoria condividendo dati tra oggetti simili.
  • Proxy: Fornisce un surrogato o un placeholder per controllare l’accesso a un oggetto.

Esempio di Decorator in Go

 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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package main

import "fmt"

// Component interface
type Coffee interface {
  Cost() int
  Description() string
}

// Concrete Component
type SimpleCoffee struct{}

func (c SimpleCoffee) Cost() int {
  return 5
}

func (c SimpleCoffee) Description() string {
  return "Simple Coffee"
}

// Decorator
type MilkDecorator struct {
  coffee Coffee
}

func (m MilkDecorator) Cost() int {
  return m.coffee.Cost() + 2
}

func (m MilkDecorator) Description() string {
  return m.coffee.Description() + ", Milk"
}

type SugarDecorator struct {
  coffee Coffee
}

func (s SugarDecorator) Cost() int {
  return s.coffee.Cost() + 1
}

func (s SugarDecorator) Description() string {
  return s.coffee.Description() + ", Sugar"
}

func main() {
  coffee := SimpleCoffee{}
  fmt.Println(coffee.Description(), "Cost:", coffee.Cost()) // Simple Coffee Cost: 5

  coffeeWithMilk := MilkDecorator{coffee: coffee}
  fmt.Println(coffeeWithMilk.Description(), "Cost:", coffeeWithMilk.Cost()) // Simple Coffee, Milk Cost: 7

  coffeeWithMilkAndSugar := SugarDecorator{coffee: coffeeWithMilk}
  fmt.Println(coffeeWithMilkAndSugar.Description(), "Cost:", coffeeWithMilkAndSugar.Cost()) // Simple Coffee, Milk, Sugar Cost: 8
}

3. Pattern Comportamentali 🎭

Questi pattern si concentrano sulle interazioni tra gli oggetti e sulla distribuzione delle responsabilità.

  • Observer: Notifica automaticamente gli osservatori quando lo stato di un oggetto cambia.
  • Strategy: Permette di selezionare dinamicamente un algoritmo tra diversi disponibili.
  • Command: Incapsula una richiesta come oggetto, permettendo di parametrizzare i client con richieste diverse.
  • State: Permette a un oggetto di cambiare comportamento in base al suo stato interno.
  • Template Method: Definisce il “guscio” di un algoritmo, delegando i dettagli alle sottoclassi.
  • Visitor: Separa un algoritmo dalla struttura dati su cui opera.
  • Chain of Responsibility: Passa una richiesta lungo una catena finché qualcuno la gestisce.
  • Mediator: Centralizza la comunicazione riducendo le dipendenze a rete.
  • Memento: Esternalizza e ripristina lo stato senza violare l’incapsulamento.
  • Iterator: Fornisce un modo uniforme di attraversare una collezione.
  • Interpreter: Definisce una grammatica e un interprete per linguaggi semplici.

Esempio di Strategy in Go

 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
package main

import "fmt"

// Strategy definisce l'interfaccia dell'algoritmo intercambiabile
type Strategy interface { Execute(a, b int) int }

type Add struct{}
func (Add) Execute(a, b int) int { return a + b }

type Multiply struct{}
func (Multiply) Execute(a, b int) int { return a * b }

// Context usa una Strategy senza conoscerne i dettagli concreti
type Calculator struct { strategy Strategy }

func (c *Calculator) SetStrategy(s Strategy) { c.strategy = s }
func (c *Calculator) Compute(a, b int) int { return c.strategy.Execute(a, b) }

func main() {
  calc := &Calculator{}
  calc.SetStrategy(Add{})
  fmt.Println("Add:", calc.Compute(3, 4)) // Add: 7

  calc.SetStrategy(Multiply{})
  fmt.Println("Multiply:", calc.Compute(3, 4)) // Multiply: 12
}

Nota: il valore del pattern emerge quando il Context cambia strategia a runtime (es. algoritmi di compressione, formati di serializzazione, politiche di pricing, ecc.).


Risorse Utili 📚


Conclusione

I design pattern sono strumenti potenti, ma come ogni strumento, vanno usati con giudizio. Non complicare inutilmente il tuo codice solo per “usare un pattern”. Ricorda: il buon senso è il miglior design pattern di tutti. 😉


Quando NON Usarli (o usarli con cautela)

  • Over-engineering: Se una funzione da 20 righe basta, non introdurre 5 interfacce.
  • Prematuro: Applicare pattern prima che emerga un bisogno reale può irrigidire il design.
  • Astrazione eccessiva: Troppi livelli rendono il debugging doloroso.
  • Copiati meccanicamente: Capire il perché prima del come.

Regola d’oro: parti semplice, poi estrai il pattern quando riconosci un problema ricorrente.

Ultimo aggiornamento il