Sicuramente uno dei design pattern dalla indiscussa utilità ed il cui utilizzo può rendere il nostro codice molto più chiaro, leggibile e semplice da manutenere. Da un punto di vista formale la Gang of Four definisce lo Strategy Pattern come un pattern che consente di isolare un insieme di algoritmi all’esterno della classe che li utilizza, per fare si che quest’ultima li possa utilizzare in modo intercambiabile, rendendo dinamico il proprio comportamento .
Prima di introdurre formalmente la struttura delle classi che implementa il pattern, proviamo a comprendere il significato reale di tali parole procedendo con un semplice esempio che risolveremo senza l’utilizzo del pattern strategy, ed evidenziandone le limitazioni, per poi vedere come il pattern le supera elegantemente.
Per chi non sia interessato può andare direttamente al paragrafo Definizione Formale.
Esempio di Shop Online
Cerchiamo di comprendere le motivazioni che spingono all’utilizzo dello strategy pattern tentando di risolvere il seguente semplice problema. Supponiamo di voler offrire ai clienti di uno shop online diversi sistemi di pagamento, da scegliere al momento del check-out. Il primo semplice approccio al problema può essere quello di realizzare una classe astratta che implementa i comportamenti comuni e più sottoclassi concrete che realizzano i comportamenti specifici.
Definiamo quindi la classe astratta AbstractPaymentProcessor
e la classe CheckOut
the la utilizza nel modo seguente:
1 2 3 4 5 6 7 |
public abstract class AbstractPaymentProcessor { /* Common methods */ public abstract void pay(BigDecimal amount); public abstract void refund(BigDecimal amount); } |
1 2 3 4 5 6 7 |
public class CheckOut { private static AbstractPaymentProcessor ipp = new ...(); public static void main(String[] args) { ipp.partialRefund(BigDecimal.TEN); } } |
I metodi pay()
e refund()
sono astratti, e devono quindi essere definiti nelle sottoclassi che implementano la classe astratta. Supponiamo ora di voler offrire i metodi di pagamento via Carta di Credito e via PayPal. La sola differenza è che con la carta di credito è possibile eseguire entrambe le operazioni, mentre con PayPal l’operazione di refund()
non è ammessa. Le possibili implementazioni di tali “strategie” sono:
1 2 3 4 5 6 7 8 9 10 11 |
public class CreditCardPaymentProcessor extends AbstractPaymentProcessor { @Override public void pay(BigDecimal amount) { System.out.println("Payed " + amount + " with credit card"); } @Override public void refund(BigDecimal amount) { System.out.println("Refunded " + amount + " with credit card"); } } |
1 2 3 4 5 6 7 8 9 10 11 |
public class PayPalPaymentProcessor extends AbstractPaymentProcessor { @Override public void pay(BigDecimal amount) { System.out.println("Payed " + amount + " with paypal"); } @Override public void refund(BigDecimal amount) { throw new RuntimeException("Refund not allowed"); } } |
Si noti che per implementare il vincolo su PayPal l’operazione di refund()
solleva una eccezione. Supponiamo ora che ci venga richiesto di introdurre un altro vincolo ma solamente per le carte di tipo VISA, per le quali la refund() non è ammessa. Procedendo con lo stesso stile introduciamo la classe VisaPaymentProcessor
che estende CreditCardPaymentProcessor
sovrascrivendo il metodo refund()
nel modo seguente:
1 2 3 4 5 6 |
public class VisaPaymentProcessor extends CreditCardPaymentProcessor { @Override public void refund(BigDecimal amount) { throw new RuntimeException("Refund not allowed"); } } |
Ma i nostri problemi non finiscono qui, perchè ad un certo punto il cliente ci chiede di implementare anche il metodo di pagamento attraverso IBAN, il quale però oltre a supportare tutte le operazioni di pagamento e rimborso, consente anche di eseguire un rimborso parziale. Al nostro cliente interessa moltissimo tale opzione e decide di offrirla ai visitatori dello shop online. Per mantenere la struttura del nostro codice consistente siamo quindi costretti ad introdurre il metodo astratto partialRefund()
nella classe astratta AbstractPaymentProcessor
, il quale dovrà essere implementato in tutte le sottoclassi sollevando un’eccezione tranne nella nuova classe IbanPaymentProcessor
così definita:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public class IbanPaymentProcessor extends AbstractPaymentProcessor { @Override public void pay(BigDecimal amount) { System.out.println("Payed " + amount + " with IBAN"); } @Override public void refund(BigDecimal amount) { System.out.println("Refunded " + amount + " with IBAN"); } @Override public void partialRefund(BigDecimal amount) { System.out.println("Partially refunded " + amount + " with IBAN"); } } |
La figura seguente mostra l’architettura delle classi fino ad ora realizzare per risolvere il nostro problema:
Come è evidente dall’immagine, la struttura della soluzione è molto complessa ed è destinata a complicarsi ancora di più ad ogni richiesta evolutiva del cliente. Basti pensare a cosa succederebbe se avessimo non 3 ma decine di metodi di pagamento differenti. Ad ogni richiesta dovremmo mettere mano a diverse righe di codice già testato e funzionante.
Utilizzo dello Strategy Pattern
Prima di procedere introducendo il pattern, procediamo per gradi ricordando uno dei principi fondamentali della progettazione software e dell’incapsulamento, ovvero:
Identificare le parti del programma che variano, mantenendole separate da ciò che rimane sempre lo stesso.
In parole semplici tale principio ci dice che dobbiamo separare ed incapsulate tutto ciò che varia frequentemente, così da gestire tutto il codice associato in un unico punto del programma. In questo modo ogni cambiamento a tale codice non ha effetto sul resto del programma, che risulta quindi più flessibile e robusto.
Applicando tale principio al nostro esempio è evidente che il comportamento dei metodi pay()
, refund()
e partialRefund()
sono candidati ideali per poter essere incapsulati al di fuori della classe principale. Questi comportamenti infatti variano tra i diversi metodi di pagamento e possono essere spostati in classi separate.
Introduciamo quindi tre interfacce PayStrategy
, RefundStrategy
e PartialRefundStrategy
che sono definite nel seguente modo:
1 2 3 4 5 6 7 8 9 10 11 |
public interface PayStrategy { public void pay(BigDecimal amount); } public interface RefundStrategy { public void refund(BigDecimal amount); } public interface PartialRefundStrategy { public void partialRefund(BigDecimal amount); } |
Tali interfacce sono poi implementate da tante classi quanti sono i metodi di pagamento. Non riportiamo per semplicità la loro implementazione, ci basti sapere che, per il nostro esempio, si tratta di 8 classi, ognuna delle quali costituita da poche righe di codice. Le classi implementate sono mostrate in figura:
Prima di procedere all’utilizzo di tali interfacce e delle classi che le implementano, scomodiamo un’altro principio cardine della progettazione software:
Programmare per interfacce, non per implementazione.
Tale principio ci suggerisce di evitare il più possibile l’utilizzo di classi concrete, ma di preferire piuttosto classi astratte o interfacce che definiscono tali classi.
Applicato al nostro caso, significa implementare una classe PaymentProcessor
che realizza le operazioni di pagamento e rimborso senza però conoscere a priori con quale metodo di pagamento vengono eseguite. Dal punto di vista pratico significa che la classe opera su oggetti che implementano le interfacce PayStrategy
, RefundStrategy
e PartialRefundStrategy
. Oggetti che vengono iniettate attraverso specifici metodi set()
al momento dell’effettivo utilizzo della classe, la quali quindi non conosce a priori su quali oggetti opererà in modo concreto.
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 |
public class PaymentProcessor { private PayStrategy payStrategy; private RefundStrategy refundStrategy; private PartialRefundStrategy partialRefundStrategy; /* Common methods */ public void setPayStrategy(PayStrategy payStrategy) { this.payStrategy = payStrategy; } public void setRefundStrategy(RefundStrategy refundStrategy) { this.refundStrategy = refundStrategy; } public void setPartialRefundStrategy(PartialRefundStrategy partialRefundStrategy) { this.partialRefundStrategy = partialRefundStrategy; } public void pay(BigDecimal amount) { payStrategy.pay(amount); } public void refund(BigDecimal amount) { refundStrategy.refund(amount); } public void partialRefund(BigDecimal amount) { partialRefundStrategy.partialRefund(amount); } } |
Una interessante conseguenza di tale approccio è che è possibile implementare nuove strategie di pagamento e rimborso, al di fuori di quelle viste, semplicemente mescolando i diversi comportamenti e senza dover implementare nuove classi. Questo implica, ad esempio, che è possibile definire un oggetto PaymentProcessor
che esegue i pagamenti attraverso PayPal, il rimborso mediante Carta di Credito e il rimborso parziale con l’IBAN.
La classe CheckOut
riportata di seguito mostra come, ad esempio, sia possibile implementare il metodo di pagamento mediante carta di credito di tipo VISA, senza dover introdurre una nuova classe, ma semplicemente mixando i comportamenti già disponibili:
1 2 3 4 5 6 7 8 9 10 11 |
public class CheckOut { private static PaymentProcessor visa = new PaymentProcessor(); public static void main(String[] args) { visa.setPayStrategy( new CreditCardPayStrategy() ); visa.setRefundStrategy( new NotAllowedRefundStrategy() ); visa.setPartialRefundStrategy( new NotAllowedPartialRefundStrategy() ); visa.pay( BigDecimal.TEN ); } } |
Per completezza riportiamo il diagramma delle classi parziale che si riferisce a quest’ultimo esempio:
Definizione Formale
Il pattern Strategy è un pattern comportamentali che può essere utilizzato ogni qual volta una classe Context
esegue delle operazioni sui dati che richiedono l’implementazione di un algoritmo che può variare nel tempo. L’adozione di tale pattern evita la necessità di dover modificare la classe Context
ad ogni nuova implementazione dell’algoritmo.
Nel linguaggio java un buon esempio dell’utilizzo di tale pattern lo troviamo nel metodo Collection.sort()
, che riceve in input, oltre alla collezione da ordinare, un oggetto che implementa l’interfaccia Comparator
:
1 2 3 4 5 6 |
Collections.sort(listOfWords, new Comparator<String>() { @Override public int compare(String s1, String s2) { return s1.length() - s2.length(); } }); |
La soluzione che il pattern ci propone è quindi di incapsulare un algoritmo all’interno di una classe esterna alla classe Context
, definendo una interfaccia generica. Il tutto si traduce nel seguente diagramma delle classi:
Dove:
Strategy
: dichiara l’interfaccia della classe di algoritmi. Interfaccia che è utilizzata dalla classeContext
per invocare uno specifico algoritmo concreto.ConcreteStrategyA
eConcreteStrategyB
: sono le classi che incapsulano gli algoritmi concreti, ovvero implementano uno specifico algoritmo esponendo l’interfacciaStrategy
.Context
: è la classe di contesto che invoca laConcreteStrategy
(sotto richiesta dei suoi client).
Codie Sorgente
Il codice sorgente del progetto è disponibile qui strategy.