Dr. Carlo Pescio
Design: Gestione dei Dati

Pubblicato su Computer Programming No. 42


Discutiamo la normalizzazione ed ottimizzazione delle relazioni, la persistenza dei dati e le diverse tecniche per ottenerla, dai database object oriented a tecniche più tradizionali di serializzazione dei dati.

Introduzione
Nella puntata precedente abbiamo visto come affrontare il dominio del problema (e della soluzione) dal punto di vista del design: in effetti, abbiamo posto al centro del discorso gli algoritmi per la risoluzione del problema, le strutture dati necessarie al loro corretto funzionamento, ma abbiamo lasciato in disparte due aspetti fondamentali: i dati e le relazioni.
In questa puntata ci occuperemo delle modifiche che la fase di design introduce a livello dei dati, parleremo di normalizzazione ed ottimizzazione delle relazioni, della persistenza dei dati e delle diverse soluzioni per ottenerla, dai database object oriented a tecniche più tradizionali di serializzazione dei dati.

Relazioni ed Oggetti
Come era stato anticipato nella terza puntata della serie, oltre alle relazioni tutto/parti e generalizzazione/specializzazione, che trovano un corrispondente immediato a livello di design e di implementazione (ovvero rispettivamente contenimento ed ereditarietà), abbiamo anche relazioni di associazione generiche, come "una persona abita in una città". Questo tipo di relazioni può essere modellato direttamente nei linguaggi per database object oriented (che vedremo in seguito in questa puntata); tuttavia, ciò non è normalmente possibile nei linguaggi object oriented "tradizionali", come il C++ o Smalltalk. Poiché il fine del design è di ottenere una descrizione della soluzione che possa essere implementata senza ulteriori modifiche strutturali, è necessario avere ben chiaro quali siano le possibilità del linguaggio target; ciò significa che, in molti casi, ci troveremo a dover riconsiderare le relazioni generiche in termini delle strutture comunemente disponibili nei linguaggi di programmazione.
Esistono diversi metodi per modellare una relazione generica all'interno dei normali costrutti di un linguaggio, alcune delle quali sono applicabili solo in casi particolari: modellare le relazioni come attributi (eventualmente con puntatori), come classi, oppure utilizzare il metodo relazionale all'interno del framework object oriented. Vedremo di seguito le diverse soluzioni, analizzandone i diversi aspetti positivi e negativi.

Relazioni come attributi diretti
Riprendendo l'esempio precedente, "una persona abita in una città", abbiamo una cardinalità (1,1) tra la classe persona e la classe città, ed una cardinalità (0,n) tra la classe città e la classe persona: ciò riflette l'idea che una persona abita in una sola città (almeno dal punto di vista del domicilio legale) e che in una città abita un numero imprecisato di persone.
Il lato interessante è quello che ha cardinalità (1,1): in effetti, ciò significa che è teoricamente possibile "immergere" la relazione all'interno dell'oggetto persona: semplicemente aggiungendo un membro di tipo città all'oggetto persona, avremo modellato la relazione rimanendo all'inteno dei costrutti elementari dei linguaggi. Esistono purtroppo diversi problemi associati a questo modello di rappresentazione, alcuni di tipo concettuale, altri di ordine pratico:

  1. Stiamo sfruttando in modo molto forte una proprietà del dominio del problema all'interno della soluzione: una modifica apparentemente innocua ai requisiti, apportata alla cardinalità della relazione, potrebbe richiedere una revisione totale del design. Dovremmo quindi limitare questo modello ai casi più stabili del dominio del problema.
  2. Introducendo all'interno della classe persona le relazioni in cui essa è coinvolta, riduciamo le possibilità di riuso: in un contesto differente, la classe persona potrebbe essere riutilizzata in sé, ma difficilmente le relazioni che emergono in un utilizzo della classe si ripeteranno in un altro. La classe diventa artificiosamente complessa, quindi più difficile da riutilizzare: è quindi meglio evitare questo modello ogni volta che si voglia perseguire la riusabilità della classe coinvolta.
  3. Non è possibile rappresentare una città senza avere anche una persona che vi abiti. Corrispondentemente, rimuovendo l'ultimo oggetto persona associato ad una città, perdiamo anche la città stessa. Questi problemi sono analoghi alle "anomalie di inserimento/cancellazione" nella teoria della normalizzazione dei dati (prima forma normale).
  4. Se un dato della città cambia (ad esempio la provincia di appartenenza è soggetta a modifiche nel tempo) è necessario esaminare tutti gli oggetti persona alla ricerca di una occorrenza della città per poterla modificare. Questo problema è analogo alla "anomalia di modifica" nella teoria della normalizzazione dei dati (prima forma normale).
