martedì 16 febbraio 2021

Structural Patterns

Pattern strutturali

Ci sono vari modi conosciuti in letteratura in cui classi o oggetti possono combinarsi per ottenere nuovi oggetti, sia a livello statico/strutturale ossia a inerente le classi, che dinamicamente, a livello di istanza/oggetto. Ne presenterò i principali, sintetizzandone le caratteristiche.
 

Adapter 

Anche detto wrapper, modifica l'interfaccia di una classe per renderla compatibile con l'interfaccia richiesta da un client esistente. 
In questo pattern distinguiamo: 
  • un client A, una classe o funzione che  utilizza un servizio avente una certa interfaccia IService
  • una classe di servizi definita da una interfaccia IService
  • un servizio B esistente "da adattare", che espone una interfaccia diversa da IService
  • una classe "Adapter" che espone l'interfaccia IService utilizzando un oggetto di tipo B (in questo caso parliamo di object adapter) o derivando dalla classe B (in questo caso parliamo di class adapter)
Abbiamo dunque due tipi di adapter a seconda che utilizzino l'ereditarietà (class adapter) o la composizione (object adapter). La classe adapter può anche servire ad isolare, per il futuro, eventuali cambiamenti dell'interfaccia di B, per esempio perché sviluppata da terze parti. In tal modo l'applicazione client utilizzerà sempre l'interfaccia esposta dall'adapter a prescindere dal rilascio di nuove versioni di B.
E' da notare che l'interfaccia dell'adapter, pur differenziandosi da quella di B, non varia radicalmente le funzioni svolte, altrimenti non si tratta più di un adapter ma di un altro dei pattern sottoelencati. 
Può anche servire a semplificare l'interfaccia di un servizio molto complesso, magari in vista di un suo completo refactoring.
Un'ulteriore funzionalità che si può realizzare con un object adapter è la possibilità di collegarlo dinamicamente a diversi oggetti (non solo di tipo B) in modo tale che il client possa vederli con un'interfaccia omogenea.
L'adapter può anche servire a modificare il tipo di parametri, per esempio con delle conversioni di valuta o di unità di misura dai parametri in input a quelli effettivamente passati al servizio, così come convertire il tipo del risultato.

Bridge

Serve a separare nettamente l'astrazione/interfaccia di un servizio/classe dalla sua implementazione, così che sia possibile cambiare l'implementazione, o usarne diverse, eventualmente anche cambiandole a run-time, senza cambiare il codice dei client.

Inizialmente ci si potrebbe chiedere come mai dovrebbe esserci bisogno di un pattern apposito per ottenere quello che è già disponibile nella maggior parte dei linguaggi ad oggetti nello strumento delle "interfacce" (interfaces e via dicendo) delle classi. Può capitare perché ad esempio il servizio utilizzato è fornito da una terza parte, oppure perché fa parte di una libreria esistente che non è stata progettata in termini di interfaccia/classe che la implementa.
Ecco che può divenire molto utile stabilire a posteriori un'interfaccia, magari inizialmente identica a quella della classe esistente, o leggermente diversa (un adapter può aiutare). Ancor più se si tratta di una gerarchia di classi, potrebbe essere utile creare in corrispondenza una gerarchia di bridge, raggruppando ove possibile le classi che espongono una stessa interfaccia, ottenendo alla fine di poter operare con un numero inferiore di interfacce rispetto alle classi presenti nel sistema.
Ma non è l'unico caso in cui può servire un bridge. Supponiamo di avere una gerarchia in cui ci sono diverse classi con funzioni simili e vogliamo aggiungere una certa funzionalità ad ognuna di esse, ma non vogliamo raddoppiare il loro numero per creare un classe derivata da ognuna.

In questo pattern distinguiamo:
  • un'interfaccia astratta I
  • un'interfaccia I2 derivata da quella astratta
  • un'interfaccia IC (implementor) che le classi concrete implementanti devono esporre, e tipicamente sono funzioni "primitive", di livello più basso rispetto ad I
  • le classi (concrete implementor) che espongono l'interfaccia IC
La classe "Bridge" utilizza le classi implementanti con l'interfaccia uniforme IC,(un adapter può servire allo scopo). La dipendenza dei client sarà sulla classe bridge anziché sulle classi concrete. Questo può anche essere utile a rendere i client indipendenti dalla piattaforma, i dettagli sulle classi concrete sono completamente nascosti dal bridge, ed il bridge può essere inoltre usato come classe base per creare ulteriori classi.

Composite

Serve a comporre oggetti in strutture gerarchiche che vengono viste dai client come un unico servizio, e gli oggetti in esso contenuti possono essere manipolati in modo omogeneo.
Nella gerarchia distinguiamo i nodi composti, che contengono altri nodi, e i nodi foglia. La gerarchia può includere classi diverse, purché espongano l'interfaccia IComponent.
In questo pattern distinguiamo:
  • un client, che manipola gli oggetti tramite l'interfaccia IComponent
  • l'interfaccia IComponent, un elenco di metodi che si applicano all'intera struttura
  • Composite: è il comportamento dei metodi per i nodi non foglia
  • Leaf: è il comportamenti dei metodi per i nodi foglia
