Salta al contenuto
Note per Studenti Design pattern - introduzione e catalogo GoF

Design pattern - introduzione e catalogo GoF

In questa pagina 7

Definizione (pattern). "Un pattern descrive un problema che si ripresenta di continuo nel nostro ambiente e poi ne descrive il nucleo della soluzione, in modo da poterla usare un milione di volte senza mai farlo due volte nello stesso modo" (Christopher Alexander, architetto). In sintesi: una soluzione ricorrente a un problema standard, in un contesto. Per l'ingegneria del software: una coppia nome-problema/soluzione applicabile in contesti nuovi, con consigli su come adattarla (Craig Larman).

Perché servono: nelle discipline mature i progettisti non ricavano ogni progetto dalle leggi della fisica ma riusano progetti già collaudati (i manuali, i "prontuari"); scrivere software da zero è costoso, e i pattern permettono di riusare l'esperienza di progetto, non solo il codice. Un pattern è come il cartamodello di un abito: dice che cosa si sta costruendo e come verificarlo, invece di dare le coordinate di ogni taglio. Un secondo vantaggio è il vocabolario: dire "qui c'è un Observer" comunica in una parola struttura e intenzioni. Questa nota introduce il catalogo e i principi; le famiglie sono in Pattern creazionali - Singleton, Factory Method, Abstract Factory, Builder e PrototypeI pattern creazionali isolano la creazione degli oggetti dal codice che li usa: Singleton garantisce un'unica istanza (costruttore privato, metodo statico getInstance; la versione lazy ingenua non è thread-safe, si correggono con synchronized, inizializzazione eager, double-checked locking con volatile, holder idiom o enum); Factory Method lascia alle sottoclassi la scelta della classe da istanziare; Abstract Factory crea famiglie coerenti di prodotti correlati tramite un'interfaccia di fabbrica; Builder costruisce passo passo oggetti complessi con campi facoltativi; Prototype crea oggetti clonando un prototipo (copia profonda delle parti mutabili); la Simple Factory non è un pattern GoF ma sposta il codice di creazione in una classe sola.Pattern creazionali - Singleton, Factory Method, Abstract Factory, Builder e Prototype →, Pattern strutturali - Adapter, Decorator, Composite, Facade e ProxyI pattern strutturali compongono classi e oggetti in strutture più grandi: Adapter converte l'interfaccia di una classe in quella attesa dal client (di oggetto, per composizione, o di classe, per ereditarietà multipla di classe e interfaccia); Decorator aggiunge responsabilità a un oggetto con un involucro che ha la stessa interfaccia (l'ordine dei decoratori conta); Composite tratta in modo uniforme foglie e contenitori in una struttura ad albero; Facade offre un'interfaccia semplice a un sottosistema (non cerca il polimorfismo, a differenza dell'Adapter); Proxy è un sostituto con la stessa interfaccia che controlla l'accesso (virtuale, di protezione, di cache, dinamico); Bridge e Flyweight completano le sette forme del catalogo.Pattern strutturali - Adapter, Decorator, Composite, Facade e Proxy →, Pattern comportamentali - Observer, Strategy, Command, Iterator e Template MethodI pattern comportamentali descrivono come gli oggetti collaborano e si dividono le responsabilità: Observer (publish-subscribe) avvisa automaticamente i dipendenti quando il soggetto cambia stato, mantenendo basso l'accoppiamento (si registrano e cancellano a runtime, modello push o pull, attenzione alle modifiche durante la notifica); Strategy rende intercambiabili gli algoritmi dietro un'interfaccia (in Java anche con una lambda); Command trasforma la richiesta in un oggetto, quindi memorizzabile, accodabile e annullabile (undo); Iterator scorre un aggregato senza esporne la struttura (for-each, fail-fast); Template Method fissa lo scheletro di un algoritmo e lascia alle sottoclassi passi astratti o hook (principio di Hollywood); State, Visitor, Chain of Responsibility, Mediator, Memento e Interpreter completano le undici forme del catalogo.Pattern comportamentali - Observer, Strategy, Command, Iterator e Template Method →; i criteri per assegnare le responsabilità a monte dei pattern sono in Principi GRASP per l'assegnazione delle responsabilitàGRASP (General Responsibility Assignment Software Patterns) sono principi per assegnare le responsabilità alle classi, dove una responsabilità è un obbligo di conoscere (knowing) o di fare (doing): Creator (chi crea A? la classe che contiene o registra A, la usa da vicino o ha i dati per inizializzarla), Information Expert (la classe che ha le informazioni necessarie), Low Coupling (poche dipendenze tra classi per ridurre l'impatto dei cambiamenti), Controller (chi riceve gli eventi di sistema? un oggetto che rappresenta il sistema o il caso d'uso, che coordina e non calcola), High Cohesion (classi focalizzate su un unico scopo); si completano con Polymorphism, Pure Fabrication, Indirection e Protected Variations, che sono alla base di molti pattern GoF.Principi GRASP per l'assegnazione delle responsabilità →.

