Decorator patten corretto lambda con Java 8

Il decorator pattern è un design pattern che non ha bisogno di molte presentazioni e questa voce di Wikipedia riassume molto bene il concetto.

Il design pattern decorator consente di aggiungere durante il run-time nuove funzionalità ad oggetti già esistenti. Questo viene realizzato costruendo una nuova classe decoratore che “avvolge” l’oggetto originale. Al costruttore del decoratore si passa come parametro l’oggetto originale. È altresì possibile passarvi un differente decoratore. In questo modo, più decoratori possono essere concatenati l’uno all’altro, aggiungendo così in modo incrementale funzionalità alla classe concreta (che è rappresentata dall’ultimo anello della catena).
La concatenazione dei decoratori può avvenire secondo una composizione arbitraria: il numero di comportamenti possibili dell’oggetto composto varia dunque con legge combinatoriale rispetto al numero dei decoratori disponibili.

In questo post prenderemo una implementazione di decorator e la riscriveremo utilizzando le lambda expression. Confronteremo poi le due soluzioni.

Coffee please

Un esempio che a me piace molto è quello presentato in questo libro: modellare il bancone di un bar dove possono arrivare diversi tipi di ordinazione di caffè (o altro). Il testo, a fini didattici, parte dall’approccio sbagliato, cioè scrivere una classe per ogni tipo di combinazione possibile di ordine (caffè, caffè lungo, caffè macchiato, caffè corretto, caffè lungo corretto, caffè macchiato corretto, ecc.). Poi viene mostrata la soluzione con il decorator. E’ proprio questa che riporto qui riducendo il numero di casi proposti e trasformando il “Frappuccino” in un macchiatone :).

Seguendo la ricetta del pattern, abbiamo bisogno di una prima classe astratta da cui deriveranno tutte le bevande: chiamiamo questa classe Bevanda

public abstract class Bevanda {

    String nome = "";

    public String getNome() {
        return nome;
    }

    public abstract double getPrezzo();
}

e poi la classe Aggiunta che modellerà ogni possibile cambiamento della bevanda di base.

public abstract class Aggiunta extends Bevanda {

    @Override
    public abstract String getNome();

}

Aggiungiamo adesso un po’ di classi adatte al nostro dominio

public class Espresso extends Bevanda {

    public Espresso() {
        nome = "Espresso";
    }

    @Override
    public double getPrezzo() {
        return 1.00;
    }

}

public class CaffeOrzo extends Bevanda {

    public CaffeOrzo() {
        nome = "Caffè d'orzo";
    }

    @Override
    public double getPrezzo() {
        return 1.50;
    }

}

e dei decoratori di prova.

public class Macchiato extends Aggiunta {

    private Bevanda bevanda;

    public Macchiato(Bevanda bevanda) {
        this.bevanda = bevanda;
    }

    @Override
    public String getNome() {
        return bevanda.getNome() + ", macchiato";
    }

    @Override
    public double getPrezzo() {
        return bevanda.getPrezzo() + 0.05;
    }

}

public class Corretto extends Aggiunta {

    private Bevanda bevanda;

    public Corretto(Bevanda bevanda) {
        this.bevanda = bevanda;
    }

    @Override
    public String getNome() {
        return bevanda.getNome() + ", corretto";
    }

    @Override
    public double getPrezzo() {
        return bevanda.getPrezzo() + 0.60;
    }

}

Fatte queste presentazioni, vediamo le nostre classi all’opera: possiamo creare semplicemente un nuovo Espresso:

Bevanda espresso = new Espresso();
System.out.printf("%s: prezzo %.2f\n", espresso.getNome(), espresso.getPrezzo());

Se invece desideriamo un macchiato ci basta “avvolgere” la bevanda di partenza con un’aggiunta:

Bevanda macchiato = new Macchiato(espresso);
System.out.printf("%s: prezzo: %s\n", macchiato.getNome(), macchiato.getPrezzo());

e possiamo aggiungere quanti decoratori vogliamo per ottenere il risultato voluto:

Bevanda correttoMacchiato = new Corretto(new Macchiato(new Espresso()));
System.out.printf("%s : prezzo $.2f\n", correttoMacchiato.getNome(), correttoMacchiato.getPrezzo());

ottenendo questo risultato.

Espresso: prezzo 1,00
Espresso, macchiato: prezzo: 1,05
Espresso, macchiato, corretto: prezzo: 1,65

