Il Decorator è un design pattern strutturale che consente di aggiungere funzionalità o modificare a runtime il comportamento di un oggetto. In generale quando si vuole estendere il comportamento di un oggetto gli approcci possibili sono due: uno dinamico ed uno staitico. L’approccio statico consiste nell’utilizzare l’ereditarietà creando gerarchie di classi che, ad ogni livello, aggiunge funzioni al livello precedente. Le caratteristiche di questo approccio sono diverse:
- le nuove funzionalità sono “aggiunte” a tempo di compilazione e si applicano a tutte le istanze della classe;
- potenzialmente gerarchie di questo tipo possono essere molto estese quando si intende modellare comportamenti molto diversi;
- non è possibile controllare l’ordine in cui i diversi comportamenti vengono concatenati nell’oggetto risultante, essendo la gerarchia rigidamente definita;
- l’approccio gerarchico in molti linguaggi di programmazione OO come Java, che non supportano l’ereditarietà multipla, è stato enormemente ridimensionata nel tempo.
Il pattern Decorator offre un’alternativa flessibile all’eredità per l’estensione della funzionalità degli oggetti. Questo pattern è progettato in modo tale che più Decorator possano essere combinati uno sopra l’altro, aggiungendo ciascuno una nuova funzionalità. Contrariamente all’eredità, un decoratore può operare su qualsiasi implementazione di una data interfaccia, eliminando la necessità implementare un’intera gerarchia di classi. Inoltre, l’uso del pattern Decorator porta a un codice pulito e testabile.
Esempio Pratico
Per meglio comprendere i vantaggi e svantaggi del pattern consideriamo il caso di una applicazione che implementa un notebook per la gestione e conservazione delle note prodotte dall’utente. La figura a sinistra mostra quattro semplici use case che utilizzeremo nella implementazione del progetto, mentre di seguito è riportata l’interfaccia NotebookInterface
che rispecchia tali use case.
1 2 3 4 5 6 |
public interface NotebookInterface { public List<String> notes(); public int count(); public void add(String note); public void clear(); } |
A partire da tale interfaccia una possibile sua implementazione utilizza un lista per conservare tutte le note, come mostrato nella classe Notebook
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public class Notebook implements NotebookInterface { protected List<String> notes = new ArrayList<String>(); public List<String> notes() { return notes; } public int count() { return notes.size(); } public void add(String note) { notes.add( note ); } public void clear() { notes.clear(); } } |
Approccio Gerarchico
Vediamo ora cosa accade se, a partire da quanto già fatto, si vuole estendere il comportamento del nostro notebook. Innanzitutto supponiamo di voler porre un limite al numero di note che il notebook è in gradi di conservare. Una prima soluzione potrebbe essere quella di aggiungere un costruttore alla classe Notebook
in modo da indicare la sua capacità. Tale soluzione naturalmente avrebbe un effetto su tutta l’applicazione, cosa potenzialmente fonte di bug soprattutto in applicazioni complesse, ma soprattutto violla due principi fondamentali dell’informatica:
- Single Responsibility Principle in quanto aggiungiamo responsabilità alla classe;
- Open-Closed Principle perchè la classe stessa deve essere modificata al fine di estenderla.
Utilizziamo quindi un approccio gerarchico, ed estendiamo la classe Notebook
nella classe FixedLengthNotebook
, in cui il comportamento del metodo add()
è ridefinito al fine di limitare il numero di note complessive ad un massimo specificato:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public class FixedLengthNotebook extends Notebook { private int size = 4; public FixedLengthNotebook() {} public FixedLengthNotebook(int size) { this.size = size; } public void add(String text) { if ( notes.size() < size ) { super.add(text); } } } |
Supponiamo poi che il nostro cliente voglia utilizzare il notebook in un progetto multithreading, e ci chieda quindi una sua implementazione thread safe. Proseguendo con l’approccio gerarchico implementiamo una nuova classe ThreadSafeNotebook
che molto banalmente utilizza il costrutto synchronized
per sincronizzare l’accesso ai metodi della classe base Notebook
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
public class ThreadSafeNotebook extends Notebook { public ThreadSafeNotebook() { super(); } public synchronized List<String> notes() { return super.notes(); } public synchronized int count() { return super.count(); } public synchronized void add(String text) { super.add(text); } public synchronized void clear() { super.clear(); } } |
Ma il nostro cliente non è ancora soddisfatto, perchè ha anche necessità di una versione che sia contemporaneamente thread safe e con capienza limitata. Ancora una volta lo accontentiamo implementando una classe ThreadSafeFixedLengthNotebook
:
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 30 |
public class ThreadSafeFixedLengthNotebook extends Notebook { private int size = 4; public ThreadSafeFixedLengthNotebook() { super(); } public ThreadSafeFixedLengthNotebook(int size) { this.size = size; } public synchronized void add(String text) { if ( notes.size() < size ) { super.add(text); } } public synchronized List<String> notes() { return super.notes(); } public synchronized int count() { return super.count(); } public synchronized void clear() { super.clear(); } } |
Non è necessario proseguire ulteriormente per capire che potenzialmente questo approccio può comportare una esplosione preoccupante del numero di classi nella gerarchia che, oltre ad essere fonte di bug, comporterebbe difficoltà nella manutenzione dell’applicazione. Ad esempio se volessimo aggiungere capacità di logging potremmo dover implementare anche le classi: NotebookWithLogging,
FixedLengthWithLoggingNotebook
, ThreadSafeWithLoggingNotebook
e ThreadSafeFixedLengthWithLoggingNotebook
. La gerarchia delle classi prodotte fino ad ora, riportata nella figura seguente, mostrata la complessità raggiunta sino ad ora dal nostro codice.
Implementazione con Decorator
Il pattern Decorator estende la funzionalità degli oggetti utilizzando la composizione anziché l’eredità. Elimina il problema dell’esplosione della gerarchia delle classi perché è necessario un solo Decorator per ogni nuova funzionalità. Vediamo come iniziando col definire la classe astratta NotebookDecorator
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
public abstract class NotebookDecorator implements NotebookInterface { protected NotebookInterface notebook; public NotebookDecorator( NotebookInterface notebook ) { this.notebook = notebook; } public List<String> notes() { return notebook.notes(); } public int count() { return notebook.count(); } public void add(String sentence) { notebook.add(sentence); } public void clear() { notebook.clear(); } } |
Tale classe implementare l’interfaccia NotebookInterface
semplicemente invocando i metodi esposti da un oggetto che implementa la stessa interfaccia. Potenzialmente la classe potrebbe anche non essere astratta ma in questo modo si vuole evitare che la si possa erroneamente istanziare.
Proseguiamo ora implementando tre distinte classi, una per ciascuno dei comportamenti che il nostro cliente ci ha richiesto. La prima è la classe FixedLengthNotebook
:
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 30 31 32 33 34 |
public class FixedLengthNotebook extends NotebookDecorator { private int size = 4; public FixedLengthNotebook( NotebookInterface notebook ) { super( notebook ); resize(); } public FixedLengthNotebook( NotebookInterface notebook, int size ) { super( notebook ); this.size = size; resize(); } public void add(String sentence) { if ( notebook.count() < size ) { notebook.add(sentence); } } // Rezide the original notebook private void resize() { if ( count() > size ) { List<String> notes = new ArrayList<String>(); notes.addAll( notebook.notes().subList(0, size) ); notebook.clear(); notebook.notes().addAll( notes ); } } } |
Come visto nel caso gerarchico il solo metodo ridefinito è add() ma si noti che, come ci aspettiamo, l’operazione vera e propria è eseguita sull’istanza Notebook
passata nel costruttore. Proseguiamo quindi implementando la versione thread safe del notebook:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
public class ThreadSafeNotebook extends NotebookDecorator { public ThreadSafeNotebook(NotebookInterface notebook) { super( notebook ); } public synchronized List<String> notes() { return notebook.notes(); } public synchronized int count() { return notebook.count(); } public synchronized void add(String note) { notebook.add(note); } public synchronized void clear() { notebook.clear(); } } |
Anche in questo caso le operazioni sono eseguite sul notebook passato come parametro nel costruttore ma con la caratteristica di essere wrappate nel costrutto synchronized
. Concludiamo implementando anche la classe NotebookWithLogging:
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 |
public class NotebookWithLogging extends NotebookDecorator { public NotebookWithLogging( NotebookInterface notebook ) { super( notebook ); } public List<String> notes() { System.out.println( "Getting all notes"); return notebook.notes(); } public int count() { System.out.println( "Counting notes"); return notebook.count(); } public void add(String note) { System.out.println( "Adding note"); notebook.add(note); } public void clear() { System.out.println( "Clearing notebook"); notebook.clear(); } } |
Complessivamente la gerarchia delle classi che sono state implementate diviene quindi quella mostrata nella figura seguente.
A questo punto viene naturale chiederci che fine abbiano fatto le classi “composte” ThreadSafeFixedLengthNotebook
, NotebookWithLogging,
FixedLengthWithLoggingNotebook
, ThreadSafeWithLoggingNotebook
e ThreadSafeFixedLengthWithLoggingNotebook
, che avevamo introdotto nell’approccio gerarchico. La versatilità e potenza del pattern Decorator si esprime proprio nella possibilità di poter impilare più decorator in numero ed ordine diverso per costruire oggetti, a runtime, con funzionalità estese a partire da quelli base.
Ad esempio un notebook che sia contemporaneamente thread safe e di lunghezza fissa lo si ottiene nel modo seguente:
1 2 3 |
NotebookInterface notebook = new ThreadSafeNotebook( new FixedLengthNotebook( new Notebook() ) ); |
Oppure un notebook che sia contemporaneamente thread safe, di lunghezza fissa e che stampi dei log lo si ottiene nel modo seguente:
1 2 3 4 |
NotebookInterface notebook = new ThreadSafeNotebook( new FixedLengthNotebook( new NotebookWithLogging( new Notebook() ) ) ); |
Definizione Formale
Prima di concludere il post introduciamo formalmente il pattern Decorator e gli elementi che lo caratterizzano, prendendo a riferimento il class diagram seguente:
Component
: definisce l’interfaccia per gli oggetti ai quali possono essere aggiunte ulteriori responsabilità.ConcreteComponent:
definisce un oggetto su cui è possibile aggiungere ulteriori responsabilità.Decorator:
mantiene un riferimento all’oggetto componente e definisce un’interfaccia conforme all’interfaccia del componente. In parole semplici, ha un oggetto componente da decorare.ConcreteDecorator:
aggiungere responsabilità all’oggetto componente.
Il Codice Sorgente
Il codice sorgente del progetto è scaricabile qui decorator.