La composizione rende il client molto semplice nel gestire il gruppo di oggetti che implementano l'interfaccia IComponent, potendo agire, con semplici comandi, su tutti gli oggetti del gruppo.
Rende semplice aggiungere nuovi tipi di componenti, a patto che implementino la stessa interfaccia.
Rende possibile gestire oggetti di tipo diverso con un'interfaccia comune, al prezzo però di avere dei metodi comuni e quindi non poter usare tutti i possibili metodi, più specifici, che sarebbero disponibili sulle classi concrete. Per accedere a quelli, è necessario operare dei check a run time sui tipi effettivi o meccanismi simili.


Decorator

Anche detto wrapper, serve ad aggiungere nuove funzionalità ad oggetti esistenti.
Nella programmazione ad oggetti, il modo classico per aggiungere funzionalità ad una classe esistente è di crearne una sottoclasse. Questo però a volte può essere poco pratico, per esempio perché vogliamo poter aggiungere diversi set di funzionalità, o vogliamo avere la possibilità di revocarle.
Questo pattern è anche detto wrapper perché per modificare le funzioni dell'oggetto originale, ne mantiene un riferimento e le richiama a sua volta, "avvolgendole", o "decorandole" con altri comportamenti, ad esempio effettuando elaborazioni aggiuntive prima o dopo l'invocazione delle funzionalità originali.
In questo pattern distinguiamo:
  • un'interfaccia IComponent, dell'oggetto a cui verranno aggiunte le funzionalità
  • un oggetto / classe Component, a cui verranno aggiunte le funzionalità
  • la classe Decorator, che ha la stessa interfaccia IComponent.
  • una o più classi che derivano da Decorator e aggiungono funzionalità a quelle di Component
La classe Decorator non fa altro che mantenere un riferimento all'oggetto Component e in maniera trasparente invoca i metodi di Component e ne restituisce l'output.
L'aggiunta di funzionalità avviene nelle classi che derivano da Decorator, in cui i metodi della classe base sono richiamati ed arricchiti di altre funzionalità.
Ad esempio supponiamo di voler "decorare" una classe 

interface IBanca {
    TransactionResult doTransaction(Transaction t);
}

class Banca:IBanca {
    public TransactionResult doTransaction(Transaction t){
        ..
    }
}


e supponiamo di voler aggiungere un'attività di log ad ogni transazione effettuata.
Andremo a definire una classe BancaDecorator del tipo:

class BancaDecorator:IBanca {
   IBanca banca;
   public BancaDecorator(IBanca banca){
    this.banca=banca;
   }

    public TransactionResult doTransaction(Transaction t){
        return banca.doTransaction(t);
    }
}


A questo punto possiamo definire la nostra classe BancaLogged:
class BancaLogged:BancaDecorator {
  Logger log;
   public BancaLogged(IBanca banca):base(banca){
    this.log = Logger.getSingleton(); // supponiamo ci sia un solo logger nel sistema
   }

    public TransactionResult doTransaction(Transaction t){
        TransactionResult res = base.doTransaction(t);
        log.logOperation(t,res);
        return res;
    }
}

E' possibile effettuare la composizione di più Decorator, poiché espongono tutti la stessa interfaccia e quindi ognuno può essere costruito a partire da un altro Decorator. Componendo più decorator è facile arricchire, anche dinamicamente, una classe di più funzionalità, ad esempio potremmo eseguire una serie di :
    IComponent component = new Component(); //per semplicità
    if (featureA_is_requested) component = new Decorator_A(component);
    if (featureB_is_requested) component = new Decorator_B(component);
    ...
e poi lavorare con la component arricchita/decorata di tutte le funzionalità richieste in modo dinamico.
Se abbiamo N funzionalità richieste, ci basterà scrivere N classi Decorator e non dovremo derivare 2^N sottoclassi per ogni combinazione distinta. Inoltre ogni singolo Decorator si occupa di una singola funzionalità quindi è semplice da scrivere e manutenere.


Facade

Serve a fornire un'interfaccia uniforme e più semplice (da cui il termine Facade o "facciata") ad un insieme di classi/componenti presenti nel sistema il cui funzionamento è complesso.
I metodi esposti dalla classe Facade sono di livello più alto rispetto a quelli delle classi gestite, e consentono di nascondere, all'esterno, l'interazioni con le componenti del sistema gestito.
La Facade non solo semplifica l'uso delle componenti, ma allo stesso tempo disaccoppia i client dai dettagli delle componenti richieste per eseguire effettivamente le azioni invocate. La Facade nasconde tutti i dettagli delle componenti che vengono quindi nascosti all'esterno.
Potremmo avere ad esempio, un sistema composto da
  • Client, l'utilizzatore del servizio
  • FacadeAutomobile, la classe che gestisce i comandi di un'automobile
  • classe MotorinoAvviamento
  • classe Batteria
  • classe Airbag
  • classe SensoriCarrozzeria
  • ..
