Dr. Carlo Pescio
Systematic Object Oriented Design, Parte 2

Pubblicato su Computer Programming No. 85


Riprendiamo la trattazione del Systematic OOD, esaminando le tecniche di trasformazione associate alla regola di stratificazione.

Alcuni mesi fa (precisamente a giugno 1999) ho presentato ai lettori di Computer Programming le quattro regole fondamentali del Systematic OOD. Pur con qualche mese di intervallo (dovuto, purtroppo, alla preponderanza del mio lavoro "reale" di progettazione e di formazione rispetto al mio pur grande interesse per la divulgazione e la scrittura) riprenderò in questa puntata il discorso interrotto, entrando nei dettagli della prima regola fondamentale e soprattutto delle tecniche di trasformazione ad essa associate.

Stratificazione
Ricordiamo che la prima regola del SysOOD [Pes99b] richiede che il design sia sempre stratificato, ovvero che nel grafo delle dipendenze tra le classi non vi siano cicli. Le conseguenze di una violazione della regola sono principalmente due: riusabilità ridotta (l'unità di riuso diventa il ciclo e non la classe) e potenziale degenerazione del ciclo (quando l'aggiunta di nuove classi comporta il loro inserimento nel ciclo, richiedendo la modifica di classi esistenti, riducendo ulteriormente la riusabilità ed aumentando la probabilità di una ulteriore degenerazione).
Ricordiamo anche che il SysOOD consta di due elementi fondamentali: le regole di design (con associate le conseguenze di una violazione) e le tecniche di trasformazione, che possono essere utilizzate per riportare il design entro i limiti segnati da ogni regola.
In questa puntata vedremo le tecniche di trasformazione primarie associate alla regola di stratificazione, ma approfitterò dell'occasione per presentare anche una semplice tecnica derivata per composizione di tecniche elementari.
Chi ha seguito i miei corsi sul SysOOD sa che preferisco sempre partire da esempi real-world e poi spostarmi verso le tecniche più astratte. Per ragioni di spazio, in questa sede non potrò presentare un caso reale di design per ogni singola tecnica di trasformazione: dovrò limitarmi a presentare le tecniche in un contesto più astratto, ma la discussione dovrebbe essere ragionevolmente semplice da seguire per chiunque abbia un po' di pratica nella programmazione ad oggetti. In particolare, per tutte le regole seguenti farò riferimento ad una situazione iniziale come quella di figura 1, dove due classi A e B si conoscono reciprocamente (ovvero, A chiama metodi di B, e B chiama metodi di A). Le tecniche presentate si applicano ovviamente anche a dipendenze circolari in cui sono coinvolte più di due classi: la discussione chiarirà di volta in volta come operare.


Asymmetric Splitting
Abbiamo già incontrato questa semplice (ma potente) tecnica di trasformazione in un articolo pubblicato ad inizio anno [Pes99a], in cui ho presentato la genesi del SysOOD. Ora avremo l'occasione di discutere più a fondo la tecnica, la sua applicazione e le sue conseguenze.
Se ci troviamo nelle condizioni di figura 1, e non ci interessa riusare B indipendentemente da A (o viceversa), non dobbiamo fare nulla: il fatto che l'unità di riuso sia il ciclo non comporta problemi.
Altrimenti, supponiamo di voler riusare B in modo indipendente da A. Cerchiamo quindi il sottoinsieme dei metodi di A che vengono utilizzati da B. Definiamo una classe interfaccia (quindi priva di implementazione) che dichiara tali metodi (A1). Possiamo adesso legare B ad A1 anziché ad A, in quanto l'interfaccia A1 espone tutte le funzioni che B intende chiamare. Infine, deriviamo A da A1, rinominandola A2 giusto per distinguerla dalla classe originale A. Otteniamo il diagramma di figura 2, che è privo di dipendenze circolari.
Naturalmente, "qualcuno" dovrà passare un riferimento all'oggetto di classe A2 (visto attraverso la sua interfaccia A1) ad ogni oggetto B. Si tratterà normalmente dell'oggetto di classe A2 stesso, che ha un riferimento a B e può quindi utilizzarne i metodi.

