Decorator Design Pattern

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:

  1. le nuove funzionalità sono “aggiunte” a tempo di compilazione e si applicano a tutte le istanze della classe;
  2. potenzialmente gerarchie di questo tipo possono essere molto estese quando si intende modellare comportamenti molto diversi;
  3. non è possibile controllare l’ordine in cui i diversi comportamenti vengono concatenati nell’oggetto risultante, essendo la gerarchia rigidamente definita;
  4. 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.

A partire da tale interfaccia una possibile sua implementazione utilizza un lista per conservare tutte le note, come mostrato nella classe Notebook:

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:

  1. Single Responsibility Principle in quanto aggiungiamo responsabilità alla classe;
  2. 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:

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:

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:

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, FixedLengthWithLoggingNotebookThreadSafeWithLoggingNotebook 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:

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:

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:

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:

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” ThreadSafeFixedLengthNotebookNotebookWithLogging, FixedLengthWithLoggingNotebookThreadSafeWithLoggingNotebook 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:

Oppure un notebook che sia contemporaneamente thread safe, di lunghezza fissa e che stampi dei log lo si ottiene nel modo seguente:

Oltre a semplificare la gerarchia di classi e migliorare la testabilità, il Decorator incoraggia lo sviluppatore a scrivere codice che aderisca ai principi di progettazione SOLID. Infatti, usando tale pattern, vengono aggiunte nuove funzionalità senza modificare le classi esistenti. Inoltre, il pattern incoraggia l’uso dell’inversione delle dipendenze (dependency inversion), che ha diversi vantaggi come il basso accoppiamento (loose-coupling) e la testabilità.

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.

 

How useful was this post?

Click on a star to rate it!

Average rating 4.7 / 5. Vote count: 10

No votes so far! Be the first to rate this post.

As you found this post useful...

Follow us on social media!