Tre famiglie di pattern, due vocabolari

famiglia che cosa descrive nel corso
GRASP (General Responsibility Assignment Software Patterns) principi generali per assegnare le responsabilità alle classi e progettare le interazioni Principi GRASP per l'assegnazione delle responsabilitàGRASP (General Responsibility Assignment Software Patterns) sono principi per assegnare le responsabilità alle classi, dove una responsabilità è un obbligo di conoscere (knowing) o di fare (doing): Creator (chi crea A? la classe che contiene o registra A, la usa da vicino o ha i dati per inizializzarla), Information Expert (la classe che ha le informazioni necessarie), Low Coupling (poche dipendenze tra classi per ridurre l'impatto dei cambiamenti), Controller (chi riceve gli eventi di sistema? un oggetto che rappresenta il sistema o il caso d'uso, che coordina e non calcola), High Cohesion (classi focalizzate su un unico scopo); si completano con Polymorphism, Pure Fabrication, Indirection e Protected Variations, che sono alla base di molti pattern GoF.Principi GRASP per l'assegnazione delle responsabilità →
GoF (Gang of Four, dal libro Design Patterns di Gamma, Helm, Johnson, Vlissides, 1995) 23 pattern di progetto orientato agli oggetti, dati in C++ e Smalltalk questa nota e le tre successive
architetturali organizzazione a grande scala (livelli, MVC...) Architettura del software - decisioni, organizzazione e stiliL'architettura del software descrive come un sistema è organizzato in componenti, le loro relazioni e la loro distribuzione; è il ponte tra requisiti e progetto dettagliato, e le sue decisioni derivano soprattutto dai requisiti non funzionali (prestazioni, sicurezza, disponibilità, manutenibilità), spesso in conflitto tra loro; si descrive con più viste (logica, processi, sviluppo, fisica); gli stili di organizzazione ricorrenti sono a livelli (layered), MVC, repository, client-server, pipe and filter, publish-subscribe e microservizi; la modularizzazione mira ad alta coesione e basso accoppiamento, nascondendo le decisioni dietro interfacce (information hiding, inversione delle dipendenze).Architettura del software - decisioni, organizzazione e stili →

Gli elementi di un pattern

Ogni pattern GoF ha quattro elementi essenziali:

elemento contenuto
nome aumenta il vocabolario dei progettisti; permette di discutere senza i dettagli
problema intento, contesto, quando applicarlo, sintomi di un progetto rigido
soluzione classi e oggetti coinvolti, relazioni, responsabilità e collaborazioni; è un modello, non un progetto concreto né un'implementazione
conseguenze risultati e compromessi (spazio, tempo, flessibilità)

Nel libro e nelle note ogni pattern si descrive con lo stesso schema:

sezione contenuto
nome e classificazione il nome del pattern e la sua famiglia
intento che cosa fa, perché esiste, quale problema affronta
also known as altri nomi (Adapter = Wrapper)
motivazione uno scenario che illustra il problema
applicabilità situazioni in cui usarlo e progetti scadenti che risolve
struttura diagramma delle classi (OMT/UML) e di interazione
partecipanti classi e oggetti e le loro responsabilità
conseguenze vantaggi, svantaggi, quale aspetto del sistema permette di variare in modo indipendente
implementazione trucchi, insidie, aspetti legati al linguaggio
codice d'esempio in questa parte: Java, tutto compilato
usi noti almeno due sistemi reali in domini diversi
pattern correlati differenze e combinazioni

Il catalogo: 23 pattern

Le tre famiglie e la tabella completa. I pattern in grassetto sono trattati con esempio completo nelle note successive; gli altri sono solo descritti.

Creazionali (5): come si creano gli oggetti

pattern intento in una riga
Singleton garantire un'unica istanza di una classe e un punto di accesso globale
Factory Method un metodo che le sottoclassi ridefiniscono per decidere quale classe istanziare
Abstract Factory creare famiglie di oggetti correlati senza nominarne le classi concrete
Builder costruire un oggetto complesso passo per passo, separando costruzione e rappresentazione
Prototype creare nuovi oggetti clonando un'istanza prototipo

Strutturali (7): come si compongono classi e oggetti

pattern intento in una riga
Adapter convertire l'interfaccia di una classe in quella che il client si aspetta (wrapper)
Decorator aggiungere responsabilità a un oggetto in modo dinamico e trasparente
Composite trattare in modo uniforme oggetti singoli e composizioni ricorsive (strutture ad albero)
Facade un'interfaccia semplice a un sottosistema complesso
Proxy un sostituto che controlla l'accesso a un altro oggetto
Bridge separare un'astrazione dalla sua implementazione perché varino indipendentemente
Flyweight condividere in modo efficiente un gran numero di oggetti a grana fine

Comportamentali (11): come collaborano gli oggetti

pattern intento in una riga
Observer quando un oggetto cambia stato, i dipendenti sono avvisati e si aggiornano
Strategy una famiglia di algoritmi intercambiabili
Command la richiesta diventa un oggetto: si può memorizzare, accodare, annullare
Iterator scorrere gli elementi di un aggregato senza esporne la struttura
Template Method lo scheletro di un algoritmo con alcuni passi forniti dalle sottoclassi
Chain of Responsibility la richiesta passa lungo una catena finché qualcuno la gestisce
Interpreter interprete per un linguaggio con una piccola grammatica
Mediator un oggetto coordina le interazioni di un gruppo di colleghi
Memento catturare e ripristinare lo stato di un oggetto senza violarne l'incapsulamento
State il comportamento di un oggetto dipende dal suo stato interno
Visitor operazioni da applicare agli elementi di una struttura eterogenea, senza modificarne le classi

Come si legge una tabella di struttura. Nelle note successive la struttura è data come tabella di ruoli, non con un diagramma: per ogni pattern si elencano i ruoli (per esempio Target, Adapter, Adaptee, Client), la classe del programma che li interpreta e la relazione. Per ricordare la notazione UML delle classi: ereditarietà (freccia con triangolo vuoto: is-a), realizzazione di interfaccia (tratteggiata con triangolo vuoto), associazione (linea o freccia: "usa"), aggregazione (rombo vuoto: "ha", le parti sopravvivono al tutto), composizione (rombo pieno: "è composto da", le parti muoiono con il tutto), dipendenza (freccia tratteggiata: "usa temporaneamente").

I principi dietro i pattern

I pattern sono applicazioni di pochi principi; riconoscerli aiuta a capire e a inventare varianti.

principio significato
programmare verso un'interfaccia (code to an interface) le variabili e i parametri hanno il tipo di un'interfaccia o di una classe astratta, non di una classe concreta
incapsulare ciò che varia si separa la parte che cambia da quella che resta uguale (la new di una classe concreta, l'algoritmo di calcolo, il canale di notifica)
preferire la composizione all'ereditarietà l'ereditarietà è statica e crea sottoclassi per ogni combinazione; la composizione si cambia a runtime
principio aperto/chiuso (OCP) le classi sono aperte all'estensione (aggiungere un tipo) e chiuse alla modifica (non si riscrive codice funzionante)
inversione delle dipendenze (DIP) dipendere dalle astrazioni, non dalle classi concrete: sia il codice di alto livello sia quello di basso livello dipendono da un'interfaccia
responsabilità unica una classe ha un solo motivo per cambiare (alta coesione, vedi Principi GRASP per l'assegnazione delle responsabilitàGRASP (General Responsibility Assignment Software Patterns) sono principi per assegnare le responsabilità alle classi, dove una responsabilità è un obbligo di conoscere (knowing) o di fare (doing): Creator (chi crea A? la classe che contiene o registra A, la usa da vicino o ha i dati per inizializzarla), Information Expert (la classe che ha le informazioni necessarie), Low Coupling (poche dipendenze tra classi per ridurre l'impatto dei cambiamenti), Controller (chi riceve gli eventi di sistema? un oggetto che rappresenta il sistema o il caso d'uso, che coordina e non calcola), High Cohesion (classi focalizzate su un unico scopo); si completano con Polymorphism, Pure Fabrication, Indirection e Protected Variations, che sono alla base di molti pattern GoF.Principi GRASP per l'assegnazione delle responsabilità →)

Linee guida del corso per l'inversione delle dipendenze (sono linee guida, non regole assolute: seguite alla lettera non si riuscirebbe a produrre un sistema eseguibile):

  • nessuna variabile dovrebbe contenere un riferimento a una classe concreta;
  • nessuna classe dovrebbe derivare da una classe concreta;
  • nessun metodo dovrebbe ridefinire un metodo già implementato di una classe base.

Il problema di new

Ogni new di una classe concreta viola "programma verso un'interfaccia": anche se la variabile ha il tipo dell'interfaccia (Duck d = new DecoyDuck()), la classe che contiene la riga dipende dalla classe concreta. Un if che sceglie quale classe creare in base a una condizione dipende da tutte le classi concrete che nomina, e va modificato (e ricompilato) a ogni nuova classe: viola l'aperto/chiuso e "incapsula ciò che varia". Le famiglie di pattern creazionali sono la risposta (Pattern creazionali - Singleton, Factory Method, Abstract Factory, Builder e PrototypeI pattern creazionali isolano la creazione degli oggetti dal codice che li usa: Singleton garantisce un'unica istanza (costruttore privato, metodo statico getInstance; la versione lazy ingenua non è thread-safe, si correggono con synchronized, inizializzazione eager, double-checked locking con volatile, holder idiom o enum); Factory Method lascia alle sottoclassi la scelta della classe da istanziare; Abstract Factory crea famiglie coerenti di prodotti correlati tramite un'interfaccia di fabbrica; Builder costruisce passo passo oggetti complessi con campi facoltativi; Prototype crea oggetti clonando un prototipo (copia profonda delle parti mutabili); la Simple Factory non è un pattern GoF ma sposta il codice di creazione in una classe sola.Pattern creazionali - Singleton, Factory Method, Abstract Factory, Builder e Prototype →).

Esempio minimo (biblioteca): SollecitoRigido crea da sé una NotificaMail e per usare gli SMS bisogna modificarla; Sollecito riceve un Notificatore dall'esterno (dependency injection) e non cambia. Per aggiungere un registro (log) a qualunque canale si usa un oggetto che lo avvolge (composizione, pattern Decorator) invece di una sottoclasse per ogni coppia.

java
/* Principi che stanno dietro ai pattern: programmare verso un'interfaccia, incapsulare cio' che varia,
   preferire la composizione all'ereditarieta', dipendere dalle astrazioni. */

interface Notificatore {                       // l'astrazione da cui dipende il codice di alto livello
    void invia(String destinatario, String testo);
}

class NotificaMail implements Notificatore {
    public void invia(String d, String t) { System.out.println("mail a " + d + ": " + t); }
}

class NotificaSms implements Notificatore {
    public void invia(String d, String t) { System.out.println("sms a " + d + ": " + t); }
}

/* PRIMA: la classe di alto livello dipende dalla classe concreta e la crea con new (violazione di DIP e di OCP). */
class SollecitoRigido {
    private final NotificaMail mail = new NotificaMail();
    void sollecita(String utente) { mail.invia(utente, "restituisci il libro"); }
}

/* DOPO: dipende dall'interfaccia, che riceve da fuori (dependency injection): cambiare canale non tocca questa classe. */
class Sollecito {
    private final Notificatore canale;
    Sollecito(Notificatore canale) { this.canale = canale; }
    void sollecita(String utente) { canale.invia(utente, "restituisci il libro"); }
}

/* Ereditarieta' contro composizione: una sottoclasse per ogni combinazione esplode (SollecitoMailConLog, SollecitoSmsConLog...).
   Con la composizione il log e' un oggetto che si aggiunge a qualunque canale. */
class NotificaConLog implements Notificatore {
    private final Notificatore interno;
    NotificaConLog(Notificatore interno) { this.interno = interno; }
    public void invia(String d, String t) { System.out.println("[log] invio a " + d); interno.invia(d, t); }
}

public class Principi {
    public static void main(String[] args) {
        new SollecitoRigido().sollecita("anna");
        new Sollecito(new NotificaMail()).sollecita("anna");
        new Sollecito(new NotificaSms()).sollecita("luca");
        new Sollecito(new NotificaConLog(new NotificaSms())).sollecita("luca");
    }
}
mail a anna: restituisci il libro
mail a anna: restituisci il libro
sms a luca: restituisci il libro
[log] invio a luca
sms a luca: restituisci il libro

Come scegliere e usare un pattern

  1. Partire dal problema, non dal pattern: quale parte del progetto è rigida, duplicata o difficile da cambiare? Quale aspetto deve poter variare (la classe creata, l'algoritmo, il formato, il numero dei dipendenti)?
  2. Cercare nel catalogo i pattern con intento corrispondente e confrontarli (per esempio Adapter e Facade hanno entrambi un'interfaccia nuova per classi esistenti ma intenti diversi).
  3. Leggere conseguenze e pattern correlati prima di decidere: ogni pattern costa complessità (classi in più, indirezione).
  4. Adattare il pattern al linguaggio (in Java le lambda sostituiscono Strategy e Command a un solo metodo; Iterator e Observer esistono già nel JDK).
  5. Non usarlo se il problema non c'è: un pattern applicato "per principio" a un programma semplice è over-engineering (il costo si paga nella manutenzione, vedi Ingegneria del software - prodotto software e processo di sviluppoL'ingegneria del software (IdS) è la disciplina che copre tutti gli aspetti della produzione di software professionale, dalle prime specifiche alla manutenzione: il software sono programmi più documentazione, può essere generico (venduto sul mercato) o specifico (su misura per un cliente), e un buon prodotto ha quattro attributi (manutenibilità, affidabilità e sicurezza, efficienza, accettabilità); le attività fondamentali sono specifica, sviluppo, validazione ed evoluzione; i costi sono dominati dalla manutenzione e un difetto di requisiti scoperto dopo il rilascio costa fino a 100 volte uno scoperto subito; i tre problemi di fondo sono incertezza, irreversibilità e complessità; verifica ("costruiamo bene il prodotto?") e validazione ("costruiamo il prodotto giusto?") sono cose diverse.Ingegneria del software - prodotto software e processo di sviluppo →).

Errori comuni: usare i pattern come fine (avere un Singleton "perché fa moderno"); confondere pattern e idiom (una soluzione specifica di un linguaggio, per esempio try-with-resources); confondere pattern e stile architetturale (stessa idea, scala diversa, vedi Architettura del software - decisioni, organizzazione e stiliL'architettura del software descrive come un sistema è organizzato in componenti, le loro relazioni e la loro distribuzione; è il ponte tra requisiti e progetto dettagliato, e le sue decisioni derivano soprattutto dai requisiti non funzionali (prestazioni, sicurezza, disponibilità, manutenibilità), spesso in conflitto tra loro; si descrive con più viste (logica, processi, sviluppo, fisica); gli stili di organizzazione ricorrenti sono a livelli (layered), MVC, repository, client-server, pipe and filter, publish-subscribe e microservizi; la modularizzazione mira ad alta coesione e basso accoppiamento, nascondendo le decisioni dietro interfacce (information hiding, inversione delle dipendenze).Architettura del software - decisioni, organizzazione e stili →); dimenticare che un pattern è un modello da adattare, non codice da copiare.

Progettare gli oggetti con le responsabilità

Larman inquadra i pattern nella progettazione guidata dalle responsabilità (responsibility-driven design): prima di scegliere un pattern GoF si decide chi fa che cosa. Gli ingredienti da cui si parte: testo dei casi d'uso, diagramma di sequenza di sistema, contratti delle operazioni, specifica supplementare (requisiti non funzionali), glossario, modello di dominio. Le responsabilità sono di due tipi (sapere e fare) e si assegnano con i principi GRASP (Principi GRASP per l'assegnazione delle responsabilitàGRASP (General Responsibility Assignment Software Patterns) sono principi per assegnare le responsabilità alle classi, dove una responsabilità è un obbligo di conoscere (knowing) o di fare (doing): Creator (chi crea A? la classe che contiene o registra A, la usa da vicino o ha i dati per inizializzarla), Information Expert (la classe che ha le informazioni necessarie), Low Coupling (poche dipendenze tra classi per ridurre l'impatto dei cambiamenti), Controller (chi riceve gli eventi di sistema? un oggetto che rappresenta il sistema o il caso d'uso, che coordina e non calcola), High Cohesion (classi focalizzate su un unico scopo); si completano con Polymorphism, Pure Fabrication, Indirection e Protected Variations, che sono alla base di molti pattern GoF.Principi GRASP per l'assegnazione delle responsabilità →); i modelli dinamici (diagrammi di interazione) aiutano a progettare la logica e sono i più difficili e importanti, i modelli statici (diagrammi delle classi) definiscono pacchetti, classi, attributi e firme. Contano le capacità di progettare oggetti, non quella di disegnare UML: il disegno è un riflesso delle decisioni.

Domande d'esame

1. Che cos'è un design pattern? Quali sono gli elementi essenziali e perché è utile avere un nome? Traccia. Soluzione generale, riusabile e collaudata a un problema ricorrente in un contesto. Elementi: nome, problema, soluzione (struttura e responsabilità, non implementazione), conseguenze. Il nome crea un vocabolario comune e permette di ragionare sul progetto senza entrare nei dettagli.

2. Classificare Singleton, Adapter, Observer, Factory Method, Decorator, Strategy nelle tre famiglie GoF e dire per ognuno che problema risolve. Traccia. Creazionali: Singleton (istanza unica), Factory Method (classe creata decisa dalle sottoclassi). Strutturali: Adapter (interfaccia incompatibile), Decorator (responsabilità aggiunte dinamicamente). Comportamentali: Observer (notifica dei cambiamenti di stato), Strategy (algoritmi intercambiabili).

3. Perché l'istruzione Duck d = new DecoyDuck(); viola un principio di progetto e come si rimedia? Traccia. Anche con la variabile di tipo interfaccia, la classe dipende dalla classe concreta; con una scelta condizionale dipende da tutte e va modificata a ogni nuova classe (viola aperto/chiuso). Si isola la creazione in una fabbrica (Factory Method, Abstract Factory) e si inietta la dipendenza, così il codice di alto livello dipende solo dall'interfaccia (inversione delle dipendenze).

Versione ripasso

principio esempio
interfaccia Notificatore canale, non NotificaMail
incapsulare ciò che varia la scelta del canale (mail, SMS) fuori da Sollecito
composizione NotificaConLog(new NotificaSms()) e non una sottoclasse per ogni coppia
aperto/chiuso nuovo canale = nuova classe, nessuna modifica a Sollecito
java
class Sollecito {
    private final Notificatore canale;
    Sollecito(Notificatore canale) { this.canale = canale; }       // iniettato dall'esterno
    void sollecita(String utente) { canale.invia(utente, "restituisci il libro"); }
}
new Sollecito(new NotificaConLog(new NotificaSms())).sollecita("luca");   // log + SMS

Esercizi su questo argomento

Teoria collegata