Fermiamoci un istante a considerare le conseguenze di questa tecnica di trasformazione. Innanzitutto, B diventa riusabile anche in contesti in cui A non è desiderata; si "porta dietro" comunque l'interfaccia A1, ma una interfaccia è raramente un problema, e sicuramente non ha impatti sulla dimensione del codice. D'altro canto, la classe A diventa ora "estendibile", nel senso che A1 può essere visto come un hot-spot di estendibilità: possiamo infatti derivare qualunque classe da A1, e la classe B sarà in grado di utilizzarla attraverso l'interfaccia astratta (per ulteriori considerazioni sul ruolo delle classi interfaccia, si veda [Pes98a], disponibile anche tramite la mia home page).
Questa asimmetria tra ciò che accade ad A e ciò che accade a B (che spesso useremo proprio per sfruttare una asimmetria di ruoli già esistente nel diagramma iniziale) mi ha portato a chiamare la trasformazione "taglio asimmetrico". Una tecnica semplice, ma decisamente importante: è alla base di numerosi pattern [GoF94], come Observer (si veda [Pes98b] per una derivazione di Observer in SysOOD), Multicast, eccetera.
La tecnica che abbiamo visto è però solo una delle trasformazioni possibili. Come possiamo capire se si tratta della scelta migliore? Ricorderete che il SysOOD prevede non solo regole oggettive e tecniche sistematiche, ma anche regole euristiche per capire se stiamo scegliendo la strada migliore. In questo caso, uno dei parametri migliori è: siamo in grado di dare un nome rappresentativo all'interfaccia A1? Se A1 è solo una accozzaglia di funzioni non correlate tra loro, o se la scelta migliore che trovate è un nome che non ha nulla di astratto, e che richiede una "I" iniziale per indicare che si tratta di una interfaccia (lo stile comodo, ma ben poco sensato, del COM), è possibile che un'altra tecnica di trasformazione sia più indicata. Ma è anche possibile che l'interfaccia in questione sia grossolana o incompleta: potrebbe essere utile suddividerla ulteriormente in più interfacce, o aggiungere alcune funzioni che completino il modello dell'astrazione relativa. Queste considerazioni sono tutte inerentemente domain-specific, e non sono formalizzabili in regole oggettive: la regola di stratificazione mostra il problema, la tecnica di trasformazione indica la strada, ma sta poi al progettista completare i minimi dettagli ed aggiungere la propria conoscenza del problema per arrivare alla soluzione migliore.