E con le lambda?

Fin qui l’approccio classico, ma ora, forti delle lambda, rivisitiamo il nostro esempio in questa chiave. Partiamo dal prezzo: fare un’aggiunta vuol dire far pagare di più, quindi sommare al metodo getPrezzo un altro valore. Possiamo modellare la somma con la lambda java.util.function.Function, una interfaccia funzionale che dato un valore di un certo tipo ne restituisce un altro dello stesso tipo o di un tipo diverso:

Function<Double, Double> macchiato = x -> x + .05;
Function<Double, Double> corretto = x -> x + .60;

Questa interfaccia ha a disposizione un metodo di default chiamato apply che consente di passare un argomento e ottenere il risultato, ad esempio:

System.out.printf("La funzione macchiato in azione %.2f", macchiato.apply(1.0));

ritorna questo risultato.

La funzione macchiato in azione: 1,05

C’è un altro metodo di default in questa interfaccia che ci interessa e che consente di combinare due funzioni assieme per ottenerne una nuova, si tratta del metodo compose. Nel nostro esempio se vogliamo ottenere la funzione che combina il macchiato al corretto scriviamo

Function<Double, Double> macchiatoCorretto = macchiato.compose(corretto);

e utilizzando

macchiatoCorretto.apply(1.0)

otterremo lo stesso risultato di

macchiato.apply(corretto.apply(1.0))

Bene, le lambda fanno già metà del lavoro che ci serve: comporre comportamenti, quindi le utilizzeremo al posto dei decorator. Resta ora da modellare una classe astratta FBevanda (per distinguerla dall’implementazione precedente) che ci consenta di “appiccicare” le decorazioni che vogliamo e lo faremo in 3 passi.

Passo 1: usare le lambda

Propongo subito la definizione della nuova classe e andiamo a commentarla.

public abstract class FBevanda {

    private Function<Double, Double> aggiunta;
    
    public FBevanda() {
        aggiunta = i -> i;
    }

    public void aggiungi(final Function<Double, Double> aggiunta) {
        this.aggiunta = aggiunta;
    }

    public abstract double getPrezzoIniziale();

    public double getPrezzo() {
        return aggiunta.apply(getPrezzoIniziale());
    }
}

Abbiamo bisogno della member variable aggiunta di tipo Function per memorizzare l’aggiunta da usare quando richiameremo il metodo getPrezzo. Dato che non vogliamo un NullPointerException, inizializziamo aggiunta nel costruttore con la lambda che torna se stessa. Per convenienza questa è disponibile anche come Function.identity().

Il metodo getPrezzo fa quello che già sappiamo, applicare l’aggiunta al prezzo iniziale. Quest’ultimo deriverà da un metodo che lasciamo astratto in modo che ogni bevanda possa definire il suo, ad esempio:

public class FEspresso extends FBevanda {

    @Override
    public double getPrezzoIniziale() {
        return 1.00;
    }
}

Ecco cosa succede “al bar”:

FBevanda espresso = new FEspresso();
System.out.printf("%s: prezzo %.2f\n", "Espresso", espresso.getPrezzo());

espresso.aggiungi(macchiato);
System.out.printf("%s: prezzo %.2f\n", "Macchiato", espresso.getPrezzo());

con risultato:

Espresso: prezzo 1,00
Macchiato: prezzo 1,05

Passo 2: più decoratori per tutti

Il passo successivo è consentire di applicare più decoratori ad una FBevanda, per far questo basta modificare solo il metodo aggiungi
che diventa

public void aggiungi(final Function<Double, Double>... aggiunte) {
        this.aggiunta = Stream.of(aggiunte)
		                  .reduce((aggiunta, altraAggiunta) -> aggiunta.compose(altraAggiunta))
						  .orElse(i -> i);
}

Cosa fa adesso il nostro metodo? Prende in ingresso una lista variabile di lambda e, tramite stream, le trasforma in un’unica lambda applicando la composizione di funzioni da sinistra a destra riducendo la lista ad un’unica funzione. Se la lista è vuota, assegnerà ad aggiunta l’identità. Ora possiamo scrivere

espresso.aggiungi(macchiato, macchiato, corretto);
System.out.printf("%s: prezzo %.2f\n", "Macchiatone corretto", espresso.getPrezzo());

e ottenere

Macchiatone corretto: prezzo 1,70

