Strategy pattern: schemi progettuali software per strategie di comportamento variabili
Nella programmazione orientata agli oggetti, i design pattern (in italiano lett. “schemi progettuali”) supportano gli sviluppatori con approcci e modelli risolutivi collaudati. Una volta trovata la soluzione giusta, non resta che apportare ciascuna singola modifica. Al momento, in tutto, esistono 70 schemi progettuali su misura per aree di applicazione specifiche. Gli strategy design pattern si concentrano sul comportamento del software.
Cos’è uno strategy pattern?
Lo strategy pattern è un tipo di design pattern comportamentale (modello di comportamento) che offre al software diversi tipi di soluzione. Dietro le strategie c’è una famiglia di algoritmi separati dal programma vero e proprio e autonomi (= scambiabili). Gli schemi progettuali di strategia includono anche specifiche e assistenza per gli sviluppatori. Gli strategy pattern descrivono quindi come creare classi, organizzare un gruppo di classi e creare oggetti. Una peculiarità degli strategy design pattern è la possibilità di implementare un comportamento del programma e degli oggetti variabile anche mentre il software è in esecuzione.
Come appare la rappresentazione UML di uno strategy pattern?
Gli strategy pattern sono generalmente progettati con il linguaggio di modellazione grafico UML (Unified Modeling Language). Questo presenta gli schemi progettuali tramite una notazione standardizzata e utilizza caratteri e simboli speciali. L’UML fornisce vari tipi di diagramma per la programmazione orientata agli oggetti. Un diagramma di classe con almeno tre componenti di base viene solitamente scelto per rappresentare un modello di progettazione della strategia:
- Context (contesto o classe di contesto)
- Strategy (strategia o classe di strategia)
- ConcreteStrategy (strategia concreta)
Nello strategy design pattern le componenti di base assumono funzioni speciali: i modelli di comportamento della classe di contesto vengono esternalizzati in diverse classi di strategia. Queste classi separate ospitano gli algoritmi noti come ConcreteStrategy. Se necessario, il contesto può accedere alle varianti di calcolo esternalizzate (ConcreteStrategyA, ConcreteStrategyB, ecc.) tramite un riferimento (interno). In tal modo non interagisce direttamente con gli algoritmi, ma con un’interfaccia.
L’interfaccia della strategia incapsula le varianti di calcolo e può essere implementata da tutti gli algoritmi contemporaneamente. Per l’interazione con il contesto, l’interfaccia generica fornisce un unico metodo per l’attivazione degli algoritmi ConcreteStrategy. Le interazioni con il contesto includono non solo il richiamo di una strategia, ma anche lo scambio di dati. L’interfaccia strategica è coinvolta inoltre nei cambiamenti di strategia che possono avvenire anche mentre un programma è in esecuzione.
L’incapsulamento impedisce l’accesso diretto agli algoritmi e alle strutture di dati interne. Un’istanza esterna (client, contesto) può utilizzare solo calcoli e funzioni tramite interfacce definite rendendo così accessibili solo i metodi e i dati di un oggetto rilevanti per l’istanza esterna.
Di seguito vi spiegheremo come viene implementato in un progetto pratico lo schema progettuale, utilizzando un esempio di strategy pattern.
Lo strategy pattern spiegato con un esempio
Il nostro esempio (basato sul Progetto di studio sullo Strategy Pattern di Philipp Hauer) consiste nel creare un’app di navigazione utilizzando uno schema progettuale di strategia. L’app deve calcolare un percorso basato sui normali mezzi di trasporto. L’utente può scegliere fra tre opzioni:
- Pedone (ConcreteStrategyA)
- Auto (ConcreteStrategyB)
- Trasporto locale (ConcreteStrategyC)
Trasferendo queste specifiche su un grafico UML, la struttura e la funzionalità dello strategy pattern richiesto diventano chiare:
Nel nostro esempio, il client è l’interfaccia utente grafica (Graphical User Inferface, GUI) di un’app di navigazione con comandi per il calcolo di percorso. Se l’utente effettua una selezione tramite un comando, viene calcolato un percorso specifico. Il contesto (classe Navigator) ha il compito di calcolare e visualizzare una serie di punti di controllo sulla mappa. La classe Navigator ha un metodo specifico per cambiare la strategia di calcolo percorso attiva. Ciò significa che è possibile passare facilmente da una modalità di trasporto all’altra utilizzando i comandi del client.
Se, ad esempio, viene attivato un comando corrispondente con il tasto pedone sul client, viene richiesto il servizio “Calcola percorso pedonale” (ConcreteStrategyA). Il metodo executeAlgorithm() (nel nostro esempio il metodo: calcolaPercorso (A, B)) accetta un’origine e una destinazione e produce una raccolta dei punti di controllo della rotta. Il contesto riceve il comando del client e, in base alle linee guida definite in precedenza (Policy), decide la strategia appropriata (setStrategy: Pedone). Tramite Call delega invece la richiesta all’oggetto-strategia e alla sua interfaccia.
Con getStrategy() si memorizza la strategia attualmente selezionata nel contesto (classe del navigatore). I risultati dei calcoli ConcreteStrategy confluiscono nell’ulteriore elaborazione e nella visualizzazione grafica del percorso nella app di navigazione. Se l’utente sceglie un percorso diverso, ad esempio facendo clic sul pulsante “Auto” in un secondo momento, il contesto cambia nella strategia richiesta (ConcreteStrategyB) e avvia un nuovo calcolo tramite un’ulteriore Call. Alla fine della procedura, viene emessa una descrizione del percorso modificata per il mezzo di trasporto auto.
Nel nostro esempio, la meccanica del pattern può essere implementata con un codice relativamente chiaro:
Contesto:
publicclassContext {
//valore standard predefinito (comportamento predefinito): ConcreteStrategyA
private Strategy strategy = new ConcreteStrategyA();
public void execute() {
//delega il comportamento a un oggetto-strategia
strategy.executeAlgorithm();
}
public void setStrategy(Strategy strategy) {
strategy = strategy;
}
public Strategy getStrategy() {
returnstrategy;
}
}
Strategy, ConcreteStrategyA, ConcreteStrategyB:
interface Strategy {
public void executeAlgorithm();
}
classConcreteStrategyA implements Strategy {
public void executeAlgorithm() {
System.out.println("Concrete Strategy A");
}
}
classConcreteStrategyB implements Strategy {
public void executeAlgorithm() {
System.out.println("Concrete Strategy B");
}
}
Client:
public class Client {
public static void main(String[] args) {
//Comportamento predefinito
Contextcontext = new Context();
context.execute();
//Modificare il comportamento
context.setStrategy(new ConcreteStrategyB());
context.execute();
}
}
Quali sono i vantaggi e gli svantaggi degli strategy pattern?
I vantaggi di uno strategy pattern diventano evidenti assumendo la prospettiva di un programmatore e amministratore di sistema. In generale, la suddivisione in moduli e classi autonomi comporta una migliore strutturazione del codice del programma. Nelle sotto-aree delimitate, il programmatore della nostra app di esempio può gestire segmenti di codice più snelli. In questo modo, l’ambito della classe navigatore può essere ridotto esternalizzando le strategie e non è necessario creare sottoclassi nell’ambito del contesto.
Poiché le dipendenze interne dei segmenti rimangono all’interno del quadro in un codice più snello e ben delimitato, le modifiche hanno effetti minori. Quindi raramente è necessaria un’ulteriore, e potenzialmente lunga, riprogrammazione; in alcuni casi può essere anche evitata del tutto. Si può inoltre mantenere più facilmente segmenti di codice più chiari nel lungo termine, e quindi semplificare l’identificazione e la diagnosi dei problemi.
Questo comporta vantaggi anche per il funzionamento, poiché l’app di esempio può avere un’interfaccia intuitiva. Utilizzando i comandi, gli utenti sono in grado di controllare facilmente il comportamento del programma (calcolo di percorso) e scegliere fra le varie opzioni.
Poiché il contesto dell’app di navigazione interagisce solo con un’interfaccia attraverso l’incapsulamento degli algoritmi, risulta indipendente dall’implementazione concreta dei singoli algoritmi. In caso di modifiche agli algoritmi o introduzione di nuove strategie in un secondo momento, il codice del contesto non deve essere necessariamente modificato. In questo modo, volendo, il calcolo di percorso può essere integrato in modo rapido e semplice con ulteriori ConcreteStrategy per rotte aeree, trasporto marittimo e traffico a lunga distanza. Le nuove strategie devono semplicemente implementare in modo corretto la strategy interface.
Gli strategy pattern facilitano la già difficile programmazione del software orientato agli oggetti grazie a un’altra caratteristica positiva: consentono la progettazione di software (moduli) riutilizzabilipiù volte, il cui sviluppo è considerato particolarmente impegnativo. Le classi di contesto correlate possono utilizzare anche le strategie esternalizzate per il calcolo di percorso tramite l’interfaccia e non devono più implementarle da sole.
Nonostante i suoi numerosi vantaggi, gli strategy pattern presentano però anche alcuni svantaggi. A causa della sua struttura più complessa, la progettazione del software può creare ridondanze e inefficienze nella comunicazione interna. L’interfaccia della strategia generica, che tutti gli algoritmi devono implementare allo stesso modo, può quindi risultare sovradimensionata nei singoli casi.
Ecco un esempio: dopo che il contesto ha creato e inizializzato alcuni parametri, li trasferisce all’interfaccia generica e al metodo in essa definito. La strategia implementata alla fine non necessita necessariamente di tutti i parametri contestuali comunicati e quindi non li elabora. Pertanto, l’interfaccia fornita non viene sempre utilizzata in modo ottimale nello strategy pattern e non è sempre possibile evitare un maggiore sforzo di comunicazione con trasferimenti di dati non necessari.
Durante l’implementazione, c’è anche una stretta dipendenza interna tra il client e la strategia. Poiché il client effettua la selezione e richiede la strategia concreta con un trigger (nel nostro esempio il calcolo del percorso pedonale), deve conoscere le ConcreteStrategy. Pertanto, è necessario utilizzare questo schema progettuale solo se i cambiamenti nella strategia e nel comportamento sono importanti o fondamentali per l’uso e le funzionalità del software.
Alcuni degli svantaggi elencati possono essere evitati o compensati. Il numero di istanze di oggetti che possono verificarsi in gran numero nello strategy pattern viene spesso ridotto implementando un pattern Flyweight. Questa misura ha anche un effetto positivo sull’efficienza e sui requisiti di memoria di un’applicazione.
Quando si utilizzano gli strategy pattern?
Come schema progettuale di base nello sviluppo del software, lo strategy design pattern non è limitato a un’area specifica di applicazione. Piuttosto, la natura del problema è decisiva per l’uso del design pattern. Il software che deve risolvere attività e problemi imminenti con variabilità, opzioni di comportamento e modifiche, è specializzato nello schema progettuale.
I programmi che offrono diversi formati di archiviazione file o varie funzioni di organizzazione e ricerca utilizzano schemi progettuali di strategia. Anche nell’area di compressione dati vengono utilizzati programmi che implementano diversi algoritmi di compressione sulla base del schema progettuale. Quindi ad esempio è possibile convertire i video nel formato di file salvaspazio desiderato o ripristinare i file di archivio compressi (come i file ZIP o RAR) al loro stato originale utilizzando speciali strategie di decompressione. Un altro esempio potrebbe essere il salvataggio di un documento o di un’immagine in diversi formati di file.
Il design pattern è anche coinvolto nello sviluppo e nell’implementazione del software di gioco dove deve, ad esempio, reagire in modo flessibile alle mutevoli situazioni di gioco in fase di esecuzione. Le attrezzature speciali, i vari personaggi, i loro modelli di comportamento o le loro mosse particolari possono essere memorizzati tutti sotto forma di ConcreteStrategy.
Un’altra area di applicazione degli strategy pattern sono i software di contabilità. Scambiando ConcreteStrategy si riesce essere facilmente ad adattare i tassi di calcolo a gruppi professionali, paesi e regioni. Inoltre, i programmi che traducono i dati in vari formati grafici (come ad esempio grafici a linee, a torta o a barre) utilizzano strategy pattern.
Troviamo poi applicazioni più specifiche degli strategy pattern nella libreria standard Java (API Java) e nei toolkit GUI Java (ad esempio AWT, Swing e SWT), che utilizzano un gestore di layout per sviluppare e creare interfacce utente grafiche. Questo permette di implementare diverse strategie per la disposizione delle componenti durante lo sviluppo dell’interfaccia. Esistono ulteriori applicazioni degli strategy design pattern nei sistemi di database, driver di dispositivo e programmi server.
Una panoramica delle proprietà più importanti degli strategy pattern
Nella vasta gamma di design pattern, lo schema progettuale di strategia è caratterizzato dalle seguenti proprietà:
- orientato al comportamento (il comportamento e le relative modifiche sono più facili da programmare e implementare, e le modifiche possono essere apportate anche mentre un programma è in esecuzione)
- orientato all’efficienza (l’esternalizzazione semplifica e ottimizza il codice e la sua manutenzione)
- orientato al futuro (le modifiche e ottimizzazioni possono essere facilmente implementate anche a medio e lungo termine)
- mira all’espandibilità (favorita dal sistema modulare e dall’indipendenza di oggetti e classi)
- mira alla riusabilità (ad es. uso multiplo di strategie)
- mira a ottimizzare l’usabilità, il controllo e la configurabilità del software
- richiede considerazioni concettuali approfondite (cosa, come e dove può essere esternalizzato nelle classi di strategia)
Gli strategy pattern consentono uno sviluppo software efficiente ed economico nella programmazione orientata agli oggetti con soluzioni di problemi su misura. Eventuali modifiche e miglioramenti sono preparati in modo ottimale già nella fase di progettazione. Il sistema, orientato alla variabilità e alla dinamicità, può essere gestito e controllato complessivamente al meglio. Errori e incongruenze si risolvono più rapidamente. Le componenti riutilizzabili e interscambiabili consentono di risparmiare sui costi di sviluppo, soprattutto in progetti complessi con una prospettiva a lungo termine. Tuttavia, è importante trovare la giusta quantità. Non è raro che gli schemi progettuali vengano usati con un’eccessiva parsimonia o troppo spesso.