Sei sulla pagina 1di 29

PROGRAMMAZIONE 2

Appunti del Corso


(L. Lesmo febbraio 2008)

PARTE SECONDA Gli alberi

INDICE

Indice dei Capitoli 2.


2.1 2.2 2.3 2.4 2.5 2.6 2.7 2.8 2.8.bis

Gli alberi

. Strutture Dati Astratte Alberi Generali ...... Memorizzazione di alberi generali: array di riferimenti ..... Alberi Binari ...... Un esempio: altezza di un nodo .......... Alberi generali rappresentati mediante alberi binari ... Lettura di un albero da file ..... Ancora sullInput/Output e sulle eccezioni .... Ancora sullInput/Output e sulle eccezioni (ripresa) ....

3 3 4 5 11 13 15 18 19 26

Indice dei Richiami di Java


6: Codifica dei caratteri .. 21

Indice delle Procedure e dei Programmi


Proc. 1 Lettura in preordine di un albero memorizzato come array di riferimenti . Proc. 2 Lettura in preordine di un albero binario .. Proc. 3 Valutazione di unespressione aritmetica rappresentata come albero binario Proc. 4 Altezza di un nodo in un albero binario (versione 1) . Proc. 5 Altezza di un nodo in un albero binario (versione 2) . Proc. 6 Altezza di un nodo in un albero generale memorizzato come array di puntatori Proc. 7 Altezza di un nodo in un albero generale memorizzato come albero binario Proc. 8 Lettura di un albero binario da un file . Proc. 9 Ricerca di un nodo in un albero binario .. 8 11 13 14 14 14 17 20 21

Indice delle tabelle


Tab.1 - Esempi di codifiche numeriche.. . 25 Tab.2 - Alcuni caratteri ASCII ... . 26

Indice delle figure


Fig.1 Rappresentazione grafica di un albero .. . 4 Fig.2 Rappresentazione di un albero mediante array di riferimenti . 6 Fig.3 Situazione iniziale dello stack allinizio dellesecuzione di preorder 8 Fig.4 Lo stack dopo linvio del primo messaggio nodePreorder 9 Fig.5 Lo stack dopo il secondo messaggio (ricorsivo) nodePreorder .. 9 Fig.6 Lo stack dopo la fine dellelaborazione del nodo n4 . 10 Fig.7 Lo stack: inizio dellelaborazione del nodo n3 .. 10 Fig.8 Lo stack: inizio dellelaborazione del nodo n1 10 Fig.9 Rappres. di espressioni aritmetiche come alberi binari (parziale) .... 12 Fig.10 Rappres. di espressioni aritmetiche come alberi binari (completa) .... 12 Fig.11 Sequenza dei riferimenti ai figli realizzata come lista di riferimenti .. 15 Fig.12 Un albero generale rappresentato come albero binario (a parte la radice) 16 Fig.13 Un albero generale rappresentato come albero binario .... 17 Fig.14 Unistanza della classe File ... 21 Fig.15 Rappresentazione tramite bit 22 Fig.16 Rappresentazione con 8 bit (un byte) per carattere ..... 23 Fig.17 Posizione dei bit in una codifica binaria ..... 24 Fig.18 Posizione dei bit in una codifica decimale ..... 24 Fig.19 Codifica effettiva del contenuto di un file ASCII (.txt) ....... 26

2.

Gli alberi

2.1 Strutture Dati Astratte


Il concetto di Struttura Dati Astratta tra quelli fondamentali dellinformatica. Lidea di definire in modo astratto la struttura, e cio senza specificare come essa realizzata nellimplementazione. Sulla base di tale definizione sar poi possibile individuare alcune operazioni che hanno senso sulla struttura astratta1. Date queste operazioni (e quindi il loro effetto previsto sulla struttura, cio quella che viene detta la loro semantica, cio il loro significato) sar poi necessario inventare gli algoritmi che ne permettano la realizzazione. A questo punto si pu cominciare a pensare alla realizzazione pratica: si dovr scegliere un particolare linguaggio di programmazione (C, Java, Pascal, ecc.); poi, si dovr scegliere tra le particolari strutture dati concrete che il linguaggio mette a disposizione quella che sembra pi adatta per la realizzazione dei vari algoritmi; infine, si potr passare a scrivere i programmi che realizzano le operazioni, e cio a tradurre gli algoritmi nel linguaggio scelto. E abbastanza ovvio che questa una visione ipotetica: nella maggior parte dei casi, la scelta del linguaggio non permessa: voi dovrete comunque realizzare gli algoritmi in Java, e, nella maggior parte degli ambienti industriali il linguaggio in cui realizzare il programma fissato a priori. Ciononostante, la successione non cambia; semplicemente, si salta un passaggio: 1. Definisci le operazioni sulla struttura 2. Inventa gli algoritmi per effettuare (in modo astratto) tali operazioni 3. [Scegli il linguaggio: passo spesso saltato] 4. Scegli la struttura dati concreta 5. Scrivi i programmi Poich quanto detto sembra un po generico, si pu osservare che, nei capitoli precedenti, abbiamo gi utilizzato delle Strutture Dati di questo tipo. Nel primo capitolo, la Struttura Dati Astratta Sequenza stata prima realizzata con i vettori e poi con le liste. Per le Sequenze, abbiamo visto che hanno senso operazioni come - Ricerca di un elemento - Cancellazione di un elemento - Inserimento di un nuovo elemento - Modifica di un elemento - Ordinamento della sequenza In realt, per motivi espositivi, io mi sono concentrato su ununica operazione: lordinamento (che anche la pi difficile). Una volta fatto questo (Passo 1), abbiamo cominciato a pensare agli algoritmi possibili per realizzare loperazione ed abbiamo inventato (si fa per dire) il Selection Sort (Passo 2). Abbiamo poi ovviamente saltato il Passo 3 (il linguaggio , per forza, Java) e siamo passati al passo 4. Qui, abbiamo scelto i vettori come struttura dati concreta (array in Java) e, finalmente, abbiamo potuto scrivere il metodo (Passo 5). A questo punto, sono tornato indietro, e cio al passo 2, cercando di vedere se non era possibile trovare dei modi pi efficienti per effettuare la stessa operazione. E importante notare che non siamo tornati al passo 1! Loperazione scelta al passo 1 (lordinamento) esattamente la stessa! Ma il risultato del Passo 2 cambiato: abbiamo trovato il Merge Sort, e abbiamo visto che migliore del Selection Sort (in termini di tempo di esecuzione). Avendo rifatto il Passo 2, dobbiamo rifare anche i passi successivi: il Passo 3 (uso di Java) sempre lo stesso. Il Passo 4 (scelta della struttura dati concreta) ha portato casualmente alla scelta della stessa struttura dati, e cio i vettori (in realt, per noi, la scelta era obbligata, visto che le liste non le conoscevamo ancora). E infine, il passo 5, cio la scrittura del programma Merge Sort. Notate che lo stesso algoritmo, con la stessa struttura dati concreta, pu comunque portare a programmi diversi, come abbiamo visto con le varie realizzazioni di Merge Sort sui vettori2.
1

Di norma per Struttura Dati Astratta si intende lorganizzazione dei dati unitamente alle operazioni fondamentali definite sui dati stessi. 2 Questo non del tutto preciso. In realt, i vari programmi di Merge Sort sono realizzazioni di specializzazioni diverse dellalgoritmo fondamentale: ad un certo livello di dettaglio (astrazione) sono lo stesso algoritmo, ad un altro livello sono algoritmi differenti. 3

2.2 Alberi generali


Quello che si vuole fare ora di applicare la stessa metodologia (successione di passi) ad un struttura dati astratta un po pi complicata delle sequenze: gli alberi. La prima cosa da fare definire tale struttura; questo, nel caso delle sequenze, non era stato fatto: lidea di sequenza sufficientemente intuitiva. Nel caso degli alberi, invece, una definizione necessaria. Vi sono molti modi per definire gli alberi. Io user una definizione ricorsiva. Pu sembrare un po difficile, ma, visto che abbiamo gi studiato metodi ricorsivi, penso che con un po di riflessione si possa comprendere come funziona: Un albero un insieme di nodi tale che vi un nodo speciale detto radice e tutti gli altri nodi sono partizionati in modo tale che ogni elemento della partizione a sua volta un albero. Un insieme vuoto di nodi un albero

Gli insiemi che costituiscono la partizione dellalbero vengono detti sottoalberi dellalbero dato. Questa definizione induce una gerarchia: la radice dellalbero al livello pi alto della gerarchia; le radici dei suoi sottoalberi sono al livello gerarchico immediatamente inferiore, e cos via, ricorsivamente. Possiamo cio definire il livello di un nodo nel modo seguente: La radice dellalbero a livello 0. Se un nodo N la radice di un sottoalbero di un albero la cui radice ha livello L, allora N ha livello L+1.

Es: A1 = {n1, n2, n3, n4, n5, n6, n7, n8} A1 un albero se uno dei suoi nodi speciale (la radice) e gli altri sono partizionati in sottoalberi. Ad esempio: A1 = <n2, {{n4}, {n1, n3, n7}, {n5}, {n6}, {n8}}> Qui abbiamo che la radice di A1 n2, e che A1 ha cinque sottoalberi. Naturalmente, necessario verificare che essi siano davvero, a loro volta, degli alberi. Per gli insiemi di un unico elemento ({n4}, {n5}, {n6} e {n8}) tale elemento sar necessariamente la radice. Rimane linsieme vuoto e lunica partizione possibile di un insieme vuoto contiene linsieme vuoto; di conseguenza abbiamo (ad es per {n4}): <n4, { {} }> e possiamo verificare che questo un albero perch lunico sottoalbero (vuoto) soddisfa la seconda parte della definizione (fine della ricorsione, come nei metodi ricorsivi). La stessa cosa, ovviamente, vale per {n5}, {n6} e {n8}. Per {n1, n3, n7} dobbiamo ripetere (ricorsivamente) quanto gi fatto per A1. Se n3 il nodo speciale (la radice) e gli altri due nodi sono partizionati in sottoinsiemi separati, abbiamo: <n3, {{n1}, {n7}}> Che necessariamente un albero, per quanto abbiamo appena visto per gli insiemi di un elemento. Una rappresentazione grafica, pi leggibile, la seguente: n2

