MokaByte Numero 33  - Settembre 1999   

Adapter

di
Lorenzo Bettini

Quando le interfacce non sono compatibili le possiamo "adattare".


Vediamo di risolvere i casi in cui del codice già esistente con una certa interfaccia (possibilmente non modificabile) deve essere riutilizzato in un nuovo o già esistente codice.


Interfacce

Un concetto fondamentale nella programmazione ad oggetti è l'incapsulamento, tramite il quale si nascondono certi dettagli implementativi a chi utilizzerà effettivamente una certa classe; questo non è dovuto semplicemente alla voglia di tenere nascosto l'implementazione di una procedura o la struttura di un oggetto, ma è più orientato a favorire il riutilizzo di quella classe: accedendo ai servizi o ai campi di un oggetto tramite metodi, si evita di conoscere i suoi dettagli, e questo ci permette di ignorarli: se la classe di tale oggetto verrà modificata noi potremmo tranquillamente continuare ad utilizzare tale oggetto come eravamo soliti fare (ho usato il condizionale, perché questo è vero solo se la classe è realizzata effettivamente bene).

Quindi noi accediamo agli oggetti tramite le loro interfacce: conosciamo i servizi che abbiamo a disposizione, ma non ne conosciamo i dettagli implementativi. Quindi tramite l'invocazione dei metodi noi inviamo richieste agli oggetti, ma non sappiamo come questi effettivamente realizzeranno tali servizi. Un'interfaccia quindi non è altro che una lista di metodi, ognuno con la propria signature.

Questo del resto non è diverso dal mondo reale (con la programmazione ad oggetti si vorrebbe infatti modellare meglio la realtà): quando ci rechiamo in un certo negozio o ufficio, effettuiamo delle richieste ai commessi o agli addetti, ma possiamo tranquillamente ignorare come loro esaudiscano le nostre richieste: ci interessa il risultato.

Ignorare i dettagli può essere limitativo, forse, ma ci slega da eventuali modifiche apportate a questi: noi potremo continuare ad utilizzare certi servizi anche se questi cambiano, oppure cambieranno. Del resto del software "interessante" ed effettivamente utile è intrinsecamente destinato a cambiare in futuro; la mancanza di evoluzione e di cambiamento di un programma è forse più sintomo di "morte" del progetto, che della bravura del programmatore che non ha inserito nessun bug nel programma.

Il concetto di interfaccia è effettivamente utilizzato in CORBA: se si vogliono utilizzare oggetti remoti, su architetture diverse con sistemi operativi diversi, per di più scritti in linguaggi diversi, si deve necessariamente basarsi sulla sola conoscenza dell'interfaccia di tali oggetti; ed infatti in CORBA, si devono definire proprio le interfacce tramite un particolare linguaggio di programmazione: l'IDL (Interface Definition Language). Non si discosta da questo nemmeno l'RMI (Remote Method Invocation) tramite il quale è possibile richiamare metodi di oggetti remoti; di questi è nota solo l'interfaccia.

Eventuali problemi

Sembrerebbe quindi che sfruttando l'incapsulamento e le interfacce si abbia "gratuitamente" la riusabilità di codice. Purtroppo non è così.

Ci possono essere infatti delle situazioni in cui si voglia utilizzare delle classi in contesti logicamente pertinenti, ma l'interfaccia non è totalmente compatibile con la maggior parte delle classi create appositamente per questi contesti. Con "logicamente" si intende anche il fatto che gli oggetti di queste classi sono in grado effettivamente di essere utilizzati in questi contesti: ci potrebbero essere infatti dei casi in cui il fatto che gli oggetti abbiano la stessa interfaccia, questo sia dovuto semplicemente al fatto che l'interfaccia sia uguale, ma solo di nome; consideriamo ad esempio un oggetto Pennello, ed un oggetto Window; entrambi possono avere il metodo draw, ma con evidente significato diverso: per il primo oggetto tale metodo servirà per disegnare qualcosa, mentre nel secondo servirà a ridisegnare l'oggetto. Le interfacce sono formalmente equivalenti, ma logicamente no; non si tratta di un errore di design, ma di un'omonimia (del resto lecita in un progetto di dimensioni notevoli).