Symmetric Splitting
Il difetto principale della tecnica di Asymmetric Splitting è proprio la proliferazione di "piccole" interfacce, ovvero interfacce con un numero limitato di metodi, non di rado uno o due. Personalmente non trovo la cosa particolarmente fastidiosa, purché le interfacce rappresentino astrazioni significative nel dominio del problema o della soluzione. Ma non posso negare che in situazioni semplici si possa preferire una soluzione diversa, che aggiunga una sola interfaccia anziché, potenzialmente, una per ogni classe coinvolta (pensate al caso in cui si voglia ottenere la riusabilità di ogni classe appartenente al ciclo).
Una possibilità è data allora dal cosiddetto "taglio simmetrico", che deve il nome proprio al trattamento uniforme di ogni .
Anche in questo caso, l'idea di fondo è molto semplice: creiamo una nuova interfaccia C1, e dotiamola di tutti i metodi di A che vengono chiamati da B, ed anche di tutti i metodi di B che vengono chiamati da A. Sganciamo A da B e B da A, agganciandole invece ad un riferimento a C1 (per precisione, chiamiamo le due nuove classi A2 e B2). In pratica, mentre prima A chiedeva un servizio a B, ora A2 lo chiederà a C1, e lo stesso farà B2 per i servizi che B richiedeva ad A. C1 è una classe interfaccia, che implementeremo in una classe concreta C2. C2 manterrà un riferimento ad A2 ed uno a B2, e utilizzerà tali riferimenti per implementare (spesso attraverso un semplice smistamento) i metodi di C1. L'oggetto di classe C2 passerà anche un riferimento a se stesso (attraverso l'interfaccia C1) sia ad A2 che a B2.
La situazione ottenuta è quella di figura 3: notiamo che esiste una sola interfaccia di riferimento C1, e che siamo liberi di cambiare l'implementazione C2 senza toccare A2 e B2.

Si impongono ora alcune considerazioni. La prima è che C2 è una classe manager, la cui unica ragione di vita è la gestione delle cooperazioni tra le classi A2 e B2. Le classi manager sono particolarmente amate da alcuni (per la loro semplicità realizzativa) e particolarmente odiate da altri (si veda ad esempio la regola del "er" in [CN93]). Ritornerò sull'argomento in futuro, parlando di accoppiamento e coesione, dando cioè dei criteri oggettivi per valutare l'idoneità di una classe manager. Chi vuole può fare riferimento ad un mio precedente lavoro ([Pes97a]), che è poi confluito nel Systematic OOD. Il taglio simmetrico genera il pattern Mediator, ed anche in questo caso potete fare riferimento a [Pes98b] per una derivazione del pattern.
Una regola euristica che possiamo però introdurre sin d'ora è la seguente: il taglio simmetrico si rivela la soluzione migliore soprattutto quando l'interfaccia C1 presenta un carattere di stabilità. Ovvero, quando per costruzione, o per adattamento intelligente da parte del progettista, si rende subito evidente che l'eventuale aggiunta di classi simili ad A2 e B2 nel sistema non comporterà una modifica all'interfaccia C1. Se invece risulta chiaro che C1 sarà inerentemente instabile, e che non c'é modo di renderla tale adattando i metodi che la compongono, probabilmente il taglio simmetrico non è la soluzione migliore: ci porterebbe infatti ad una classe manager problematica, da ritoccare ogni volta che si inserisce un nuovo elemento.

Generic Class
Le due tecniche viste sopra offrono riusabilità di una o più classi, ed estendibilità di altre: è una tipica conseguenza dell'uso delle classi interfaccia, e del conseguente polimorfismo a run-time. Non sempre è però necessario impostare una soluzione che permette il polimorfismo a run-time: talvolta, può essere più vantaggioso seguire una strada diversa, che garantisca il riuso ma non l'estendibilità. La strada maestra è allora l'uso della programmazione generica, che offre il vantaggio di un maggiore controllo statico sui tipi (si veda [Pes98c], disponibile anche tramite la mia home page, per un approfondimento del tema).
Per rompere una dipendenza circolare attraverso la programmazione generica, scegliamo una classe che vogliamo rendere riusabile (es. A). Riscriviamo A sotto forma di template, sostituendo un tipo T generico ad ogni occorrenza del tipo B precedente: chiamiamo questa classe A3. Notiamo che A3 risulta (per costruzione) riusabile con ogni tipo T che rispetti l'interfaccia del tipo B originale (tralascio qui ogni considerazione circa la specifica esplicita o implicita di tale interfaccia, rimandando nuovamente a [Pes98c] per i dettagli).
La classe B non sarà naturalmente legata alla classe generica A3, ma ad una sua specifica istanza, in particolare l'istanza A3< B >. Otteniamo quindi il diagramma di figura 4, dove apparentemente abbiamo sempre una dipendenza circolare. Tuttavia, il ramo verde della dipendenza è apparente, nel senso che la classe A3< B > non ha un codice proprio che siamo interessati a riusare, ma è generata dal compilatore come istanza del template A3 sul parametro B. La dipendenza apparente rimane a ricordarci che la tecnica della generic class non consente l'estendibilità a run-time, ovvero non è possibile sostituire a B una classe diversa senza ricompilare il programma.

Quando è utile la tecnica della generic class? In generale, quando non siamo interessati ad ottenere un programma estendibile (almeno non nella parte in cui useremo questa tecnica), ma solo alla riusabilità di alcune classi. Inoltre, quando risultano preponderanti le ragioni di type safety esposte nel succitato [Pes98c]. Infine, quando la riusabilità non deve avere alcun impatto sulle prestazioni: ricordiamo infatti che interporre una classe interfaccia può portare ad un peggioramento nelle performance, dovuto non tanto al dispatch dinamico quanto all'impossibilità di inlining delle funzioni virtuali.

Merge
La tecnica decisamente più semplice per eliminare una dipendenza circolare consiste sicuramente nel fondere le classi coinvolte! Ovviamente, questo non rimedia il problema del riuso indipendente: ciò nonostante, talvolta è la scelta migliore, per due ragioni distinte. La prima è che occasionalmente, come progettisti, ci lasciamo prendere la mano dal principio di "separation of concern" e creiamo delle distinzioni artificiali tra classi che, di fatto, rappresentano un unico concetto. L'accoppiamento circolare emerge quindi come indizio che, forse, le diverse astrazioni possono essere unificate. La seconda ragione, che per ora lascio a livello intuitivo (non avendo ancora definito delle misure oggettive) è che talvolta abbiamo semplicemente mal partizionato le responsabilità delle classi. Riunire classi con dipendenze circolari in una sola classe diventa quindi un passo intermedio di una serie di trasformazioni, intese a ridistribuire meglio i compiti. Vedremo più avanti, quando parleremo di accoppiamento e coesione, come questi concetti possano essere resi oggettivi.
La tecnica di fusione va comunque usata con parsimonia. Esempi infausti del suo utilizzo (guidato evidentemente da una fallace intuizione) abbondano nelle librerie commerciali: si veda ad esempio MFC, che fonde il concetto di RecordSet e di Record corrente in una sola classe, con conseguenze gravi sotto molti punti di vista.

Recursive Parent
Vi presento ora un esempio di "trasformazione derivata", ovvero ottenuta tramite composizione di tecniche più semplici (ad alcuni sarà evidente la struttura algebrica che sottende tutto il Systematic OOD; rimane però uno strato formale che sostiene, ma non invade, un approccio decisamente pragmatico).
Considerate il diagramma di figura 5/1: abbiamo una classe base che dipende da una sua classe derivata. Si tratta di una situazione anomala, che tuttavia ho riscontrato in diversi progetti e librerie reali. Inutile dire che un simile design ha seri problemi di estendibilità della gerarchia, e che la classe base B non è riusabile in assenza della particolare classe derivata D.

Supponiamo di voler rendere B riusabile in assenza di D. Possiamo allora utilizzare il taglio asimmetrico, ottenendo la situazione di figura 5/2. Notiamo che D deriva adesso da B e da D1. Le funzioni di D1 sono le sole funzioni di D che venivano utilizzate da B.
A questo punto possiamo fermarci a riflettere: se le funzioni di D1 sono un sottoinsieme delle funzioni di B, possiamo fondere B e D1. Altrimenti, possiamo valutare l'opportunità di aggiungere le funzioni di D1 a B: ovviamente, questo ha senso solo se l'astrazione risultante è corretta e significativa. Se così è, possiamo nuovamente fondere B e D1, ottenendo il diagramma di figura 5/3. Notiamo che ora la classe base B conosce solo se stessa, e questo apre la strada non solo al riuso ma anche all'estensione.
Questa tecnica è alla base di pattern come Chain of Responsibility. Vi lascio come esercizio una riflessione su come, partendo sempre dal diagramma di figura 5/2 ed applicando un diverso ragionamento (motivato da un diverso contesto) si arrivi invece a pattern come il Composite.

Conclusioni
Immagino sia evidente a tutti i lettori come l'approccio del SysOOD sia abbastanza diverso da quello tradizionale dei metodi basati sui "grandi principi" di progettazione. In questa puntata abbiamo preso in esame una regola di design molto semplice (e verificabile oggettivamente, anche in modo automatico), e diverse tecniche di trasformazione, ognuna applicabile in modo sistematico. Abbiamo anche visto alcune regole euristiche che possono guidare il progettista nella scelta della tecnica di trasformazione più adatta. Più avanti vedremo anche alcune metriche oggettive che possono completare la visione intuitiva dalle regole euristiche.
Nella prossima puntata prenderemo invece in esame la seconda regola del SysOOD e le tecniche di trasformazione ad essa associate. Come sempre, se avete qualche considerazione che volete condividere, mandatemi un email all'indirizzo pescio@eptacom.net.

Bibliografia
[CN93] Peter Coad, Jill Nicola, "Object Oriented Programming", Prentice-Hall, 1993.
[GoF94] Gamma ed altri, "Design Patterns", Addison-Wesley, 1994.
[Pes97a] Carlo Pescio, "Manager Classes: a Software Engineering Perspective", Object Expert, May/June 1997.
[Pes98a] Carlo Pescio, "Oggetti ed Interfacce", Computer Programming No. 63.
[Pes98b] Carlo Pescio, "Deriving Patterns from Design Principles", Journal of Object Oriented Programming, November/December 1998.
[Pes98c] Carlo Pescio, " Programmazione ad Oggetti e Programmazione Generica ", Computer Programming No. 62.
[Pes99a] Carlo Pescio, "Systematic Object Oriented Design", Computer Programming No. 76.
[Pes99b] Carlo Pescio, "Systematic Object Oriented Design, Parte 1", Computer Programming No. 81.

Biografia
Biografia Carlo Pescio svolge attivitā di consulenza e formazione in ambito internazionale nel campo delle tecnologie Object Oriented. Ha svolto la funzione di Software Architect in grandi progetti per importanti aziende europee e statunitensi. Č l'ideatore del metodo di design ad oggetti SysOOD (Systematic Object Oriented Design), ed autore di numerosi articoli su temi di ingegneria del software, programmazione C++ e tecnologia ad oggetti, apparsi sui principali periodici statunitensi. Laureato in Scienze dell'Informazione, č membro dell'ACM e dell'IEEE.