n4

n3

n5

n6

n8

n1

n7

Fig.1 Rappresentazione grafica di un albero 4

Si deve per osservare che mentre la prima forma in cui lalbero stato presentato (quella ricavata dalla definizione) un albero, la seconda solo un disegno di un albero, anche se utile per noi. Rimane comunque il fatto, fondamentale, che ambedue le forme si riferiscono allo stesso albero. Prima di passare alle operazioni sugli alberi, necessario introdurre qualche termine di uso comune nel trattamento degli alberi (abbiamo gi visto la defnizione di radice e di sottoalbero). - Si dice che un nodo ny figlio di un nodo nx se ny la radice di un sottoalbero di nx. In tal caso, diciamo anche che esiste un arco tra nx e ny (nel nostro esempio, n7 figlio di n3, e larco evidenziato nella rappresentazione grafica dellalbero). - Si dice che un nodo nx genitore di un nodo ny, se ny figlio di nx. - Si dice cammino una sequenza di nodi <nx1, nx2, , nxk> tale che per ogni coppia < nxi, nxi+1 >, con i=1,2,..,k-1, si ha che nxi+1 figlio di nxi (nel nostro esempio, < n2, n3, n1> un cammino, come potete facilmente verificare). Notate che anche un solo arco pu costituire un cammino. Si dice che un nodo ny discendente di un nodo nx, se esiste un cammino < nx, nx1, nx2, , ny > Si dice che un nodo nx antenato di ny, se ny discendente di nx. Si dice che un nodo nx una foglia se nx non ha figli.

Possiamo ora passare alle operazioni. In tutti i casi precedenti ci siamo riferiti ad una nozione astratta di albero (sia in modo formale, come nel primo caso, sia in modo grafico, come nel secondo). Si , cio, parlato di una Struttura Dati Astratta (come era stato richiesto allinizio). Abbiamo quindi iniziato il Passo 1. Per completarlo, dobbiamo pensare a ragionevoli operazioni che si possono fare sugli alberi. Ad esempio, dato un nodo, elencare tutti i suoi figli. O, dato un nodo, elencare tutti i suoi discendenti (se il nodo dato la radice, avremo un elenco di tutti i nodi dellalbero). Oppure, si pu pensare di modificare lalbero: si possono aggiungere o eliminare dei nodi, e cos via. Poich le operazioni possibili sono tante, anzich elencarle tutte ora, possiamo immaginare di avere un elenco (magari parziale, come quello che ho fatto sopra) e cominciare a ragionare verso le strutture dati concrete. Per limplementazione (passo 4), come si era visto nel caso delle sequenze, possono esserci diverse alternative. In quel caso, la struttura dati astratta Sequenza era stata realizzata con due classi diverse (in alternativa): i Vettori e le Liste. La stessa cosa dobbiamo ora fare per gli alberi, ragionando sugli alberi, anzich sulle sequenze.

2.3 Memorizzazione di alberi generali: array di riferimenti


La prima cosa che si pu osservare che le frecce usate nella rappresentazione grafica dellalbero assomigliano tanto a dei riferimenti. Una prima soluzione, che salta subito agli occhi, dunque la seguente: Realizziamo i nodi come oggetti di una classe Nodo (come gli elementi di una lista per implementare una sequenza) e inseriamo, allinterno di essi, dei riferimenti ai figli. Sorge per un problema: visto che i riferimenti sono pi duno (ogni nodo pu avere tanti figli), non possiamo usare un riferimento semplice, come per le sequenze. Dobbiamo perci usare (dentro la classe Nodo, per la parte relativa ai riferimenti), una struttura dati complessa. Come abbiamo gi visto, poich gli elementi di questa struttura dati sono tutti dello stesso tipo (e cio riferimenti a nodi), possiamo considerarli come una sequenza di riferimenti, e, come abbiamo visto, possiamo realizzarli o tramite un vettore (di riferimenti) o tramite una lista (in cui la parte dato costituita da riferimenti). Vediamo cosa succede adottando la prima soluzione. Converrebbe ora tornare a guardare la sezione relativa ai vantaggi e svantaggi di vettori e liste per la realizzazione delle sequenze. Lo svantaggio principale dei vettori che, una volta dichiarata la dimensione del vettore, essa non pi modificabile. Come salta subito allocchio, questo un problema: nella definizione generale di albero non posto alcun limite sul numero dei figli. Se per noi dichiariamo, ad esempio, che il nostro vettore di riferimenti, nel nodo n3 (ce ne uno, ovviamente, per ciascun nodo) di 2 elementi, allora non sar possibile in seguito aggiungere un nuovo figlio a n3. Ciononostante, visto che questa la soluzione pi intuitiva (e in alcuni casi reali anche possibile che il numero 5

di figli abbia un range limitato), vediamo come questa si pu realizzare. Innanzitutto, la dichiarazione Java della classe NodoGen private class NodoGen {public int dato; public NodoGen[] daughters;} Dovrebbe saltare allocchio che ho definito questa classe come privata. Per comprenderne il motivo, possiamo osservare che un nodo ha, in un albero, pressappoco lo steso ruolo che un elemento ha in una lista. Si tratta cio di una componente interna dellalbero stesso. In altre parole, esso corrisponde a quello che, nelle liste, avevamo chiamato ListElem. Di conseguenza, come avviene per ListElem nelle liste, NodoGen non sar visibile allesterno (ad esempio, dal main). Ma allora, ci serve qualcosaltro che invece consenta laccesso allalbero. La classe visibile (cio public) la chiamer AlberoGen, e la definizione sar la seguente: public class AlberoGen { private nodoGen radice; private class NodoGen {public int dato; public NodoGen[] daughters;} public alberoGen () {radice = null;} public metodo1 () { } } // variabile di istanza // classe privata interna // metodo costruttore // altri metodi

Con una definizione di questo tipo, la struttura di base del tutto simile a quella delle liste: il ruolo giocato nelle liste dalla variabile di istanza first assunto qui dalla variabile radice. Poi, come dal first era possibile accedere agli elementi della lista, nello stesso modo dalla radice sar possibile accedere (con le modalit che vedremo) ai nodi dellalbero. Usando le convenzioni grafiche gi viste per le liste, lalbero dellesempio precedente apparirebbe come nella figura 2 (ricordiamo che ogni rettangolo che compare nella figura rappresenta un nodo, cio unistanza di NodoGen, e si riferisce a un p di memoria riservata nello heap). Notate che lalbero non cos ben allineato per livelli, come nella figura precedente. Questo lho fatto per una questione di spazio sulla pagina, ma , naturalmente, irrilevante: il livello specificato dalle relazioni tra i nodi (e quindi, nellimplementazione, dai riferimenti) e non da come fatto il disegno. Ricordate anche che le istanze che realizzano i nodi dellalbero sono memorizzate nello Heap, e quindi la loro posizione fisica del tutto casuale e, di conseguenza, indipendente dalla struttura dellalbero.

dato radice

daughters

n2
dato dg. dato dg. dato daughters

n8
dato dg.

n4

n3

n5
dato dg.

dato

dg.

n6

dato dg.

n1

n7

Fig.2 Rappresentazione di un albero mediante array di riferimenti. Le barre nere verticali rappresentano vettori vuoti (cio di lunghezza zero). Ovviamente dg. sta per daughters (non ci stava in figura). Ho evidenziato (in grigio) lunica istanza di AlberoGen. Tutte le altre sono istanze di NodoGen. 6