Passo 3: aggiungere il nome

Affinché il nostro esempio sia equivalente a quello iniziale, dobbiamo “portarci dietro” anche il nome dell’aggiunta e qui, purtroppo, si perde un po’ dell’eleganza vista fin’ora. Non possiamo più usare una funzione tra Double ma dobbiamo aggiungere il nuovo tipo InfoAggiunta che aggreghi nome e prezzo.

public class InfoAggiunta {
    private final double prezzo;
    private final String nome;

    public InfoAggiunta(String nome, double prezzo) {
        this.prezzo = prezzo;
        this.nome = nome;
    }

    public double getPrezzo() {
        return prezzo;
    }

    public String getNome() {
        return nome;
    }

}

Di conseguenza, le lambda fino qui introdotte avranno questo aspetto:

 Function<InfoAggiunta, InfoAggiunta> macchiato = 
                x -> new InfoAggiunta(x.getNome() + ", macchiato", x.getPrezzo() + 0.05);

Da qua in poi, le modifiche presentate sono puro adattamento meccanico volto a incastrare la nuova struttura in FBevanda:

public abstract class FBevanda {

    private Function<InfoAggiunta, InfoAggiunta> aggiunta = Function.identity();

    public void setAggiunte(final Function<InfoAggiunta, InfoAggiunta>... aggiunte) {
        this.aggiunta = Stream.of(aggiunte)
		                      .reduce((aggiunta, altraAggiunta) -> aggiunta.compose(altraAggiunta))
							  .orElse(Function.identity());
    }

    public abstract InfoAggiunta getInfoIniziali();

    public double getPrezzo() {
        return aggiunta.apply(getInfoIniziali()).prezzo;
    }

    public String getDescrizione() {
        return aggiunta.apply(getInfoIniziali()).nome;
    }


}

e aggiornare le classi che ne derivano

public class FEspresso extends FBevanda {

    private final InfoAggiunta info = new InfoAggiunta("Espresso", 1.0);

    @Override
    public InfoAggiunta getInfoIniziali() {
        return info;
    }

}

Infine ecco il codice client nella sua versione finale:

FBevanda espresso = new FEspresso();
Function<InfoAggiunta, InfoAggiunta> macchiato = 
    x -> new InfoAggiunta(x.getNome() + ", macchiato", x.getPrezzo() + 0.05);

Function<InfoAggiunta, InfoAggiunta> corretto = 
    x -> new InfoAggiunta(x.getNome() + ", corretto", x.getPrezzo() + 0.60);

espresso.setAggiunte(macchiato, corretto);
System.out.printf("%s: prezzo %.2f\n", espresso.getDescrizione(), espresso.getPrezzo());

Confronto e conclusioni

Abbiamo visto due modi di implementare il decorator pattern, che vantaggi può avere l’approccio lambda? Io ne trovo fondamentalmente quattro, premettendo che siamo ai bordi del campo minato del gusto personale:

  1. Partiamo con quello meno opinabile: la compattezza del package. Con una classe per ogni decorator, si fa presto a riempire il package di molti file che probabilmente contengono una logica elementare. Con le lambda, basta una variabile per ogni decoratore;
  2. Distinguiamo meglio tra i Component come FEspresso e il suo decorator. Il primo è una classe, il secondo invece è una lambda;
  3. Dato che i decorator passano da classi a variabili, in linea teorica (ma non è difficile costruire un esempio), possiamo istanziare i decoratori runtime, mentre prima eravamo limitati a compile time.
  4. Avvolgere la classe base nei decoratori è più lineare: nell’approccio classico siamo costretti a creare una catena di “new” che in caso di molti decoratori è poco leggibile, con l’approccio visto si risolve nella chiamata di un metodo con numero di argomenti variabili.

Ma ci sono dei casi in cui le lambda non possono essere utilizzate? Teoricamente quelli in cui il decoratore per qualche motivo ha bisogno di uno stato mutabile al suo interno. Bisogna vedere però, a questo punto, se il decorator patter è la soluzione corretta per il tipo di problema da risolvere.

Il codice presentato in questo post è disponibile su GitHub

Giampaolo Trapasso

Sono laureato in Informatica e attualmente lavoro come Software Engineer in Radicalbit. Mi diverto a programmare usando Java e Scala, Akka, RxJava e Cassandra. Qui mio modesto contributo su StackOverflow e il mio account su GitHub