La succitata prima forma normale, all'interno del modello relazionale dei dati, richiede che tutti gli attributi siano di tipo elementare, ovvero non consente il contenimento di oggetti strutturati. All'interno del paradigma object oriented, questa regola è spesso considerata troppo restrittiva, e non viene osservata con il rigore del modello relazionale: esistono in effetti dei casi in cui la strada degli attributi diretti è non solo percorribile, ma anche perfettamente indicata. Consideriamo ad esempio un oggetto "grafico" posto in relazione con un oggetto "viewport" che ne stabilisca la scalatura e la posizione; anche se l'oggetto di classe viewport non è di tipo elementare, non esistono reali problemi nell'inserirlo come attributo del grafico. Di conseguenza, l'uso degli attributi per modellare una relazione non è una soluzione da scartare a priori: per quanto soffra di numerosi svantaggi, ha pregi di immediatezza ed efficienza non trascurabili, ed è normalmente utilizzabile quando non si preveda di condividere l'oggetto posto in relazione.

Relazioni come attributi indiretti
Riconsiderando il punto precedente, possiamo vedere che alcune delle anomalie sorgono proprio dall'aver rinunciato alla possibilità di sharing degli oggetti: due persone che abitano nella stessa città conterranno due istanze distinte di città aventi gli stessi valori (il concetto di object identity contrapposto all'uguaglianza tra oggetti verrà ripreso più avanti). Se fosse possibile condividere un solo oggetto, elimineremmo immediatamente il problema 4 (anomalie di modifica), e come vedremo altre verrebbero eliminate come effetto collaterale. In ogni linguaggio O.O. è in effetti possibile condividere un oggetto con altri oggetti tramite i reference o i puntatori: in questo caso, potremmo inserire un reference (o un puntatore) a città all'interno della classe persona, e fare in modo che se due persone abitano nella stessa città, lo stesso oggetto venga referenziato all'interno di entrambe. L'uso degli attributi indiretti (così chiamati in quanto introducono un livello di indirezione) comporta anche le seguenti conseguenze:

  1. È possibile modellare anche relazioni del tipo (0,1) oltre che (1,1), utilizzando un reference ad un oggetto "nullo" o il puntatore nullo.
  2. È possibile creare e distruggere gli oggetti referenziati indipendentemente dagli oggetti che li referenziano: in tal modo, possiamo eliminare le anomalie di inserimento e di cancellazione.
  3. Se consideriamo un attributo di tipo array o lista come un attributo indiretto, possiamo modellare relazioni del tipo (0,n).
Rimangono invece i problemi relativi alla riusabilità ed alla mantenibilità della soluzione; si aggiungono inoltre dei problemi di overhead, spesso trascurabili ma comunque esistenti: esiste un overhead sullo spazio (necessità di memorizzare sia il puntatore/reference che l'oggetto referenziato) che sul tempo (tutti gli accessi all'oggetto referenziato avvengono tramite un livello di indirezione). In molti linguaggi, l'uso dei puntatori comporta inoltre altri tipi di overhead, dovuti all'allocazione/deallocazione dinamica, alla risoluzione delle chiamata virtuali, eccetera; il lettore interessato al caso del C++ può consultare [3]
In generale, potremmo considerare la soluzione degli attributi indiretti come preferibile alla soluzione con attributi diretti ogniqualvolta non esistano seri problemi di prestazioni o di occupazione di memoria, e quando si sia comunque disposti ad accettare le difficoltà di riuso di cui sopra.