Possiamo ora passare a vedere qualche operazione che si pu effettuare sugli alberi. La cosa pi naturale la lettura dellalbero. Notate che il termine lettura si usa di norma per indicare una elencazione dei nodi, e non per riferirsi allintroduzione dellalbero (input) da tastiera o da file. Se si osserva lalbero disegnato in precedenza, si pu notare che vi sono due metodi principali per percorrere lalbero: o si scende per livelli, o si scende fino in fondo su uno dei cammini (dalla radice alla foglia pi a sinistra) e poi si risale e si ridiscende per prendere la foglia successiva, e cos via (questo pu, ovviamente, essere fatto partendo dalla foglia pi a destra, non cambia molto). Abbiamo cos due metodi principali per percorrere un albero: Percorrimento in ampiezza In questo caso (lettura per livelli), si otterr: - Livello 0 (radice): n2 - Livello 1: n4, n3, n5, n6, n8 - Livello 2: n1, n7 Per cui, la sequenza completa : n2, n4, n3, n5, n6, n8, n1, n7 Si noti, comunque, che i figli di un nodo non si intendono ordinati tra loro, e che quindi il loro ordine del tutto casuale (e non significativo). Percorrimento in profondit In questo caso, si otterr: - Si parte dalla radice (n2) - Si scende sul primo figlio di n2 (n4) - Poich n4 non ha figli si risale (n2) - Si scende sul figlio successivo di n2 (n3) - Si scende sul primo figlio di n3 (n1) - Poich n1 non ha figli si risale (n3) - Si scende sul figlio successivo di n3 (n7) - Poich n7 non ha figli si risale (n3) - Poich tutti i figli di n3 sono stati esaminati, si risale (n2) - Si scende sul figlio successivo di n2 (n5) - Poich n5 non ha figli si risale (n2) - Si scende sul figlio successivo di n2 (n6) - Poich n6 non ha figli si risale (n2) - Si scende sul figlio successivo di n2 (n8) - Poich n8 non ha figli si risale (n2) - Poich tutti i figli di n2 sono stati esaminati, si risale (fine) Sebbene questo metodo sembri molto pi complicato del precedente, in alcuni casi, di pi semplice implementazione, oltre ad essere pi efficiente sotto certi aspetti. Deve essere osservato che, se si vuole usare questo tipo di percorrimento per stampare i nodi di un albero, rimangono aperte alcune possibilit di scelta. Infatti, a differenza del percorrimento in ampiezza, qui si passa sullo stesso nodo pi volte. Ad esempio, si passa da n3 tre volte e da n2 ben sei volte. In teoria, la stampa del nome (o dato) del nodo pu essere effettuata una qualsiasi di queste volte, ma evidente che ci sono almeno due passaggi speciali: la prima volta che si arriva ad un nodo e lultima volta che si passa dal nodo. Ed, in effetti, questi sono i due modi principali per elencare i nodi: nel primo caso, si parla di lettura in preordine, nel secondo caso di lettura in postordine. Nel primo caso si otterr quindi il seguente elenco: n2, n4, n3, n1, n7, n5, n6, n8 Mentre con la lettura in postordine otterremo: n4, n1, n7, n3, n5, n6, n8, n2 Possiamo ora, finalmente, passare a vedere un primo metodo che opera sugli alberi. Quello che segue il metodo per la lettura, e stampa, di un albero in preordine. Il metodo utilizza ambedue le forme di ripetizione viste fino ad ora: literazione e la ricorsione. Literazione (un ciclo for) viene usata per esaminare tutti i figli di un nodo, e la ricorsione per scendere di livello. Notate che in questa implementazione, anzich introdurre un cursore (cosa che si sarebbe potuta fare, come nelle liste) ho utilizzato la stessa soluzione gi vista in sortList, e cio ho definito il metodo interno sui nodi, anzich sullintero albero. A differenza di quanto fatto in sortList, per, anzich passare il nodo come argomento del messaggio, ho seguito unaltra via: ho deciso che il messaggio va inviato al nodo (che ha quindi il 7

ruolo di ricevente). Questo per non si pu fare se il metodo associato alla classe AlberoGen, perch in tal caso solo le istanze di AlberoGen potrebbero ricevere il messaggio, e non quelle di NodoGen (sempre le istanze, visto che non si tratta di metodi statici!). La soluzione ovvia: definire il metodo nella classe NodoGen. E questo quello che ho fatto in figura, ripetendo la definizione complessiva di NodoGen solo per chiarire la posizione in cui il metodo interno nodePreorder va definito. public void preorder (); { if (radice != null) radice.nodePreorder (); } private class NodoGen { public int dato; public NodoGen [] daughters; public void nodePreorder () {System.out.println (dato); for (i = 0; i < daughters.length ; i++) daughters[i].nodePreorder (); } } Proc.1 Lettura in preordine di un albero memorizzato come array di riferimenti. Si ripete la definizione della classe NodoGen, per evidenziare che nodePreorder un metodo di tale classe, e non di AlberoGen Per comprendere bene come questo metodo funziona, e inoltre per ricordare i concetti principali dei meccanismi ricorsivi, pu essere utile simulare lesecuzione del programma su una (ideale) macchina a stack. La chiamata iniziale avverr da un metodo esterno (supponiamo sia il main) che quindi includer (tra altre) le seguenti istruzioni: public static void main (string [] args) {AlberoGen inputAlb = new AlberoGen (); inputAlb.leggiAlbero (); inputAlb.preorder (); } Supponiamo dunque che allatto dellinvio del messaggio preorder, lalbero sia gi stato letto e memorizzato nella forma che abbiamo visto sopra. Nel momento in cui viene eseguita la chiamata a preorder, un nuovo blocco viene allocato sullo stack. Questo nuovo blocco (come tutti i blocchi associati al metodo preorder) includer due elementi (solo due, perch il metodo non ha parametri, n variabili di metodo)3: - Il ricevente - Lindirizzo di ritorno Disegnando lo stack dal basso verso lalto, esso apparir quindi nel seguente modo (uso this per indicare il ricevente): IR this

IndHeap (radice)

preorder

Fig.3 Situazione iniziale dello stack allinizio dellesecuzione di preorder. In questa figura e nelle successive ho omesso il blocco del metodo che ha richiesto preorder (es. il main)

Ricordate che daughters non una variabile del metodo, bens una variabile di istanza. Il suo valore, quindi, si ottiene da this (il ricevente nello stack) 8

In cui, IndHeap(radice) lindirizzo (nello Heap) della cella in cui inizia listanza di NodoGen che contiene il riferimento al nodo n2 (la cella grigia in fig.2) e lindirizzo (nello spazio istruzioni del main) della istruzione che segue immediatamente linvio del messaggio preorder. A questo punto, inizia lesecuzione del body del metodo. Poich la radice del ricevente non NULL, viene inviato il messaggio nodePreorder alla radice stessa. Questo produce laggiunta di un nuovo blocco sullo stack. Questo blocco conterr tre celle (il ricevente, lindirizzo di ritorno e lo spazio per la variabile di metodo i che, per ora, non contiene alcun valore). La situazione attuale mostrata in fig.4.

i IR this IR this

IndHeap (n2)

nodePreorder

IndHeap (radice)

preorder

Fig.4 Lo stack dopo linvio del primo messaggio nodePreorder Ora inizia lesecuzione del metodo e viene effettuata la println. Il ricevente (this) permette di identificare la posizione del nodo nello heap e la definizione della classe permette di stabilire dove si trova nellistanza la variabile cercata (dato) e di che tipo tale variabile. println pu quindi effettuare la stampa. Poi viene inizializzata la variabile i. Questo vuol dire scrivere nello stack, nella cella associata alla variabile i, il valore 0. Poich la variabile daughters del ricevente (il nodo n2 v. stack) ha lunghezza 5, il controllo del for verificato e si esegue il body; questo richiede unicamente di inviare il messaggio nodePreorder allistanza il cui riferimento sta in daughters[0], e cio listanza che contiene il nodo n4. Nuovo messaggio, nuovo metodo, nuovo blocco sullo stack: la situazione ora quella mostrata in fig. 5.

i IR this i IR this IR this

IndHeap (n4) 0

nodePreorder

IndHeap (n2)

nodePreorder

IndHeap (radice)

preorder

Fig.5 Lo stack dopo il secondo messaggio (ricorsivo) nodePreorder

Nel nuovo blocco, il ricevente listanza relativa al nodo n4, e lindirizzo di ritorno quello dellistruzione successiva alla daughters[i].nodePreorder (); Ma non c nessuna istruzione dopo questa! In realt, tale istruzione esiste (nel programma compilato) ed quella che incrementa la variabile i. A questo punto pu essere effettuata la println del nodo n4 e si entra nel ciclo. Ma n4 non ha figli, e quindi nellistanza relativa a n4 la variabile daughters un vettore di lunghezza zero. Poich il controllo del ciclo (i < daughters.length) non soddisfatta gi allinizio (i vale 0 e daughters.length vale anchessa 0), il body del for non viene eseguito nemmeno una volta e il ciclo termina subito. Di conseguenza, termina anche lintero metodo e il blocco in cima allo stack viene rimosso (v. fig.6).

i IR this IR this

IndHeap (n2)

nodePreorder

IndHeap (radice)

preorder

Fig.6 Lo stack dopo la fine dellelaborazione del nodo n4 Ricordiamo ora che lindirizzo di rientro dal metodo () corrispondeva allincremento della variabile i. Essa assume quindi il valore 1 e il messaggio nodePreorder viene inviato al riferimento daughters[1] del ricevente (sul blocco in cima allo stack, e cio listanza di n2) cio allistanza associata al nodo n3. La situazione dello stack del tutto simile a quella di fig.5, ma ora i vale 1 e il ricevente n3 (si veda fig.7). Ora si stampa n3 e poi si entra nel ciclo. i IR this i IR this IR this

IndHeap (n3) 1

nodePreorder

IndHeap (n2)

nodePreorder

IndHeap (radice)

preorder

Fig.7 Inizio dellelaborazione del nodo n3. Questa volta, n3 ha due figli, e quindi il ciclo viene eseguito, avendo inizializzato la variabile i a 0. Ci produce linvio del messaggio nodePreorder allistanza il cui riferimento si trova in daughters[0] di n3, e cio quella che contiene i dati di n1. La figura (lultima, poi potete procedere da soli) la fig.8. i IR this i IR this i IR this IR this

IndHeap (n1) 0

nodePreorder

IndHeap (n3) 1

nodePreorder

IndHeap (n2)

nodePreorder

IndHeap (radice)

preorder

Fig.8 Inizio dellelaborazione del nodo n1. 10

E importante osservare bene la figura, perch si deve notare che ci sono ben 3 celle associate alla variabile i. Notate che i la posizione (nel ciclo) che permette di spostarsi su daughters. Intuitivamente, essa serve a ricordarsi a che punto si arrivati nello spostamento sui figli di un certo nodo. Ma questa memoria deve essere ripetuta per ogni livello, perch in ogni livello (da quello del nodo su cui stiamo lavorando fino alla radice) ci possiamo trovare in una posizione differente. Ed in effetti, nella fig. 8, noi siamo posizionati sul secondo figlio della radice (la i relativa ad n2 vale 1) e sul primo figlio di n3 (infatti, nel blocco intermedio i vale 0). E non siamo ancora posizionati su nessun figlio di n1 (n mai lo saremo, visto che n1 non ha figli). Per concludere, notate che gli indirizzi di ritorno dei due blocchi in cima allo stack coincidono. Infatti, nel metodo, si tratta sempre della stessa istruzione che stata eseguita e che ha prodotto laggiunta del blocco sullo stack (e cio daughters[i].nodePreorder ();), anche se sia i che daughters sono diverse.

2.4 Alberi binari


In un albero binario, ogni nodo ha zero, uno, o due figli. Per, gli alberi binari non sono un caso particolare degli alberi generali che abbiamo appena visto; infatti, essi non rispettano le definizioni precedenti. Il motivo che negli alberi binari vengono distinti i due possibili figli in figlio sinistro e figlio destro. In altre parole, lordine dei figli , nel caso degli alberi binari, di importanza fondamentale! Scambiare il figlio sinistro e il figlio destro in un albero binario produce due alberi diversi. Al contrario, scambiare il primo e il secondo figlio di un nodo in un albero generale non ha alcun effetto: lalbero risultante lo stesso albero dellalbero originale (lordine dei figli non ha importanza: si vedano le definizioni). Non solo, ma anche quando ha un unico figlio, diverso il caso in cui tale figlio sia figlio sinistro dal caso in cui esso sia figlio destro. Non c molto da dire sulle modalit di rappresentazione degli alberi binari, poich la soluzione praticamente immediata (e unica, visto che non presenta particolari inconvenienti): public class AlberoBin { private nodoBin radice; private class NodoBin {public int dato; public NodoBin left; public NodoBin right;} public alberoBin () {radice = null;} public metodo1 () { } } // variabile di istanza // classe privata interna