ed un metodo della classe FacadeAutomobile "avvio" che si occupa di controllare se la Batteria è carica con un metodo della classe Batteria, di verificare lo stato degli Airbag invocando un metodo di quel componente, verificare la chiusura delle portiere usando l'oggetto SensoriCarrozzeria e dando corrente al motorino di avviamento inviando un messaggio alla classe che lo gestisce.
In sostanza con un solo comando "avvia" il client avvia l'automobile, ignorando tutti i dettagli su quali sensori vanno verificati e quali componenti vanno messe sotto tensione e via dicendo.
Il comando "avvia" quindi è un metodo esposto dall'interfaccia FacadeAutomobile, presumibilmente insieme ad altri, ognuno dei quali interagirà in modo, anche complesso, con le componenti dell'automobile, con modalità semplici. Tra l'altro questo fa anche si che anche sistemi piuttosto diversi possano avere un'interfaccia omogenea, poiché i dettagli sono nascosti nell'implementazione della Facade. Ad esempio un'automobile diesel o a benzina potranno essere guidate (utilizzate) con la stessa interfaccia.

Proxy

Fornisce un'interfaccia per accedere ad un altro oggetto/servizio. 
I motivi per cui un proxy può risultare utile o necessario sono diversi, ad esempio:
  • (smart reference) l'oggetto/servizio è molto costoso da costruire in termini di tempo o allocazione di banda o memoria, per cui si cerca di ritardare il più possibile la sua creazione delegandola al proxy. Questo concetto l'abbiamo esaminato nel design pattern "lazy instantiation"
  • (smart reference) l'oggetto è una risorsa condivisa tra più client, e va deallocato solo quando non ci sono più client che lo utilizzano
  • (locking check) accertarsi che chi deve accedere all'oggetto condiviso ne ottenga il controllo esclusivo
  • (protection proxy) verificare che chi accede all'oggetto abbia le autorizzazioni a farlo
  • (remote proxy) l'oggetto si trova su una macchina remota ed il proxy si occupa di inviare i messaggi e ricevere le risposte occupandosi delle comunicazioni 

Le entità coinvolte nel Proxy pattern sono:
  • Il Proxy,  che
    • gestisce il riferimento dell'oggetto Service da gestire, che espone una certa interfaccia IService
    • espone l'interfaccia IService ai propri client, cosi che può essere usato in sostituzione della classe Service (o di altre classi di pari interfaccia)
    • controlla l'accesso al servizio Service ed eventualmente di occupa di allocarlo e deallocarlo
  • Il Servizio (astratto), che espone un'interfaccia IService, ed a cui si accede tramite il Proxy
  • Il Servizio concreto, di cui il Proxy mantiene effettivamente un riferimento. Questo può anche coincidere con il servizio "astratto"

FlyWeight

Serve gestire grosse moli di dati in modo efficiente, minimizzandone l'occupazione di memoria.
E' utile quando:
  • sarebbe necessario allocare un elevato numero di oggetti uguali, ma con il flyweight si riesce a condividere le stesse istanze tra più client e possibilmente senza creare duplicati
  • è possibile sostituire gruppi di oggetti con un singolo oggetto dopo averne separato lo stato "esterno" in un'area distinta, e facendo in modo tale che lo stato "interno" sia condiviso tra tutte le istanze virtuali
Per l'applicabilità è necessario che l'applicazione non abbia bisogno di confrontare gli indirizzi di due oggetti gestiti con il flyweight, perché si avrebbe un'uguaglianza indesiderata

Le entità coinvolte sono:
  • L'interfaccia  IFlyweight, con cui viene elaborato lo "stato esterno" dell'oggetto, ossia la parte non condivisa tra le varie istanze
  • La classe Flyweight, che implementa l'interfaccia IFlyweight. Lo stato di questa classe è condiviso tra tutti i client, ossia deve essere indipedente dall'oggetto condiviso
  • FlyweightFactory, la classe che crea e gestisce gli oggetti  FlyWeight
  • Client, le classi che istanziano e mantengono dei riferimenti agli oggetti FlyWeight, e calcolano e memorizzano lo stato esterno dei flyweight (la parte di stato non condiviso)

Possiamo considerare l'uso del Flyweight quando la parte di stato "esterno" di una classe è relativamente piccolo rispetto alla parte del suo stato "interno", quello condiviso tra tutte le sue istanze. Possiamo ad esempio pensare ad una classe che quando viene inizializzata effettua una serie di calcoli e ne memorizza i risultati in una struttura in memoria. Per esempio, i primi N numeri primi con N molto grande. Ovviamente questa parte non varierà tra le diverse istanze e farà parte dello "stato interno" del flyweight.


Conclusione

Abbiamo esaminato i principali pattern strutturali. Certamente non siamo scesi in dettaglio, ma anche il solo fatto di conoscere la possibilità di utilizzare un certo pattern, quando se ne presenterà l'occasione, ci consentirà di approfondire e studiare meglio i dettagli di quel pattern.
In rete ci sono molte risorse e descrivere l'implementazione di questi pattern avrebbe certamente travalicato gli obiettivi di un post in un blog. 



Nessun commento:

Posta un commento

Refactoring: improving modularization

  Modularità In generale, per modularità si intende la misura in cui un sistema può essere decomposto e ricombinato. La modularizzazione è a...