Relazioni come classi
Proprio per eliminare alla radice le problematiche di riuso, possiamo pensare di modellare la relazione in modo totalmente esterno alle classi coinvolte nella stessa: in tal modo, le classi non avranno conoscenza del contesto in cui le impiegheremo, e pertanto potranno essere riutilizzate più facilmente in contesti differenti. Modellare una relazione come classe è molto semplice: la classe "relazione" sarà costituita da un insieme di coppie (o tuple nel caso di relazioni non binarie) di puntatori ai singoli elementi; ad esempio, la relazione "una persona abita in una città" verrà rappresentata attraverso una coppia (puntatore/reference a persona, puntatore/reference a città). Così come la soluzione basata su attributi indiretti, l'uso dei reference permette lo sharing degli oggetti ed elimina le anomalie relative; la memorizzazione dei puntatori esternamente alle classi permette invece una più semplice manutenzione ed un migliore riutilizzo. Nuovamente, abbiamo un overhead in termini di spazio (un ulteriore puntatore rispetto al caso degli attributi indiretti) ed anche di tempo rispetto alla soluzione precedente. Come sempre, la scelta del design più adeguato va eseguita sui singoli casi, e non esiste la soluzione perfetta per ogni esigenza; talvolta, le prestazioni sono più importanti della riusabilità; spesso, è vero il viceversa. Modellare le relazioni come classi è di norma la soluzione più elegante, mantenibile e flessibile, con maggiori opportunità di riutilizzo per le classi coinvolte nella relazione.