// metodo costruttore // altri metodi

Come si vede, la classe AlberoBin include una variabile di istanza per il dato e due distinte variabili di istanza per i riferimenti ai figli: left e right. Possiamo subito vedere il metodo di lettura in preordine di un albero binario, che non altro che una semplificazione di quello gi visto per gli alberi generali. Lunica differenza che qui, anzich avere un ciclo for, si hanno solamente due richiami ricorsivi in sequenza: il primo per il sottoalbero che ha come radice il figlio sinistro, il secondo per il sottoalbero che ha come radice il figlio destro. E chiaro che qui il ciclo non serve: si sa che i puntatori ai figli sono esattamente due (anche se uno o tutti e due potrebbero essere null)! public void binPreorder (); { if (radice != null) radice.nodoBinPreorder (); } private void nodoBinPreorder (); { System.out.print (dato+, ); if (left != null) left.nodoBinPreorder (); if (right != null) right.nodoBinPreorder (); } Proc.2 Lettura in preordine di un albero binario 11

Gli alberi binari sono molto importanti, perch, pur avendo una struttura molto semplice, permettono di memorizzare (con una tecnica un po particolare) anche alberi generali. Prima, per, di vedere tale tecnica, facciamo un esempio di uso di alberi binari. In particolare, gli alberi binari possono essere utilizzati per memorizzare delle espressioni aritmetiche. Vediamo quindi come questo pu essere realizzato, ed una procedura per valutare lespressione. Nellespressione, assumiamo che tutti gli operatori siano binari e che gli unici operatori permessi siano +, -, *, /. Nella scrittura dellespressione, possiamo inserire tutte le parentesi, che ci consentono di ricavare in modo immediato la struttura interna dellespressione. Ad esempio, lespressione: 3+7-(18*(10/5)) Corrisponde alla versione con parentesi: ((3+7) (18 * (10/5))) In questa espressione compare un operatore principale (che quello relativo alle parentesi pi esterne, nel nostro caso -) che pu avere come argomenti o dei valori interi o delle espressioni aritmetiche. Nel nostro caso, ambedue gli argomenti delloperatore principale sono a loro volta delle espressioni: (3+7) e (18*(10/5)). Ciascun operatore pu essere rappresentato come un nodo di un albero binario, i cui argomenti sono i sottoalberi sinistro e destro. Ad esempio, nel nostro caso: -

(3 + 7)

(18 * (10 / 5))

Fig.9 Rappresentazione di espressioni aritmetiche come alberi binari (parziale)

Naturalmente, poich la definizione ricorsiva, anche i sottoalberi verranno rappresentati nello stesso modo, ottenendo cos alla fine lalbero di fig.10.

+ *

18

10

Fig.10 Rappresentazione di espressioni aritmetiche come alberi binari (completa)

Si osservi che le parentesi tonde non compaiono pi: esse infatti servono solo per rendere espliciti, nella rappresentazione lineare standard dellespressione i livelli di annidamento delle sottoespressioni; ma questo, nellalbero, gi dato esplicitamente nella struttura dellalbero stesso. 12

Possiamo ora pensare alla struttura dei nodi dellalbero. Innanzitutto, poich le foglie dellalbero hanno associati dei dati interi, mentre i nodi intermedi si riferiscono a dati di tipo char, non sar possibile memorizzare tali informazioni nella stessa variabile di istanza. Di conseguenza, dovremo avere due variabili dati: la prima sar di tipo int e verr usata quando listanza contiene i dati di una foglia. La seconda sar di tipo char e verr usata, nei nodi intermedi, per memorizzare loperazione. Ora per sorge un problema: come fare a sapere di che tipo di nodo si tratta? E, ovviamente, possibile verificare sempre se un nodo non ha figli (nel qual caso dovr necessariamente contenere un valore, e non un operatore), ma pi semplice utilizzare lo stesso campo usato per loperazione come indicatore di tipo di nodo; ad esempio, possiamo porre in tale campo il valore v, se il nodo contiene un valore intero (e cio si tratta di una foglia). Ora possiamo dare la definizione della classe: public class AlberoAritm { private nodoAritm radice; private class NodoAritm {public int dato; public char oper; public NodoAritm left; public NodoAritm right;} public alberoAritm () {radice = null;} public metodo1 () { } } // variabile di istanza // classe privata interna

// metodo costruttore // altri metodi

Ora, scrivere il metodo per la valutazione dellespressione proprio facile: per ogni nodo (partendo dalla radice), si verifica il tipo; se si tratta unoperazione, la si esegue; se si tratta di un valore (una foglia) si prende il valore. Ovviamente, per eseguire unoperazione si dovr conoscere il valore dei suoi operandi; ma questi si ottengono valutando lespressione associata al sottoalbero, e cio con due chiamate ricorsive (una per il primo operando, sottoalbero sinistro, una per il secondo operando, quello destro) allo stesso metodo (che chiamer aritmEval): public int aritmEval () {switch (oper) { case +: return left.aritmEval () + right.aritmEval (); case -: return left.aritmEval () - right.aritmEval (); case *: return left.aritmEval () * right.aritmEval (); case /: return left.aritmEval () / right.aritmEval (); case v: return dato; } } Proc.3 Valutazione di unespressione aritmetica rappresentata come albero binario Si osservi solo che, per i nodi intermedi, la variabile dato non utilizzata. Volendo, possibile (con semplici modifiche alla procedura) memorizzare in essa i risultati (parziali) del calcolo delle sottoespressioni.

2.5 Un esempio: altezza di un nodo


Un utile esercizio, che ci permette di confrontare le operazioni sugli alberi binari con quelle sugli alberi generali, quello del calcolo dellaltezza di un nodo. Definiamo per altezza del nodo n, la massima distanza di n da una foglia. Pi precisamente: - Se n una foglia, allora altezza(n)=0 - Se n non una foglia, allora altezza(n)=Max(altezza dei suoi figli)+1 Cominciamo a vedere come questa definizione pu essere realizzata su alberi binari (di cui si calcola laltezza della radice). La cosa pi interessante della procedura che segue, che essa ricalca esattamente la definizione data! Si osservi che, in caso di albero vuoto, il valore restituito 1, mentre se lalbero costituito dalla sola radice, si restituisce 0 (la radice una foglia).

13

// il metodo che segue nella classe AlberoBin public int height () { if (radice == null) { return -1; } else { return radice.nodeHeight ();} } // il metodo che segue nella classe NodoBin public int nodeHeight () { if (left == null) { if (right == null) {return 0;} else {return 1+ right.nodeHeight (); } } else // left non null { if (right == null) {return 1+ left.nodeHeight (); } else { return 1 + Math.max (left.nodeHeight(), right.nodeHeight() ) ; } } } Proc.4 Altezza di un nodo in un albero binario (versione 1) Lunica differenza, rispetto alla definizione, che, per trovare il massimo dellaltezza dei figli, si devono trattare separatamente i casi in cui uno o ambedue i figli sono null (cio, mancano)4. Unalternativa, un po pi elegante, quella nel riquadro Proc.5. Si noti, per, che in questa versione necessario passare il nodo come argomento, per evitare di inviare un messaggio a null (cosa che provocherebbe una NullPointerException). public int height2 () { nodeHeight2 (radice); } private int nodeHeight2 (nodoBin nodo) { if (nodo == null) { return 1; } else { return 1 + Math.max (nodeHeight2 (left), nodeHeight2 (right)) ; } } } Proc.5 Altezza di un nodo in un albero binario (versione 2) La differenza, rispetto alla versione precedente, che qui non si termina la ricorsione sulle foglie, ma si scende di un livello in pi, andando a considerare i figli null (cio assenti). La cosa interessante che questo si applica sia alle foglie (in cui ambedue i figli sono null) sia ai nodi con un solo figlio (destro o sinistro). Nel caso di una foglia, noi vogliamo che la sua altezza sia 0, per cui, visto che la definizione richiede che laltezza di un nodo sia pari allaltezza massima dei suoi figli incrementata di 1, sar necessario che laltezza che si ottiene da un riferimento null sia 1. In questo modo, per le foglie, si ha che laltezza di ambedue i figli (null) 1, il massimo quindi 1, e il massimo incrementato di 1 0 (come desiderato). Nel caso in cui uno solo dei due figli sia null, il valore dellaltezza determinato dallaltro figlio, per cui il valore 1 non ha alcun effetto (laltezza di un qualunque nodo maggiore di 1). Per calcolare laltezza di un nodo in un albero generale, il procedimento sempre lo stesso, ma utilizzando un ciclo (for) per spostarsi sulla sequenza dei figli di un nodo.
4

Il metodo max un metodo statico int associato alla classe Math. E quello che deve essere usato per trovare il massimo tra due numeri. 14

public int heightGen () { if (radice == null) {return 1;} else {return radice.nodeHeightGen (); } } private int nodeHeightGen () { int tempHeight = 0; for (int i=0; i<daughters.length; i++) tempHeight = Math.max (tempHeight, 1+ daughters[i].nodeHeightGen () ) ; return tempheight; }

Proc.6 Altezza di un nodo in un albero rappresentato tramite array di riferimenti Penso sia utile confrontare questa versione con lultima versione vista per gli alberi binari. La differenza principale qui, come ovvio, che al posto della semplice operazione return 1 + Math.max (nodeHeight2 (left), nodeHeight2 (right)) ; necessaria lintroduzione del ciclo for. Per rendersi conto meglio dellanalogia, osservate che loperazione riportata qui sopra equivalente a: tempheight = Math.max (tempheight, 1+ nodeHeight2 (left)); tempheight = Math.max (tempheight, 1+ nodeHeight2 (right)); return tempheight Il che praticamente identico allo sviluppo del for del metodo per alberi generali, nel caso in cui si eseguano solo due iterazioni.

2.6 Alberi generali rappresentati mediante alberi binari