I casi che interessano a noi sono invece quelli in cui due oggetti hanno metodi per effettuare le stesse operazioni, ma con nomi differenti; anche questo non dovrebbe essere considerato un errore di design: è vero che si dovrebbe sempre considerare la maggior parte delle eventualità, ma non sempre questo è possibile. La necessità di utilizzare un certo oggetto in un contesto non previsto all'inizio può verificarsi in un periodo successivo dello sviluppo. Pensare di modificare una delle due interfacce richiederebbe la modifica di tutte le classi della gerarchia interessata all'interfaccia modificata, e questo non solo non è pensabile, ma può essere anche considerata una soluzione poco elegante (ed in un certo senso anche poco pratica).

Questa soluzione, un po' brutale, potrebbe anche non essere comunque attuabile nel caso in cui si voglia utilizzare in un contesto differente oggetti di classi di cui si possiede solo le interfacce, ma non le implementazioni (vedi librerie di terze parti o di sistema).

Il pattern Adapter

Per non effettuare le modifiche alle interfacce e quindi alle gerarchia (cosa, come abbiamo detto, non sempre attuabile), si può ricorrere al pattern Adapter, che risolve il problema in modo semplice (e molto probabilmente la soluzione potrà già essere venuta in mente al lettore).

Per semplicità chiamaremo "nuova" l'interfaccia degli oggetti che vengono utilizzati nel determinato contesto, in cui vogliamo adesso utilizzare anche altri oggetti con interfaccia differente, che chiameremo "vecchia" (nel senso che era già esistente ma che non veniva utilizzata nel contesto in cui adesso la vogliamo utilizzare).

L'idea del pattern è di "adattare" la vecchia interfaccia a quella nuova, cioè creare uno strato di interfaccia in più fra quella nuova e quella vecchia; questo strato di astrazione in più non farà altro che adattare le richieste, che ci si aspetta dalla nuova interfaccia, a delle richieste plausibili per quella vecchia. Nella maggior parte dei casi si tratterà di richiamare il metodo corrispettivo nella vecchia interfaccia, senza nemmeno effettuare delle operazioni prima e/o dopo.

Per far questo si creerà una nuova classe che implementerà la nuova interfaccia, e che inoltri le richieste all'oggetto che appartiene alla classe che implementa la vecchia interfaccia. Un cliente della nuova interfaccia potrà utilizzare tranquillamente questa nuova classe nel contesto specifico, come ha sempre fatto; in realtà utilizzerà un oggetto con un'interfaccia effettivamente differente, ma adattato per quel contesto.

Vediamo come sempre in dettaglio i partecipanti a questo pattern:

  • Target: E' l'interfaccia (o anche la classe) specifica del contesto in cui vogliamo utilizzare i nuovi oggetti (quelli con la vecchia interfaccia).
  • Client: Sono gli oggetti che utilizzano, normalmente, gli oggetti con l'interfaccia Target.
  • Adattato (Adaptee): E' l'interfaccia che vogliamo adattare al nuovo contesto (quella che fino ad adesso abbiamo chiamato "vecchia").
  • Adattatore (Adapter): adatta l'interfaccia dell' Adaptee a quella Target.

Tipicamente l'Adapter conterrà al suo interno un riferimento ad un Adaptee, che utilizzerà per inoltrargli le richieste. Una soluzione alternativa potrebbe anche essere quella di far derivare l'Adapter sia da Target che da Adaptee (questo non è un vero problema in Java se invece di classi - per cui non è disponibile l'ereditarietà multipla - si utilizzano le interfacce: è possibile far implementare ad una classe più interfacce).

Se quindi ad esempio nella interfaccia del Target è dichiarato il metodo m, non presente nell'Adaptee, che però ha il metodo p che svolge fondamentalmente le stesse cose, l'adattatore non dovrà fare altro che richiamare il metodo p dell'Adaptee, quando riceve una richiesta di m.

Vi lascia come sempre ad Andrea Trentini per delle possibili implementazioni di questo pattern.

A presto :-)

Lorenzo