Usare il metodo relazionale
Un approccio molto diverso consiste invece nella fusione del metodo relazionale [4] con l'approccio object oriented. Nel modello relazionale un'entità, così come una relazione, è rappresentata attraverso una tupla di attributi elementari (per evitare le anomalie di cui sopra); ogni tupla ha una sua chiave, formata dalla concatenazione di un certo numero di campi: la chiave determina in modo univoco il record. Una relazione è quindi rappresentata concatenando le chiavi degli elementi che partecipano alla relazione stessa.
Vi è una profonda differenza tra il modello relazionale e quello object oriented, determinato proprio dal concetto di chiave e di esistenza degli oggetti; nel modello relazionale, un elemento esiste come tupla di valori: se due elementi hanno gli stessi valori in ogni campo, sono lo stesso oggetto. Nel modello object oriented, un oggetto esiste in sé, indipendentemente dal valore che assume (che di norma è incapsulato); pertanto due oggetti possono essere uguali come valore ma diversi come identità. Se cerchiamo di modellare gli oggetti basandoci sui concetti relazionali, dobbiamo gestire questa differenza manualmente, in quanto non avremo alcun supporto dal formalismo O.O e dal linguaggio di programmazione; per una analisi più approfondita delle differenze tra i due modelli, potete consultare [5].
Come si riflette, a livello pratico, la scelta di usare il metodo relazionale? Per ogni classe nel dominio del problema, dovremo definire una classe ausiliaria nel dominio della soluzione, atta a contenere la chiave della classe originale. Ad esempio, per la classe città del dominio del problema potremmo utilizzare il nome o il CAP come chiave della stessa. La classe originale verrà poi modellata nel dominio della soluzione inserendo un oggetto della relativa classe chiave come attributo, in sostituzione degli attributi che abbiamo inserito nella classe chiave stessa. Notiamo che in alcuni casi sarà necessario introdurre una chiave "artificiale", ad esempio un contatore; osserviamo inoltre che, essendo le chiavi per definizione tutte distinte, non si pone il problema dello sharing di oggetti pur utilizzando un attributo diretto.
Dovremo poi definire un metodo class-level (ad esempio come metodo statico in C++) che data una chiave ritrovi l'elemento (se esiste) corrispondente a tale chiave; questo metodo può facilmente essere implementato per funzionare sia in memoria centrale che di massa, semplificando così la gestione della persistenza.
Le relazioni non saranno a questo punto diverse da ogni altra classe, e verranno rappresentate tramite aggregazione di oggetti chiave delle classi che partecipano alla relazione stessa. È importante notare che all'interno del modello relazionale esiste un'ampia teoria della normalizzazione dei dati, di cui la prima forma normale citata sopra costituisce una parte molto ridotta; alcuni concetti, come quello di dipendenza funzionale, hanno in effetti rilevanza anche ai fini dei modelli object oriented, mentre altri (es. forma normale di Boyce-Codd) sono più strettamente legati al modello relazionale. Si tratta di argomenti molto importanti per chiunque si accinga a progettare una base dati, ma troppo ampi per essere adeguatamente trattati in questa sede: il lettore interessato può consultare un qualunque testo moderno sui database relazionali, ad esempio [6]
I lati negativi dell'approccio sono il notevole overhead di codice, dovuto sia alle classi chiave, che alla necessità di alcune classi "adattatore" verso l'ambiente relazionale (oltre naturalmente all'engine relazionale stesso); per contro, come vedremo, la gestione della persistenza dei dati è spesso semplificata.

Persistenza dei dati
Le considerazioni e le scelte su esposte si applicano al momento della definizione degli attributi e delle classi, all'interno della fase di design; esse influenzeranno direttamente sia l'esistenza di alcune classi che il layout interno di altre. Rimane comunque in sospeso un problema molto sentito, ovvero come memorizzare gli oggetti su disco e come recuperarli: questo problema, che viene in genere detto di "persistenza dei dati", è di soluzione tutt'altro che immediata, e può essere affrontato a due differenti livelli:

  1. Utilizzando un database orientato agli oggetti; in questo caso, la persistenza degli oggetti è garantita dal database stesso. I database object oriented sono relativamente nuovi, e non vi sono molti prodotti commerciali disponibili; in ogni caso, esistono sia prodotti stand-alone (equiparabili ad un tradizionale package con funzionalità di database) sia librerie che possono essere utilizzate con linguaggi tradizionali. Purtroppo, tali librerie richiedono spesso l'uso di un pre-processore specializzato per ottenere codice compilabile, e questo ha conseguenze negative nelle fase di testing e soprattutto di debugging, dove si perde il legame con il codice originale.
  2. Implementando una soluzione all'interno di un normale linguaggio object oriented; in questo caso, dovremo in genere definire manualmente, per ogni classe, alcuni metodi che si occuperanno della gestione specifica dei nostri dati.

Serializzazione
Rimanendo all'interno dei linguaggi tradizionali, un metodo molto semplice per memorizzare un oggetto consiste nel "serializzarlo", ovvero emettere in un formato memorizzabile su file la sequenza (serie) dei suoi campi. L'operazione è invertibile per recuperare gli oggetti. Questa soluzione lascia scoperti numerosi problemi, che possono essere affrontati in modi diversi:

I puntatori non possono in genere essere memorizzati come tali, in quanto non vi è nessuna garanzia che ricaricando gli oggetti il precendente valore del puntatore sia valido e punti alla corretta area di memoria. A questo proposito esistono diverse soluzioni, tra cui:
  1. utilizzare uno pseudo-puntatore, ovvero un indice di riferimento all'oggetto puntato che viene memorizzato altrove.
  2. utilizzare uno schema pseudo-relazionale, memorizzando la chiave dell'oggetto puntato.
Osserviamo che i due approcci coincidono se consideriamo l'indice come chiave dell'oggetto puntato; in entrambi i casi, è comunque necessario avere a disposizione un meccanismo di ricerca di un oggetto dato un indice o una chiave; per esigenze di prestazioni, ciò comporterà l'uso di tutto l'apparato ben noto di tecniche di indicizzazione, cache, e così via, oltre agli eventuali problemi di accesso concorrente e di sharing dei dati Si tratta comunque in genere di codice che può essere posto in una libreria di classi e riutilizzato senza sforzo nei singoli casi.
Il polimorfismo pone un problema piuttosto rilevante, in quanto un puntatore o reference a classe base può in effetti puntare ad un elemento di classe derivata; ciò richiede particolari cautele sia al momento della memorizzazione che al momento della lettura. Supponendo ad esempio di utilizzare un diverso file per gli oggetti di ogni classe, è necessario conoscere la classe di appartenenza dell'oggetto puntato al momento della memorizzazione: ciò è ottenibile attraverso il supporto esplicito del linguaggio (se esiste, come in Smalltalk o nelle versioni del C++ con Run-Time Type Information) o attraverso una gerarchia di classi con un metodo virtuale per l'identificazione.
Al momento della lettura, il problema è ancora più sentito in quanto non sappiamo a priori a quale classe apparterrà l'oggetto che dobbiamo leggere; in questo caso, esistono diverse strategie, come il mantenimento dell'informazione della classe insieme allo pseudo-puntatore, o la gestione di un dizionario globale, o la scansione parallela di più file indice.
È importante notare che tutte queste operazioni possono essere implementate in classi di più alto livello solo in parte: esiste sempre la necessità di scrivere codice ad-hoc per ogni singola classe, e di mantenerlo al variare dello schema logico. Proprio per questo motivo le librerie più diffuse per realizzare OODB all'interno dei linguaggi di programmazione richiedono l'uso di un preprocessore che gestisca la parte di codice ad-hoc che occorre generare per ogni specifica classe.
Chi desiderasse approfondire l'argomento su un testo completo, che affronta la realizzazione di un metodo per la persistenza degli oggetti in C++, può consultare [7].

Uso di un Database Relazionale
Come visto in precedenza, uno dei metodi per rappresentare gli oggetti è quello di far riferimento al modello relazionale; in questo caso, è molto vantaggioso sfruttare una delle tante librerie esistenti per la gestione relazionale dei dati. Di norma, ciò richiederà comunque la creazione di alcune classi "adattatore" che isolino la visione relazionale (verso il database) e quella orientata agli oggetti (verso i clienti della classe). Può comunque essere una scelta vantaggiosa in numerosi casi, ad esempio quando esistano esigenze di condivisione con una base dati esistente o di interoperabilità con altri prodotti basati sul modello relazionale.

Object Oriented Databases
Pur con i limiti evidenziati sopra, talvolta la scelta migliore consiste nell'utilizzo di un completo database obiect oriented: ciò è vero specialmente nei casi in cui un numero molto elevato di oggetti, con interconnessioni non banali, abbia requisiti di persistenza, flessibilità e di mantenibilità. Va detto che si tratta spesso di prodotti dal costo piuttosto elevato e talvolta con limitazioni piuttosto pesanti; proprio per evitare l'errore di considerare "object oriented" un database tradizionale con alcune estensioni orientate agli oggetti, magari sulla base delle affermazioni del produttore, ho riportato in una side-bar il decalogo dei database orientati agli oggetti, come riferimento per la valutazione di un prodotto prima dell'eventuale acquisto.
Una possibile alternativa è l'uso di un prodotto CORBA-compliant [8]: in questo caso, l'object-request broker è in grado di gestire la persistenza e la distribuzione degli oggetti. Attualmente, il problema maggiore associato a tali sistemi è quello delle prestazioni: se tuttavia state progettando un sistema che dovrà evolversi nei prossimi anni, e che abbia requisiti di interoperabilità con altri prodotti CORBA, potrebbe essere molto interessante considerare l'alternativa CORBA tra le possibili soluzioni.

Conclusioni
Ancora una volta, il passaggio al paradigma object oriented non significa il rifiuto dei concetti già consolidati in altri settori dell'informatica: in particolare, il trattamento dei dati e delle relazioni ha molto in comune, ma anche grandi punti di distacco, con il modello relazionale. Non è raro che esperti di diverso calibro affermino che il paradigma object oriented rappresenta una totale rottura con il passato, e che conoscenze precedenti possono essere controproducenti nella transizione ai nuovi schemi di pensiero: mentre ciò può essere vero per alcune tecniche di programmazione a basso livello, spesso le conoscenze acquisite in campi più astratti, come la teoria relazionale, non solo non sono di impaccio, ma costituiscono un utile strumento per la comprensione e la progettazione dei sistemi.

Reader's Map
Molti visitatori che hanno letto
questo articolo hanno letto anche:

Bibliografia
[1] W. Kent: "Data and Reality: Basic Assumptions in Data Processing Reconsidered", North-Holland, 1978.
[2] Christian Tanzer: "Remarks on object oriented modeling of associations", Journal of Object-Oriented Programming, Febbraio 1995.
[3] Carlo Pescio: "C++ Manuale di Stile", Edizioni Infomedia, 1995.
[4] E. F. Codd: "A Relational Model of Data for Large Shared Data Banks", Communication of ACM, Giugno 1970.
[5] Tsichritzis, Nierstrasz: "Fitting Round Objects into Square Databases", Technical Report, Centre Universitaire d'Informatique, Geneva, Switzerland, 1988.
[6] Stefano Ceri: "Progettazione di basi di dati", Cooperativa Libraria Universitaria del Politecnico.
[7] Al Stevens: "C++ Database Development", MIS Press, 1993.
[8] Object Management Group: The Common Object Service Specification, Luglio 1994.

Biografia
Carlo Pescio (pescio@acm.org) svolge attivitÓ di consulenza 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. ╚ incaricato della valutazione dei progetti dal Direttorato Generale della ComunitÓ Europea come Esperto nei settori di Telematica e Biomedicina. ╚ laureato in Scienze dell'Informazione ed Ŕ membro dell'ACM, dell'IEEE e della New York Academy of Sciences.