Dobbiamo ora ritornare al paragrafo 2.3, in cui abbiamo scelto di rappresentare gli alberi generali come array di riferimenti. Allora, si era sottolineato come questa soluzione soffrisse di tutti i problemi legati alla rappresentazione di sequenze tramite vettori: in particolare, limpossibilit di aggiungere dei figli. Ora, il caso di chiedersi se, anzich un vettore di riferimenti, non sia possibile usare una lista di riferimenti (esattamente come avevamo fatto nel caso delle sequenze di interi, quando si trattava il problema dellordinamento). Naturalmente, questa soluzione possibile (i vettori possono essere sempre sostituiti da liste). Il nostro albero di esempio (vedi le figure 1 e 2) risulta, con le liste di riferimenti, rappresentato come si vede nella figura che segue. n2

n4

n3

n5

n6

n8

n1

n7

Fig. 11 Sequenza dei riferimenti ai figli realizzata come lista di riferimenti

15

Come si vede dalla figura, ci sono due tipi di riferimenti: quelli che puntano a dei nodi (con letichetta) e quelli che puntano a coppie di riferimenti. Questo naturale: i primi sono i nostri vecchi riferimenti dellalbero, i secondi sono dei riferimenti che ci servono a rappresentare la sequenza di (riferimenti ai) figli come lista. Questo metodo di rappresentazione pu essere definito in Java nel modo seguente: public class AlberoGen2 { private nodoGen2 radice; private class NodoGen2 {public int dato; public DoppioRif firstDaughter;} private class DoppioRif {public NodoGen2 daughter; public DoppioRif nextSister;} public alberoGen () {radice = null;} public metodo1 () { } } // variabile di istanza // classe privata interna // classe privata interna // metodo costruttore // altri metodi

Si pu osservare che la classe DoppioRif ha come variabili di istanza una coppia di riferimenti (anche se di tipo diverso). Il primo di essi (istanza della classe NodoGen2) in realt un dato (come gli interi del nostro esempio di sort), mentre il secondo il vero puntatore al prossimo elemento. Notate infine che il riferimento allinizio della lista (dei figli) non memorizzato in una variabile del main o della procedura, ma dentro le istanze (e cio nello Heap); in particolare, nelle istanze della classe NodoGen2. Sebbene questa rappresentazione risolva i problemi visti per i vettori (ci sono solo tanti riferimenti quanti servono e non ce n un numero massimo), forse si pu fare meglio! Se guardate bene la figura, vedete che, per ogni nodo della classe DoppioRif c un corrispondente nodo della classe NodoGen2, e viceversa. In altre parole, c un accoppiamento rigido (una relazione 1 a 1) tra nodi DoppioRif e nodi NodoGen2. E inoltre, per arrivare al primo figlio di un nodo, bisogna fare un doppio passaggio: prima si deve passare per il nodo coppia di riferimenti e poi, da esso, si pu arrivare al vero nodo che contiene i dati. Perch, allora, non fondere in un unico nodo ogni DoppioRif con il NodoGen2 ad esso associato? Se facciamo questa operazione, otteniamo la seguente rappresentazione: n2 n4 n3 n5 n6 n8

n1

n7

Fig. 12 Un albero generale rappresentato come albero binario (a parte la radice) Beh, questa realizzazione sembra molto pi semplice della precedente. Qui abbiamo che ogni nodo ha due riferimenti; il primo di essi punta al primo figlio (se c), mentre il secondo punta al fratello successivo (se c). Lunico problema quello della radice, che ha un solo riferimento; questo dipende dal fatto che la radice non pu avere un fratello successivo. Ma noi possiamo lo stesso mettere nel record questo puntatore (per avere un nodo fatto come tutti gli altri), anche se esso sar sempre null. A questo punto, la classe Java per questa rappresentazione pu essere come segue: public class AlberoGen3 { private nodoGen3 radice; private class NodoGen3 {public int dato; public NodoGen3 firstDaughter; public NodoGen3 nextSister;} public AlberoGen3 () {radice = null;} public metodo1 () { } } 16 // variabile di istanza // classe privata interna

// metodo costruttore // altri metodi

Ma questa classe assomiglia (anzi esattamente uguale, a parte i nomi delle istanze) a quella che avevamo visto per gli alberi binari! Quindi, quello che abbiamo ottenuto un albero binario! Ma a questo siamo arrivati per rappresentare un albero generale. Di conseguenza, quella che abbiamo trovato una rappresentazione di alberi generali fatta mediante alberi binari. Ripeto, perch importante: la struttura dati astratta lalbero generale, mentre limplementazione (struttura dati concreta) quella di un albero binario. Per comprendere meglio questa differenza, riprendiamo il problema di determinare laltezza di un nodo. Ridisegniamo per, prima, lalbero binario di Fig.12 nel nostro solito formato leggibile. n2 n4 n3 n1 n7 n5 n6 n8 Fig. 13 Un albero generale rappresentato come albero binario Ora, qual laltezza della radice di questalbero? Facile! Guardando la figura, si vede subito che laltezza 5! Facile, ma sbagliato. Se questo albero solo unimplementazione diversa del nostro vecchio albero delle figure precedenti, allora laltezza che vogliamo quella della radice di quellalbero, e cio 2. Il motivo dellerrore semplice: muoversi verso il sottoalbero destro non deve far aumentare laltezza! Infatti, ci stiamo solo spostando tra fratelli, che hanno tutti la stessa altezza. Proviamo allora a scrivere una procedura che ci permetta di calcolare laltezza giusta (ovviamente, possiamo applicare il metodo che calcola laltezza di un nodo negli alberi binari sullalbero dellesempio, ma in questo caso otterremo, appunto, il risultato sbagliato, cio 5). Per ottenere laltezza di un albero generale rappresentato come albero binario, scriviamo, come al solito, un metodo iterativo in ampiezza e ricorsivo in profondit. Naturalmente, in questo caso, literazione in ampiezza comporta non un ciclo su un vettore, ma un ciclo su una lista. E quindi il ciclo terminer quando si sar raggiunta la fine della lista (nextdaughter.nextSister=null). public int height4 () { return nodeHeight4 (radice); } private int nodeHeight4 (NodoBin nodo) {if (nodo == null) {return -1;} else {int tempHeight = -1; NodoBin nextDaughter = nodo.firstDaughter; while (nextDaughter != null) { tempheight = Math.max (tempHeight, nodeHeight4 (nextDaughter)); nextDaughter = nextDaughter.nextSister; } return tempHeight + 1; } } Proc.7 Altezza di un nodo di un albero generale rappresentato come albero binario 17

Come si pu vedere, questo metodo corrisponde esattamente alla Proc.6 (altezza di un albero rappresentato come array di riferimenti). La differenza che pu essere difficile da comprendere quella relativa allinizializzazione del ciclo while. Nel caso di Proc. 6, era sufficiente porre i=0 (che vuol dire posizionarsi sul primo figlio); qui, per posizionarsi sul primo figlio, necessario scendere sul sottoalbero sinistro; infatti nextDaughter, che ha la funzione di variabile di ciclo, viene inizializzata a nodo.firstDaughter.

2.7 Lettura di un albero da file


In questultimo paragrafo riporto un programma completo per leggere un albero binario da un file. Ricordo che sto parlando di un file solo perch i dati, se memorizzati in un file, saranno disponibili per diverse prove (i programmi raramente funzionano al primo colpo). Lo stesso input pu essere anche dato, volendo, da tastiera; si riveda la discussione sui files nel primo capitolo di queste dispense. Il primo problema da affrontare relativo al modo in cui dare lalbero in input. Ovviamente, non possiamo usare riferimenti: i riferimenti non si possono leggere n stampare, visto che sono oggetti interni (indirizzi sullo heap). Ma tutte le rappresentazioni che abbiamo usato coinvolgono dei riferimenti! Lunica alternativa quella di ritornare alle nostre definizioni della struttura dati astratta albero. In particolare, possiamo riferirci al concetto di arco, definito come coppia di nodi. Lidea quindi di indicare nel file di input lelenco degli archi dellalbero, come coppie <genitore, figlio>. Per una questione di semplicit, assumer inoltre che lalbero venga costruito, un arco alla volta, partendo dalla radice. Questo vuol dire che, ogni volta che viene letto un arco, verr aggiunto un nuovo nodo, figlio di un nodo gi presente. In altre parole, non deve essere possibile introdurre un nodo intermedio nellalbero che si sta costruendo, ma vengono aggiunte solo foglie (che magari non saranno foglie alla fine, poich ad esse potranno essere aggiunti, in seguito, dei figli). Questo perch laggiunta di una foglia unoperazione molto semplice, mentre laggiunta di un nodo intermedio un po pi complicata. Questa strategia abbastanza naturale, ma ha tre conseguenze: 1. Abbiamo il problema di introdurre la radice. Infatti, non c nessun arco in cui la radice compare come secondo elemento (figlio). 2. Ogni volta che si introduce un arco, il genitore (primo elemento della coppia) deve gi essere nellalbero. 3. Quando si introduce un arco si deve specificare se esso si riferisce ad un figlio sinistro o ad un figlio destro. Consideriamo prima il problema n.3: per distinguere i figli sinistri dai figli destri, possiamo introdurre, allinizio di ogni linea del file, un carattere che pu essere L (left: figlio sinistro) o R (right: figlio destro). Passiamo ora al problema n.1: poich lalbero deve crescere dallalto verso il basso, il primo nodo da introdurre deve essere la radice. Poich la radice non ha un genitore (non quindi n figlio sinistro n figlio destro), mettiamo un asterisco allinizio della linea (anzich L o R). Infine, il problema n.2: il metodo dovr fare in modo che, se si introduce un nodo non esistente come genitore, si abbia una segnalazione di errore. A questo punto, lalgoritmo di lettura abbastanza semplice: Per ogni arco: - se il primo elemento *, allora si tratta della radice altrimenti, cerca nellalbero costruito finora il primo elemento (il genitore) e inserisci il secondo elemento come suo figlio (sinistro o destro a seconda del primo carattere) Possiamo ora vedere il metodo. C per ancora un dubbio: cosa succede se introduciamo due volte lo stesso valore? Questo un problema interessante che richiede due parole di spiegazione (il problema non dipende dal fatto che io abbia scelto un intero, con qualunque tipo sarebbe stato lo stesso)5. Spesso si tende a confondere un nodo con la sua etichetta (il dato che esso contiene). Ma le due cose sono completamente diverse! Nella
5

Vi suggerisco di leggere attentamente le poche righe che seguono. Il problema si pone per strutture dati anche pi complesse degli alberi e, in un certo senso, anche per sistemi applicativi quali le basi di dati. 18

definizione di albero si dice che un albero un insieme di nodi, non di etichette di nodi (o nomi di nodi). Poich si parla di un insieme, i nodi devono essere diversi: in un insieme non ci sono elementi uguali. Ma nulla esclude che due elementi distinti abbiano etichette (o in generale, dati) uguali. E chiaro che un insieme di persone pu anche includere due persone (diverse) con lo stesso nome. A questo punto, ci si pu chiedere: ma allora come facciamo a distinguere due elementi che hanno gli stessi dati? Da un punto di vista applicativo, questo un problema serio (non per niente sono stati inventati i codici fiscali), che non il caso di approfondire qui. Ma nel nostro piccolo (la lettura dellalbero) si possono fare due osservazioni interessanti. Due nodi con la stessa etichetta sono diversi perch sono memorizzati in due istanze diverse! E cio ci saranno celle diverse nello heap che contengono lo stesso dato. Ma il fatto che le istanze siano diverse non vuol dire che noi siamo in grado di trovare quella giusta! Supponiamo infatti di aver introdotto due coppie <a,z> e <b,z>. Fin qui, nulla di male. Ma se ora introduciamo la coppia <z,c>, sotto quale dei due z dovr essere introdotto c? Non c nulla da fare: non abbiamo modo per distinguerli! Ci manca un identificatore univoco (appunto, un codice fiscale dei nostri nodi)! Io non mi preoccuper di questo problema nel metodo che segue. Un modo possibile per risolverlo quello di imporre che non possano esserci due nodi con la stessa etichetta (cosicch letichetta lidentificatore univoco): questo controllo non fatto nel programma, ma non difficile aggiungerlo. Di conseguenza, quello che succeder nel programma nella situazione descritta sopra (inserimento di <a,z>, <b,z> e <z,c>) che c verr inserito come figlio sotto il primo z che si trova. Poich la procedura di ricerca del genitore sempre la stessa, ogni figlio di z andr sempre sotto lo stesso z. Laltro, poverino, rimarr per sempre senza figli! Possiamo ora tornare al nostro programma. Esso comprende due metodi: 1. locate trova un nodo (data letichetta) nellalbero costruito fino ad ora ( quella che trova sempre lo stesso z, anche se ce ne sono pi duno) 2. leggiAlberoBin fa la vera e propria lettura (usando la locate per trovare il genitore e la new per allocare lo spazio nello heap per il nuovo figlio). Il metodo locate ricalca esattamente la lettura in preordine di un albero. Il metodo leggiAlberoBin un ciclo while di lettura da file. Suppongo che nel file di input ogni riga corrisponda a un arco. Quando il file terminato, la while termina. Le righe del file sono nella forma: px y in cui p la posizione (L, R o * per la radice), x letichetta del genitore e y quella del figlio (c solo x per la radice). Per la lettura necessario un nuovo accorgimento. Nella lettura dei dati per una lista (readListFromFile, box Proc.16, nella prima parte della dispense), si era introdotta la classe Scanner. Allistanza di questa classe, creata appositamente (inputScanner) era stato inviato il messaggio nextInt(); il metodo nextInt fa due cose: innanzitutto procede fino al prossimo separatore (spazio, a capo, ) prelevando i vari caratteri, poi converte i caratteri trovati in formato numerico (rappresentazione binaria). Ora, dovrebbe essere chiaro che per leggere il file che contiene i dati dellalbero, questo non si pu fare: il primo carattere (L, R o *) non un numero, e quindi nextInt solleverebbe uneccezione. Quindi, la soluzione che adotto nel metodo per la lettura degli alberi di fare le due operazioni separatamente: prima si leggono i caratteri (metodo next), poi, se non il primo della riga, si effettua la conversione. A questo punto si pu analizzare il primo token e verificare se *. In questo caso si sta introducendo la radice. Questo comporta lassegnazione alla variabile root (passata per riferimento, per cui lassegnazione viene in realt effettuata su una variabile del main) del nodo appena creato, dopo aver verificato che root non abbia gi un valore (nel qual caso, nel file, c pi di una radice). Se upLabel non *, si cerca il nodo con etichetta upLabel nellalbero (locate) e si aggiunge il nuovo figlio.

2.8 Ancora sullInput/Output e sulle eccezioni


Nei metodi appena visti, abbiamo usato varie classi per effettuare linput da file (abbiamo, ad esempio, utilizzato le classi File e Scanner). E forse il caso di raccogliere le idee su come gestisce linput Java. Nel 1.11 abbiamo per prima cosa introdotto la classe File. Ricordo che le istanze di tale classe sono in pratica dei collegamenti tra i file esterni ai metodi e i metodi stessi. Naturalmente, le istanze della classe File stanno nello heap, e nel metodo c una variabile che contiene un riferimento allistanza. Nella figura 14 ho cercato di mostrare, con una certa approssimazione, alcune delle informazioni che debbono essere mantenute in una istanza di File; in particolare, il nome del file, il suo tipo, il suo indirizzo sullhard disk, la posizione in cui ci si trova attualmente sul file (allinizio, in posizione 0). A questo punto, avendo aperto un canale di comunicazione, potremmo iniziare la lettura, usando i metodi della classe File e inviando opportuni messaggi allistanza portIn. 19

public void leggiAlberoBin (string filename) { try {File portIn = new File (filename); Scanner lineIn = new Scanner (portIn); String nxtLine; String nxtDato; String nxtVal; int newMother; nodoBin motherNode; boolean contin = true; while (contin) {try {nxtLine = lineIn.next (); if (nxtLine.equals ()) { contin = false; } else { nxtdato = nxtLine; if (nxtDato.equals (*)) { if (radice != null) { System.out.println ( Pi di una radice ) ; } else { nxtVal = lineIn.next (); radice = new nodoBin (Integer.parseInt (nxtVal ) ); } } else { nxtVal = lineIn.next (); newMother = Integer.parseInt (nxtVal); motherNode = locate (newMother); if (motherNode == null) { System.out.println ( Genitore non ancora presente: +newMother) ; } else { nxtVal = lineIn.next (); int newDaughter = Integer.parseInt (nxtVal); if (nxtDato.equals (L)) { if ( motherNode.left == null ) { motherNode.left = new nodoBin (newDaughter) ; } else { System.out.println (Due figli sinistri per +motherNode); } } else // assumo che newDato sia uguale a R { if ( motherNode.right == null ) { motherNode.right = new nodoBin (newDaughter) ; } else { System.out.println (Due figli destri per +motherNode);}}} } } // fine else di nxtLine.equals(*) } // fine try nel body del while catch (NoSuchElementException NFE) { contin = false; System.out.println ("Fine File ");} } // fine while } // fine try lettura e conversioni } // fine try open file catch (FileNotFoundException fNF) { System.out.println (" Il file + filename + non esiste");} catch (IOException IOE) { System.out.println ("Errore in readLine ");} catch (NumberFormatException NFE) { System.out.println ("Dato non numerico ");} } // fine lettura albero da file Proc.8 Lettura di un albero binario da un file 20

public nodoBin locate (int nlabel) { return nodeLocate (radice, nlabel) ; } public nodoBin nodeLocate (nodoBin nodo, int nlabel) { if (nodo == null) { return null; } else { if ( nodo.dato == nlabel ) { return nodo; } else { nodoBin trovatoLeft = nodeLocate (nodo.left, nlabel); if (trovatoLeft == null) { return nodeLocate (nodo.right, nlabel) ; } else { return trovatoLeft; } } // fine else: il nodo attuale non quello cercato } // fine else: il nodo attuale non null } Proc. 9 Ricerca di un nodo in un albero binario

Memoria Centrale (RAM)


istanza di File

il file dati.txt

leggiAlberoBin

portIn

main STACK

dati.txt ascii 125.384.120 0 HEAP

nome tipo indirizzo pos

Hard Disk
l'indirizzo 125.384.120

Fig. 14 Unistanza della classe File Purtroppo, per, la classe File non aiuta molto! Si pu solo inviare il messaggio read, e il risultato che il prossimo carattere del file viene restituito al nostro metodo. Prima di continuare, per, desidero ricordarvi cosa si intende per codifica in un calcolatore. Chi sa gi bene i principi della codifica dei dati pu saltare direttamente alla continuazione del paragrafo (2.8.bis).

Richiami di Java 6: Codifica dei Caratteri


Questa sezione non in realt un richiamo di Java, ma di alcuni principi di codifica, che dovrebbero essere noti fin dallinizio degli studi in Informatica. Poich penso sia comunque utile avere una descrizione sintetica di questi temi, ho inserito qui una breve presentazione di alcuni principi di codifica di testi e numeri.

21

Il punto essenziale, che dovrebbe essere noto a tutti, che, dentro le memorie di un calcolatore (uso il plurale, memorie, perch ce n pi duna: RAM, Hard Disk, Cache, ) i dati sono sempre e solo memorizzati come sequenze di bit. Poich a noi servono invece dati di tipo diverso (testi, numeri, immagini, suoni) necessario decidere come i bit possano essere usati per rappresentare nella memoria i dati voluti. Io parler qui solo di testi e numeri, ma ovvio che codifiche opportune debbono anche essere inventate per memorizzare, ad esempio, un film. Cominciamo dai testi. Lobiettivo che ci poniamo descritto in fig.15: noi abbiamo delle parole (testi o parti di essi) e vogliamo stabilire un metodo per scrivere le stesse parole usando solo degli zeri e degli uno. codifica decodifica Figura 15: Rappresentazione tramite bit Sebbene esistano varie soluzioni, quella che sembra pi ragionevole (e ovvia) di basarsi sul fatto che i testi sono composti da caratteri messi uno dopo laltro ( chiaro che non ci stiamo preoccupando qui di tabelle o figure che possono comparire in un testo, per le quali si dovranno inventare altre soluzioni). In la palla abbiamo una elle, seguita da una a, seguita da uno spazio, seguita da una pi, e cos via. A questo punto, possiamo pensare di codificare separatamente i singoli caratteri, decidendo, ad esempio, che la elle viene codificata come 0011, la a con 00, la pi con 11, e cos via. In questo modo lap verrebbe codificata come 00110011. Per, come potete osservare in figura, necessario realizzare anche il processo inverso (decodifica), e, con il metodo scelto, non si pu fare; infatti, 00110011 pu essere decodificato come lap, ma anche come ll o apl. Spero sia chiaro che nella rappresentazione interna (memoria del calcolatore) non ci possono essere separazioni tra un carattere e laltro: ripeto che ci sono solo zeri e uno. Per sapere dove termina un carattere e inizia il successivo, la cosa pi semplice decidere che per ogni carattere si usa lo stesso numero di bit6. A questo punto, dobbiamo decidere quanti bit usare per ogni carattere, partendo dalla considerazione che ad ogni carattere deve corrispondere una configurazione diversa di bit. Per cui, se decido che la elle viene codificata come 0011, non posso usare 0011 anche per la emme (avremmo di nuovo il problema della decodifica). E noto che, se uso n bit per ciascun carattere, posso avere 2n diverse configurazioni; se quindi decidessi di usare 4 bit, potrei codificare 24 = 16 diversi caratteri. Ma i caratteri sono molti di pi! Abbiamo una trentina di caratteri minuscoli, altrettanti maiuscoli, una ventina di segni speciali (punto, virgola, punto esclamativo, ), le dieci cifre decimali (sono caratteri anchesse), ecc. Diciamo che mi servono almeno un centinaio di diverse configurazioni. Quindi, il numero minimo di bit 7: con 7 bit ho 27 = 128 configurazioni, mentre con 6 ne avrei solo 26 = 64, che non mi bastano. Per, visto che tutti i dati nel calcolatore sono basati sul 2 (cifre binarie appunto), sembrato pi opportuno sprecare un po di spazio e usare 8 bit per carattere (8 una potenza di 2), anzich 7 (che un numero primo). Abbiamo, a questo punto, inventato il byte. A conclusione delle considerazioni che abbiamo fatto, decidiamo quindi di mettere un carattere per byte. Dobbiamo per metterci daccordo. Se io decido che codifico la elle con 01010101, la pi con 00001111 e la a con 00100110, ottengo quello che si vede in fig.16. E mi pare chiaro che non ci sono problemi di decodifica: i primi 8 bit sono 00001111, quindi una pi; sono poi seguiti da 00100110, quindi una a, e infine 01010101, e cio una elle: pal, come desiderato. Per c ancora un piccolo inconveniente: supponiamo che io spedisca la sequenza di bit ad uno di voi. Il ricevente non pu decodificarla senza sapere il codice. Ma magari lui si era codificato un testo con un codice diverso: Ma io, per la a, volevo usare 00000000!: Babele. Bisogna quindi, per trasmettere dei dati codificati, mettersi daccordo sul codice; e se i dati debbono essere diffusi tra migliaia o milioni di riceventi, serve uno standard. Il codice standard che si usa da alcune decine di anni si chiama ASCII, che sta per American Standard Code for Information Interchange (e cio: Codice Standard Americano per lo Scambio di Informazioni). Questo codice ha le caratteristiche che ho esposto sopra (8 caratteri per bit), ma sulla

la palla

0001010100010001010001

Esistono anche codici a lunghezza variabile, ma questa una presentazione molto sintetica, per cui non ne parler 22

corrispondenza tra caratteri e codici c ormai un accordo tra tutti7. codifica decodifica Figura 16: Rappresentazione con 8 bit (un byte) per carattere Alcuni esempi di codici ASCII sono i seguenti: a 01100001 b 01100010 c 01100011 A 01000001 B 01000010 C 01000011 0 00110000 1 00110001 2 00110010 . $ 00100100 % 00100101 & 00100110 00100000 (spazio)

pal

000011110010011001010101

Beh, questa tabella abbastanza illeggibile, ma per presentarla in un altro formato, dobbiamo prima passare ad un altro tipo di codifica, quella dei numeri interi. Poich i calcolatori (come dice il nome stesso) sono stati inventati per fare dei calcoli (su numeri), chiaro che un obiettivo primario quello di rappresentare i numeri nel modo pi compatto ed efficiente possibile. Se, per rappresentare il numero 105, uso la codifica ASCII, debbo usare 3 byte (uno per luno, uno per lo zero e uno per il cinque)8. Ma, riprendendo il concetto di configurazione introdotto sopra, noi sappiamo che con un byte abbiamo 28 = 256 diverse configurazioni. Perch allora non usare una diversa configurazione per ciascun numero (non cifra, numero! Si veda la nota 8). In questo caso otteniamo una tabellina diversa dalla precedente. Infatti, qui abbiamo una codifica diversa! 7

zero uno due tre

00000000 00000001 00000010 00000011

In realt la vera codifica dei caratteri in Java parecchio pi complicata, in quanto prevista la possibilit di avere un codice su due bytes. La codifica effettiva detta UniCode, ma nei programmi viene usato uno schema detto Utf8, in cui i caratteri standard ASCII occupano effettivamente solo un byte, mentre per caratteri speciali (es. lettere di alfabeti diversi da quello occidentale) vengono usati pi bytes. Questo non cambia il discorso di base che sto facendo nel testo. Continuo ad usare una descrizione in lettere corsive per parlare dei caratteri (elle) e delle cifre (cinque). Questo per evidenziare che, ad esempio, cinque qualcosa di pi astratto di 5. Infatti, lo stesso numero (cinque) pu essere scritto (codificato) con 5 (numeri arabi, con codifica decimale), V (numeri romani), 101 (codifica binaria, come vediamo tra un attimo). Volendo essere precisi, anche cinque una rappresentazione, perch il vero numero cinque ci che hanno in comune tutti gli insiemi che contengono 5 elementi (la classe di equivalenza degli insiemi aventi cardinalit 5). 23

dieci 00001010 undici 00001011 dodici 00001100 tredici 00001101 cento 01100100 centouno 01100101 centodue 01100110 . duecento 11001000 duecentouno 11001001 duecentocinquantacinque 11111111

Questo tipo di codifica ha una caratteristica fondamentale che la distingue dalla precedente: non convenzionale, cio non c bisogno di mettersi daccordo su uno standard! In altre parole, mentre non c modo di sapere qual la codifica del carattere @ in ASCII, a meno che non vi faccia vedere la riga corrispondente della tabella, al contrario, la codifica numerica di quattro o di centocinque si pu ricavare con una semplice regola. La regola la seguente: contiamo i bit (da destra a sinistra) della codifica numerica, partendo da zero. Prendiamo come esempio il codice di tredici della tabella precedente (00001101): il bit pi a destra (posizione 0) un uno, quello subito prima (posizione 1) uno 0, quello precedente (posizione 2) un 1, e cos via (si veda la fig. 17).

posizione

6 5 4 3

2 1 0

00001101
Figura 17: Posizione dei bit in una codifica binaria Ora, prendiamo le posizioni e usiamole come esponenti di una potenza con base 2: per la posizione pi a destra otterremo 20, per quella precedente 21, per quella ancora prima 22, e cos via. Infine, utilizziamo le cifre del numero come coefficienti, ottenendo cos lespressione: 0 * 27 + 0 * 26 + 0 * 25 + 0 * 24 + 1 * 23 + 1 * 22 + 0 * 21 + 1 * 20 Se facciamo i calcoli, otteniamo: 0 * 128 + 0 * 64 + 0 * 32 + 0 * 16 + 1 * 8 + 1 * 4 + 0 * 2 + 1 * 1 = tredici Sebbene questo procedimento sembri abbastanza originale, questo quello che facciamo tutti i giorni con i consueti numeri decimali, con lunica differenza che la base, anzich essere 2, 10! (v. fig.18)

posizione

2 1

013
Figura 18: Posizione delle cifre in una codifica decimale 0 * 102 + 1 * 101 + 3 * 20 E cio: 0 * 100 + 1 * 10 + 3 *1 = tredici Notate ( importante!) che il numero rappresentato non 13, bens tredici: 13 la rappresentazione decimale di questo numero, come 00001101 la rappresentazione binaria dello stesso numero. Come abbiamo imparato dalle elementari, 013 corrisponde a zero centinaia, una decina e 3 unit: esattamente quello che abbiamo fatto 24

sopra! Se ora vogliamo sapere qual la rappresentazione di trentanove (sappiamo che in decimale 39), dovremmo applicare un procedimento inverso. Questo procedimento abbastanza semplice e comporta una serie di divisioni per due, ma non questa la sede per imparare a fare le conversioni. Lunica cosa importante che siate convinti che trentanove 00100111 e che se si cambia una qualunque cifra (uno zero diventa un uno o viceversa) il numero codificato cambia! Questo stesso metodo di codifica si pu usare con qualunque base. Una base utile quella esadecimale, quella, cio, per cui la base 16. Il motivo che c una corrispondenza molto stretta tra base 2 e base 16, poich 16 = 24. Questo vuol dire che un numero (es. di tre cifre) in base 16 sar espresso come x * 162 + y * 161 + z * 160. Ma questo esattamente lo stesso che x * 28 + y * 24 + z * 20. Ora, se riprendiamo lespressione che abbiamo visto sopra per il numero tredici, possiamo riscriverla nel modo seguente: (0 * 23 + 0 * 22 + 0 * 21 + 0 * 20) * 24 + (1 * 23 + 1 * 22 + 0 * 21 + 1 * 20) * 20 Ognuna delle espressioni tra parentesi corrisponder quindi ad una cifra esadecimale; nellesempio: 0 * 161 + 13 * 160 Il vantaggio di questa codifica che, grazie alle considerazioni sulle potenze fatte sopra, un numero binario di otto cifre pu essere espresso come numero esadecimale di due cifre, raggruppando le cifre binarie a quattro a quattro. Purtroppo, come nellalfabeto binario abbiamo due cifre (0 e 1) e in quello decimale ne abbiamo 10 (da 0 a 9), in quello esadecimale ne dovremo avere 16: per convenzione, si usano 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E, F. Per cui, la codifica esadecimale del numero tredici sar: 0D. Nella tabella 1, alcuni esempi di codifica.

Numero zero uno due dieci tredici trentanove cento duecento duecentocinquantacinque

Binario (8 bit) 00000000 00000001 00000010 00001010 00001101 00100111 01100100 11001000 11111111

Decimale (3 cifre) 000 001 002 010 013 039 100 200 255

Esadecimale (2 cifre) 00 01 02 0A 0D 27 64 C8 FF

Romano I II X XIII XXXIX C CC CCLV

Tabella 1: Esempi di codifiche numeriche Ora che abbiamo a disposizione una rappresentazione pi compatta di quella binaria, possiamo scrivere la tabella che contiene una parte significativa (non tutti, in realt) del codice ASCII (Tab. 2). I primi esempi sono relativi a caratteri speciali (N.B. Null non il null di Java): SOH sta per Start of Heading, HT per Horizontal Tab, LF per Line Feed (nuova riga), VT per Vertical Tab, FF per Form Feed (salto pagina), CR per Carriage Return (ritorno a inizio riga). Per completezza, si pu anche osservare che i codici terminano a 7F (in binario 01111111). Questo perch lASCII base occupa in realt 7 bit (il primo sempre a zero). LASCII esteso (su 8 bit) include anche caratteri accentati e altri caratteri di vari alfabeti, che non ho riportato in tabella. Prima di ritornare al nostro metodo di lettura da file, unultima considerazione. In un file di tipo testo, i caratteri sono rappresentati in formato ASCII. Poich si tratta comunque di una sequenza di bit, non esistono tabulazioni o a capo separate, ma anchesse debbono essere codificate nello stesso modo (ASCII). Di conseguenza, esiste un codice ASCII per a capo, anche se, per motivi storici, questo , di norma, codificato con due caratteri separati: Line Feed (cambio riga: 00001010) e Carriage Return (ritorno a inizio riga: 00001101). Di conseguenza, la codifica del contenuto avviene come in fig.19.

25

00 Null 01 SOH .. 09 HT 0A LF 0B VT 0C FF 0D CR . 20 Space 21 ! 22 23 # 24 $ 25 %

26 27 28 29 2A 2B 2C 2D 2E 2F 30 31 32 33 34

& ( ) * , . ; 0 1 2 3 4

35 36 37 38 39 3A 3B 3C 3D 3E 3F 40 41 42 43

5 6 7 8 9 : ; < = > ? @ A B C

44 45 46 47 48 49 4A 4B 4C 4D 4E 4F 50 51 52

D E F G H I J K L M N O P Q R

53 54 55 56 57 58 59 5A 5B 5C 5D 5E 5F 60 61

S T U V W X Y Z [ \ ] ^ _ a

62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F 70

b c d e f g h i j k l m n o p

71 72 73 74 75 76 77 78 79 7A 7B 7C 7D 7E 7F

q r s t u v w x y z { | } ~ Del

Tabella 2: Alcuni caratteri ASCII; ogni elemento formato da una coppia: <Codifica, carattere>. La codifica espressa in esadecimale; per cui, ad esempio, la codifica di @ (40) corrisponde a 01000000

7 100 13

370A0D3130300A0D3133

Figura 19: Codifica effettiva del contenuto di un file ASCII (.txt).

2.8.bis Ancora sullInput/Output e sulle eccezioni (ripresa)


Se ora si legge un carattere, si ottiene 37 (esadecimale) che il codice ASCII di 7, ed un tipo char di Java. Se per a noi serve un int c una non-corrispondenza di tipo (un errore). Noi vorremmo invece un 07 (esadecimale, cio 00000111) che proprio la codifica binaria del numero 7. Se usiamo una istanza di Scanner, il problema parzialmente risolto; infatti un Scanner, non legge un carattere per volta, ma va avanti fino al prossimo separatore (nel nostro caso i due caratteri 0A 0D). E se usiamo il metodo nextInt, si occupa il metodo stesso di convertire in binario (come desiderato). Se per nel file possono esserci sia caratteri (come nel nostro esempio, L, R, *) che numeri, ci troviamo di fronte a un dilemma: se usiamo nextInt, quando c il carattere c un errore, perch non pu essere convertito in intero. Se usiamo next (che non fa la conversione), quando c un carattere ok, ma quando c un numero otteniamo una String (ASCII), e non un int. La soluzione, come dicevo, di fare le due operazioni separatamente: si legge comunque il prossimo dato come stringa; se si tratta di L, R o * si lascia com, altrimenti lo si converte da stringa a intero. La conversione viene fatta tramite un metodo della classe Integer (da non confondere col tipo elementare int) che si chiama parseInt. Notate che si tratta di un metodo statico: il messaggio viene infatti inviato alla classe Integer, e non a una sua istanza. Infine, unultima osservazione sulle eccezioni. Nel metodo leggiAlberoBin compaiono tre catch, rispettivamente: 1. FileNotFoundException 2. IOException 3. NumberFormatException Sebbene esse siano del tutto analoghe, esiste una differenza. Alcune eccezioni sono obbligatorie, altre no. Il fatto che io abbia introdotto le eccezioni nel 1.11 dipende appunto dal fatto che esistono eccezioni che Java richiede siano verificate: tali eccezioni si dicono controllate. Se non si introduce nel programma la coppia try catch per uneccezione di questo tipo, Java produce un errore in compilazione, cosicch non si pu neppure provare ad eseguire il programma. Le eccezioni FileNotFoundException e IOException sono di questo tipo. Al contrario, NumberFormatException non uneccezione controllata, cos si pu anche non mettere la catch relativa ad essa. Ovviamente, se nel file compare, come nome di un nodo, un valore non intero, parseInt solleva leccezione 26

comunque: poich non c nessuna catch adatta, leccezione sale fino alla macchina virtuale Java, che la rileva e interrompe lesecuzione del programma. In realt, anche se voi togliete la catch di FileNotFoundException, il metodo viene compilato correttamente e funziona, ma per un altro motivo: IOException una classe di eccezioni pi generale, di cui FileNotFoundException un caso particolare. Di conseguenza, in mancanza di FileNotFoundException, sar IOException a catturare lerrore di file mancante (con la conseguenza che, se non si cambia ulteriormente il metodo, anche se manca il file si otterr la stampa Errore in readLine). Infine, ancora sulle eccezioni. Come abbiamo visto, uneccezione catturata da una clausola catch, che richiede, oltre al tipo di eccezione, anche una variabile. Tale variabile pu destare qualche perplessit: a cosa serve? Per comprendere questo, necessario ricordare che la throw lancia leccezione con unespressione del tipo throw new TipoEccezione (); Ovviamente, nei vari esempi di uso delle eccezioni per la lettura da file non c nessuna throw, ma solo perch le throw si trovano nei metodi delle classi File, Scanner (i costruttori), e Integer (parseInt). Al contrario, nella prima parte delle dispense (v. pag.36) stato descritto il modo per definire proprie classi di eccezioni, e in questo caso si era usata una throw new noSuchElementException (); per sollevare leccezione. Ora, come tutte le new, anche per le eccezioni il suo effetto quello di creare una nuova istanza della classe specificata (nel nostro esempio noSuchElementException). A che serve questa istanza? Nel nostro semplice esempio, a nulla; ma solo perch noi abbiamo (per semplicit) definito la classe noSuchElementException nel modo seguente: class noSuchElementException extends Exception {} e cio senza nessuna informazione utile. Ma, come tutte le classi, anche una classe di eccezioni pu includere variabili di istanza, costruttori, metodi. Ad esempio, una definizione un po pi complessa potrebbe essere la seguente: class noSuchElementException extends Exception { private int type; public noSuchElementException (int val) { type = val;} public int getType () { return type; } } Come si vede, non c nulla di nuovo rispetto a quanto gi sappiamo: abbiamo introdotto una variabile di istanza (type), un costruttore (che prende come parametro un intero), e un metodo per estrarre il valore di type. Ora, la throw vista poco sopra non pi corretta! Infatti, come sempre, se il costruttore prevede degli argomenti, la new deve fornirli. Dovremo perci scrivere qualcosa del tipo: throw new noSuchElementException (2); E cos 2, cio il type? Beh, questo dipende da cosa si vuol fare: il programmatore che lo decide. Ad esempio, si pu scegliere che leccezione noSuchElementException possa essere usata in vari casi in cui non si trova un elemento nella lista: quando si vuole cancellare lelemento, quando lo si vuole modificare, quando si vuole inserire un nuovo elemento prima di quello specificato, etc. Poich leccezione sempre la stessa, type pu servire per distinguere i vari casi: dentro la deleteElem comparir throw new noSuchElementException (1); dentro la modifyElem comparir throw new noSuchElementException (2); e cos via.

27

Possiamo ora tornare alla catch. La coppia throw-catch pu essere vista come una forma speciale di assegnazione: la throw dice cosa deve essere assegnato, la catch dove metterlo, e cio proprio nella variabile associata. Cio, se scrivo catch (noSuchElementException nSEE) un p come se facessi nSEE = new noSuchElementException (1); cosa che ovviamente non posso fare, visto che la throw e la catch sono in due metodi diversi! Per, se lerrore si verifica, la variabile della catch conterr proprio listanza (o, meglio, il riferimento allistanza) creata dalla throw. E quindi tramite tale riferimento possiamo andare a prelevare dallistanza tutte le informazioni che vi erano state messe dalla new (e cio allatto della throw). Ad esempio, si potr fare: try { provalista.provaOperazioni (); } catch (noSuchElementException nSEE) { System.out.println (" Errore noSuchElementException di tipo "+nSEE.getType() ); } ; in cui provaOperazioni un metodo che include il solito switch che chiede allutente quale operazione vuol fare9. Si noti luso del messaggio getType inviato proprio alla variabile nSEE. Come si vede, il meccanismo delle eccezioni molto potente: come in molti altri casi, abbiamo qui visto solo le basi.

Ho inserito provaOperazioni, per rendere pi chiaro lesempio: nel frammento riportato (che potrebbe essere il main) non si pu sapere se leccezione sorta quando lutente aveva richiesto una cancellazione o una modifica (perch questo stato fatto dentro la provaOperazioni), e quindi lunico modo per saperlo di utilizzare il type delleccezione. 28