Capitolo 4
Transcript
Capitolo 4
4. Semantica Operazionale 4.1 La macchina astratta SIMPLESEM La semantica operazionale di un linguaggio di programmazione si definisce sulla base delle modifiche che l’esecuzione delle varie istruzioni producono sullo stato di una particolare macchina astratta di riferimento. Così come per quella denotazionale, lo stato nella semantica operazionale è dato dalla configurazione dei valori in memoria in un dato momento. L’approccio operazionale alla semantica è senza dubbio quello più orientato alla macchina: quanto più la macchina astratta di riferimento è vicina ad un calcolatore reale, tanto più la descrizione della semantica operazionale di un linguaggio di programmazione equivale a scriverne un traduttore. Per quanto riguarda la nostra analisi, introduciamo una macchina astratta che chiameremo SIMPLESEM. Essa consiste in un processore, una memoria e un instruction pointer, ovvero un riferimento all’istruzione che risulti essere in corso d’esecuzione in un dato momento. La memoria contiene sia le istruzioni da eseguire (il programma), sia i dati da manipolare. Per semplicità d’uso, assumeremo che queste due parti siano memorizzate in due diverse aree di memoria: la code memory (C) e la data memory (D). Sia gli indirizzi di C che quelli di D iniziano da zero e assumeremo che sia il programma che i dati saranno memorizzati a partire da questo indirizzo. L’instruction pointer (IP) è usato sempre per puntare una locazione in C ed è inizializzato a zero. Utilizzeremo la notazione D[X] e C[X] per indicare i valori memorizzati nella Xesima cella di D e C rispettivamente. Dunque X è un l-value e D[X] è l’r-value corrispondente. Le modifiche ai valori memorizzati in una cella sono effettuate attraverso l’istruzione set target, source dove i due parametri target e source sono rispettivamente l’indirizzo della cella il cui valore deve essere aggiornato, e l’espressione che indica il nuovo valore. Ad esempio, l’effetto dell’istruzione set 10, D[20] sulla data memory è di copiare nella locazione 10 il valore memorizzato nella locazione 20. L’input/output si ottiene nel SIMPLESEM utilizzando l’istruzione set e facendo riferimento a dei registri speciali read e write. Ad esempio, set 15, read significa che il valore in input è memorizzato nella locazione 15, mentre set write, D[50] significa che il valore memorizzato nella locazione 50 è restituito in output. L’instruction pointer di SIMPLESEM è inizializzato a zero ad ogni nuova esecuzione ed è aggiornato ogni volta che un’istruzione viene eseguita. Il ciclo di fetch-execute di SIMPLESEM, finché non si raggiunge l’istruzione speciale di terminazione halt, risulta infatti essere il seguente: 1 1. Determina l’istruzione da eseguire (cioè C[ip]); 2. Incrementa ip; 3. Esegui l’istruzione. Come tutti i calcolatori che si rispettino, SIMPLESEM fornisce due istruzioni di salto jump (salto incondizionato) e jumpt (salto condizionato) al fine di poter alterare la normale struttura sequenziale dell’esecuzione di un programma. Ad esempio, jump 47 fa sì che l’istruzione memorizzata all’indirizzo 47 di C sia la prossima ad essere eseguita, mentre jumpt 47, D[3] > D[8] fa sì che questo avvenga solo se il valore memorizzato all’indirizzo 3 di D è maggiore di quello nell’indirizzo 8 di D. SIMPLESEM fornisce anche l’indirizzamento indiretto. Ad esempio, set D[10], D[20] assegna il valore memorizzato nella locazione 20 alla cella il cui indirizzo è il valore contenuto nella locazione 10. Il funzionamento di SIMPLESEM è molto semplice. In altre parole potremmo dire che la semantica delle sue istruzioni è intuitivamente nota. La semantica di un qualsiasi linguaggio di programmazione potrà quindi essere introdotta definendo le regole secondo le quali ogni costrutto del linguaggio si traduce nell’equivalente sequenza di istruzioni di SIMPLESEM. 4.2 Struttura dei linguaggi a tempo di esecuzione Discuteremo ora come sia possibile illustrare i concetti principali legati al funzionamento dei linguaggi di programmazione a tempo di esecuzione attraverso l’uso di SIMPLESEM. Procederemo gradualmente, partendo dai concetti di base fino ad arrivare alle strutture complesse che riflettono gli strumenti forniti dai moderni linguaggi di programmazione di tipo general-purpose. Ci muoveremo attraverso una serie di linguaggi basati su delle semplificazioni del linguaggio C. Per questo motivo saranno indicati con i nomi da C1 fino a C5. La nostra discussione mostrerà come sia possibile classificare i linguaggi in varie categorie a seconda della loro struttura a tempo di esecuzione. • Linguaggi statici. Questi linguaggi, come le prime versioni del FORTRAN e del COBOL, garantiscono che la memoria richiesta da un programma per la sua esecuzione possa essere quantificata prima dell’esecuzione del programma (cioè a tempo di traduzione). Chiaramente questi linguaggi hanno dei limiti come, ad esempio, quello di non poter supportare la ricorsione. I linguaggi C1, C2 e C2’ che discuteremo in seguito appartengono a questa categoria. • Linguaggi a pila. In questi linguaggi, di cui il primo rappresentante fu l’ALGOL60, la richiesta di memoria da parte di un programma si fa più consistente e non può essere 2 calcolata a tempo di traduzione. Tuttavia, l’utilizzo della memoria può essere predetto e segue una disciplina last-in/first-out (LIFO). Le variabili dichiarate in uno scope sono allocate automaticamente quando si entra nello scope a tempo di esecuzione e deallocate quando se ne esce. Quindi l’ultimo record di attivazione ad essere stato allocato sarà il primo ad essere deallocato. Risulta così possibile gestire la memoria D di SIMPLESEM come una pila per modellare il comportamento di questi linguaggi a tempo di esecuzione. I linguaggi C3 e C4 sono di questo tipo. • Linguaggi dinamici. Questi linguaggi hanno un utilizzo della memoria che non può essere predetto in quanto gli oggetti sono allocati dinamicamente solo quando sono richiesti durante l’esecuzione. Queste variabili sono dette appunto dinamiche. In questo caso sorge anche il problema di gestire efficientemente la memoria, riconoscendo e riallocando gli spazi non più utilizzati. L’implementazione di questi linguaggi avviene dividendo la memoria D di SIMPLESEM in due parti: quella gestita a pila e una nuova parte detta “heap”. Il linguaggio C5 appartiene a questa classe. 4.2.1 Il linguaggio C1 Il linguaggio C1 può essere visto come un sottolinguaggio del C in cui abbiamo solo tipi e istruzioni semplici e non ci sono funzioni. Assumiamo che gli unici dati manipolabili nel linguaggio sono quelli le cui richieste di spazio in memoria sono note staticamente, come interi, reali e array e strutture di dimensioni fissate. Il programma consiste della routine principale main() che racchiude le dichiarazioni dei dati e le istruzioni che li manipolano. Per semplicità, l’input/output è ottenuto utilizzando le istruzioni get e print rispettivamente per leggere e scrivere dei valori. Mostriamo ora un semplice programma C1 insieme alla sua rappresentazione codificata nel linguaggio di SIMPLESEM nella memoria C e al record di attivazione del programma contenuto nella memoria D. C main() { int i,j; get(i,j); while (i != j) if (i > j) i = i – j; else j = j – i; print(i); } D 0 set 0, read 0 cella riservata a i 1 set 1, read 1 cella riservata a j 2 jumpt 8, D[0] = D[1] 3 jumpt 6, D[0] ≤ D[1] 4 set 0, D[0] – D[1] 5 jump 7 6 set 1, D[1] – D[0] 7 jump 2 8 set write, D[0] 9 halt Record di attivazione del programma 3 4.2.2 Il linguaggio C2 Otteniamo il linguaggio C2 aggiungendo a C1 la possibilità di definire semplici routine. La struttura di un programma C2 sarà dunque la seguente: • • • Un insieme (eventualmente vuoto) di dichiarazioni di dati (dati globali). Un insieme (eventualmente vuoto) di definizioni e/o dichiarazioni di routine; Una routine main(), attivata automaticamente all’inizio dell’esecuzione, che contiene la dichiarazione dei suoi dati locali e un insieme di istruzioni. Essa non può essere chiamata dalle altre routine. Le routine possono accedere ai loro dati locali e a quelli globali che non sono ridefiniti al loro interno. Esse non possono essere annidate, non posso chiamare loro stesse ricorsivamente, non hanno parametri e non ritornano valori. Riportiamo un esempio di programma C2: int i = 1, j = 2, k = 3; alpha() { int i = 4, l = 5; … i = i + k + l; … } beta() { int k = 6; … i = j + k; alpha(); … } main() { … beta(); … } Sotto le assunzioni fatte finora, le dimensioni del record di attivazione di ciascuna unità possono essere determinate a tempo di traduzione e ogni record può essere allocato prima dell’esecuzione del programma. Ogni variabile può quindi essere associata ad un indirizzo della memoria D prima dell’esecuzione. L’allocazione statica è una soluzione molto semplice che non causa rallentamenti a tempo di esecuzione dovuti alla gestione della memoria, ma può sprecare spazio. Infatti, la memoria necessaria all’esecuzione di una routine verrebbe allocata anche se la routine non venisse mai chiamata. Poiché il nostro obiettivo è solo quello di fornire una descrizione semantica, non ci addentreremo nello studio di schemi di gestioni della memoria più efficienti. Presentiamo ora lo stato di SIMPLESEM dopo l’esecuzione dell’istruzione i = i + k + l; della routine alpha. 4 C D 0 8 (i) 1 2 (j) 2 3 (k) 3 125 ret. point 4 12 (i) 49 5 5 (l) 50 6 16 ret. point 7 6 (k) 14 set 6, 16 15 jump 100 58 set 4, D[4] + D[2] + D[5] 99 jump D[3] Segmento di codice del main dati globali record di alpha record di beta Segmento di codice di alpha 100 122 set 0, D[1] + D[7] 123 set 3, 125 124 jump 50 Segmento di codice di beta 59 ip jump D[6] La prima locazione di ogni record di attivazione (offset 0) è riservata al return pointer. Lo spazio a partire dall’offset 1 è riservato alle variabili locali. In generale, per un’istanza di un’unità A, il return pointer conterrà l’indirizzo dell’istruzione che deve essere eseguita quando A termina. Ciò non avviene per main() che non è chiamato da alcuna unità. Sui calcolatori reali, tuttavia, main() è chiamato dal sistema operativo, dunque alla fine del programma il controllo deve essere restituito al sistema operativo. Il record di attivazione di main() in SIMPLESEM contiene dunque solo i dati globali. Finora abbiamo assunto implicitamente che il programma sia contenuto in un unico file compilato in un unico passo. Introduciamo ora il linguaggio C2’ che consente la compilazione separata, cioè la possibilità di definire le varie unità di programma in file separati che possono essere compilati indipendentemente in ordine arbitrario. Assumiamo che il file contenente la routine principale main() contenga anche i dati globali e che ogni file possa contenere un’unica unità. Se una routine fa uso di un dato globale, essa deve definirlo usando la parola chiave extern. Riorganizziamo ora il programma precedente in file separati. 5 file 1: int i = 1, j = 2, k = 3; extern beta(); main() { … beta(); … } file 2: extern int k; alpha() { … } file 3: extern int i,j; extern alpha(); beta() { … alpha(); … } Come nel caso di C2, l’implementazione di C2’ riserverà la prima locazione di ogni record di attivazione (eccetto main()) per il return pointer. Le locazioni successive saranno quindi riservate eventualmente per le variabili locali che possono essere associate al loro offset all’interno del record di attivazione, poiché ogni routine è compilata separatamente. Ciò che non è possibile, invece, è associare ogni variabile locale al suo indirizzo assoluto e ogni variabile globale importata al proprio offset all’interno del record di attivazione di main(). Analogamente, le chiamate di routine non possono essere associate all’indirizzo di partenza del corrispondente segmento di codice. Sarà compito del linker risolvere gli indirizzi nel combinare insieme i moduli compilati separatamente in un unico modulo eseguibile. ESERCIZIO: dare lo stato di SIMPLESEM dopo la compilazione di ognuno dei file sopra riportati e dopo l’operazione di link. 4.2.3 Il linguaggio C3 Il linguaggio C3 è ottenuto aggiungendo a C2 la capacità delle routine di restituire valori e di chiamare loro stesse (ricorsione diretta) o di chiamarsi a vicenda (ricorsione indiretta). Un esempio di programma C3 per il calcolo del fattoriale è il seguente: int n; int fact() { int loc; if (n > 1) { loc = n--; return loc*fact(); } else return 1; } main() { get(n); if (n >= 0) print(fact()); else print(“input error”); } 6 Analizziamo quali sono gli effetti dovuti all’introduzione della ricorsione. Sebbene il record di attivazione di ogni unità ha una dimensione nota e fissata, non si conosce quante istanze di una particolare unità saranno attivate durante l’esecuzione. Nel programma riportato sopra, ad esempio, non si conosce a priori quante attivazioni della funzione fact saranno necessarie. Queste attivazioni, pur avendo tutte lo stesso segmento di codice, hanno bisogno di diversi record di attivazione per memorizzare i diversi valori del loro ambiente locale. Così come in C2, il traduttore può associare ogni variabile al suo offset nel record di attivazione corrispondente. Tuttavia non è possibile stabilire quale sarà l’indirizzo assoluto nella memoria D di una variabile fino a tempo di esecuzione quando l’offset di una variabile (noto staticamente) verrà sommato all’indirizzo di partenza del record di attivazione (noto dinamicamente). Per rendere possibile questa operazione useremo la cella di indirizzo zero di D per memorizzare l’indirizzo base del record di attivazione dell’unità in corso di esecuzione (tale valore sarà chiamato CURRENT). Quando l’istanza corrente di un’unità termina, il suo record di attivazione non è più necessario e si può quindi liberare il relativo spazio in memoria. Ad esempio, se A chiama B che poi chiama C, i record di attivazione delle tre unità sono allocati nell’ordine A, B, C. Quando C ritorna il controllo a B, il record di attivazione di C può essere distrutto. Sarà poi la volta del record di attivazione di B, quando il controllo sarà restituito ad A. Poiché il record di attivazione che viene deallocato è quello che è stato allocato più di recente, i record di attivazione sono gestiti secondo una politica LIFO in una memoria organizzata a pila. Per fare in modo che sia possibile restituire il controllo all’unità chiamante, a questo punto si rende necessario oltre al return pointer anche l’indirizzo base del record di attivazione del chiamante. Assumiamo, quindi, che la cella ad offset zero del record di attivazione continui a contenere il return pointer e che quella ad offset uno contenga l’indirizzo base del record di attivazione del chiamante. Questo puntatore è chiamato link dinamico. La catena di link dinamici che si genera a partire dal record di attivazione corrente è detta catena dinamica. In ogni momento la catena dinamica rappresenta la sequenza dinamica di attivazione di unità. Per poter gestire la memoria D di SIMPLESEM come una pila è necessario conoscere a tempo di esecuzione l’indirizzo della prima cella libera di D, poiché un nuovo record di attivazione sarà allocato a partire da tale punto. Useremo la cella di indirizzo 1 di D per memorizzare tale valore che chiameremo FREE. Infine è necessario fornire spazio in memoria per il valore eventualmente ritornato da una routine. Poiché il record di attivazione è deallocato nel momento in cui tale valore viene ritornato, esso va salvato nel record di attivazione del chiamante. Quindi, quando una routine che restituisce un valore è chiamata, il record di attivazione del chiamante è esteso per fornire spazio per il valore ritornato, e la routine chiamata scriverà tale valore nell’apposito spazio utilizzando un offset negativo prima di ritornare il controllo. Illustriamo, nella figura che segue, il generico stato della memoria D di SIMPLESEM. 7 0 1 LEGENDA 2 3 CURRENT record di attivazione del chiamante FREE link dinamico record di attivazione della routine in corso di esecuzione return pointer Poiché la ricorsione è la caratteristica principale che caratterizza C3, mostriamo ora come la semantica della chiamata di una routine è specificata in termini di istruzioni di SIMPLESEM. Chiamata di routine: set 1, D[1] + 1 alloca spazio sulla pila per il valore ritornato (nel caso in cui la routine chiamata restituisca un valore che occupi una cella di memoria); set D[1], ip + 4 setta il valore del return pointer nel record di attivazione del chiamato (4 è il numero di istruzioni ancora necessarie per implementare la chiamata); set D[1] + 1, D[0] setta il link dinamico nel record di attivazione del chiamato al valore dell’indirizzo base del record di attivazione del chiamante; set 0, D[1] pone CURRENT uguale all’indirizzo base del record di attivazione della routine chiamata; set 1, D[1] + AR aggiorna FREE (qui AR sta ad indicare la dimensione del record di attivazione della routine chiamata); jump start_addr start_addr rappresenta l’indirizzo nella memoria C della prima istruzione della routine chiamata. Ritorno dalla routine: set 1, D[0] dealloca il record di attivazione della routine terminata; set 0, D[D[0] + 1] l’unità chiamante torna ad essere quella in corso di esecuzione; jump D[D[1]] salta all’indirizzo memorizzato nel return pointer. Assumiamo che, prima che l’esecuzione di un programma di SIMPLESEM abbia inizio, ip sia inizializzato a zero. L’istruzione alla locazione zero, dunque, inizializza il valore di FREE sulla base dello spazio di memoria necessario a contenere i valori delle variabili globali e di quelle locali di main (qualora ve ne siano). Mostriamo, come esercizio, l’esecuzione del programma per il calcolo del fattoriale su SIMPLESEM riportando rispettivamente il codice memorizzato in C e la situazione della memoria 8 D immediatamente dopo la prima chiamata a fact, e al momento della terminazione della terza attivazione di fact nel caso in cui l’input letto sia uguale a 3. 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 set 1, 3 inizializza FREE: la locazione 2 è riservata a n; set 2, read legge n e lo memorizza nell’area di memoria ad esso riservata; jumpt 11, D[2] < 0 testa il valore di n; set 1, D[1] + 1 riserva spazio per il valore ritornato da fact; set D[1], ip + 4 setta il return pointer; set D[1] + 1, D[0] setta il link dinamico; set 0, D[1] setta CURRENT; set 1, D[1] + 3 setta FREE (3 è la dimensione del record di attivazione); jump 13 13 è l’indirizzo al quale comincia fact; set write, D[D[1] – 1] D[1] – 1 è l’indirizzo riservato al risultato restituito da fact; jump 12 fine della chiamata; set write, “input error” halt fine del main; inizio del codice di fact: testa il valore di n; jumpt 24, D[2] ≤ 1 set D[0] + 2, D[2] assegna n a loc; set 2, D[2] – 1 decrementa n; set 1, D[1] + 1 riserva spazio per il valore ritornato da fact; set D[1], ip + 4 setta il return pointer; set D[1] + 1, D[0] setta il link dinamico; set 0, D[1] setta CURRENT; set 1, D[1] + 3 setta FREE (3 è la dimensione del record di attivazione); jump 13 13 è l’indirizzo al quale comincia fact; set D[0] – 1, D[D[0] + 2] * D[D[1] – 1] memorizza il valore di ritorno; jump 25 set D[0] – 1, 1 ritorna 1; set 1, D[0] dealloca il record di attivazione di fact; set 0, D[D[0] + 1] pone main come unità in corso di esecuzione; jump D[D[1]] restituisce il controllo a main. D 0 4 CURRENT 1 7 FREE 2 3 n valore ritornato 3 AR di fact 4 9 return pointer 5 2 link dinamico 6 loc 9 D 0 12 CURRENT 1 15 FREE 2 1 n 3 AR di fact valore ritornato 4 9 return pointer 5 2 link dinamico 6 3 loc 7 AR di fact AR di fact valore ritornato 8 22 return pointer 9 4 link dinamico 10 2 loc 11 1 valore ritornato 12 22 return pointer 13 8 link dinamico 14 loc 4.2.4 Il linguaggio C4 La famiglia di linguaggi C4 contiene due sottoinsiemi: C4’ e C4’’. C4’ consente di inserire dichiarazioni locali all’interno di qualsiasi istruzione composta. C4’’ supporta la possibilità di definire una routine all’interno di un’altra. Convenzionalmente, queste caratteristiche offerte da C4’ e C4’’ sono dette struttura a blocchi. La struttura a blocchi è usata per controllare lo scope delle variabili, per definire il loro ciclo di vita e dividere il programma in sottounità. Due blocchi qualsiasi di un programma possono essere disgiunti (non hanno parti in comune) oppure annidati (un blocco contiene interamente l’altro). In C4’, un blocco è definito dalla seguente sintassi: {< declaration_list > ; < statement_list >} e può apparire in qualsiasi punto del programma in cui può apparire un’istruzione. Un blocco definisce automaticamente lo scope delle sue variabili locali: esse sono visibili all’interno del blocco e di tutti gli altri blocchi in esso annidati, purché tali variabili non siano ridichiarate. Illustriamo con un esempio questa nuova situazione: int f() { //blocco 1 int x,y,w; while (…) { int x,z; while (…) { int y; … } //blocco 2 //blocco 3 //fine blocco 3 10 if (…) { //blocco 4 int x,w; … } } if (…) { //fine blocco 4 //fine blocco 2 //blocco 5 int a,b,c,d; … } } //fine blocco 5 //fine blocco 1 La funzione f dichiara localmente le variabili x, y e w il cui scope si estende a tutto il corpo della funzione, con le seguenti eccezioni: • • • x è ridichiarata nel blocco 2. Quindi all’interno dei blocchi 2, 3 e 4 la variabile x dichiarata da f non risulta visibile; y è ridichiarata nel blocco 3. Quindi all’interno di tale blocco la variabile y dichiarata da f non risulta visibile; w è ridichiarata nel blocco 4. Quindi all’interno di tale blocco la variabile w dichiarata da f non risulta visibile; Allo stesso modo, la visibilità delle variabili x e z dichiarate all’interno del blocco 2 si estende all’intero blocco con una eccezione. Poiché x è ridichiarata nel blocco 4, all’interno di tale blocco essa maschera quella dichiarata nel blocco 2 che risulta quindi non visibile. Un blocco definisce anche il ciclo di vita dei dati locali. Lo spazio in memoria è associato a una variabile quando durante l’esecuzione si entra nel blocco in cui essa è dichiarata. Tale binding viene quindi rimosso quando si esce dal blocco. Al fine di implementare la struttura a blocchi su SIMPLESEM si può scegliere tra due alternative. L’una consiste nell’includere staticamente la memoria richiesta dal blocco all’interno del record di attivazione dell’unità in cui tale blocco occorre; l’altra consiste nell’allocare dinamicamente questo spazio solo nel momento in cui il flusso di esecuzione entra nel blocco. Il primo schema è più semplice e veloce, mentre il secondo gestisce la memoria più efficientemente. Consideriamo il primo schema applicato alla funzione f. Notiamo, innanzi tutto, che i blocchi 2 e 5 sono disgiunti così come i blocchi 3 e 4. Poiché due blocchi disgiunti non possono essere contemporaneamente attivi, è possibile usare le stesse celle di memoria per memorizzare i loro dati locali. Il record di attivazione di f può quindi essere implementato nel seguente modo: 11 return pointer link dinamico x in 1 y in 1 w in 1 x in 2 -- a in 5 z in 2 -- b in 5 y in 3 -- x in 4 -- c in 5 w in 4 -- d in 5 Il simbolo -- sta ad indicare una sovrapposizione. La definizione delle sovrapposizioni può essere effettuata a tempo di traduzione. Una struttura a blocchi può essere descritta da uno static nesting tree (SNT) che mostra come i blocchi sono annidati uno nell’altro. Ogni nodo rappresenta un blocco e i figli di un nodo indicano i blocchi che sono immediatamente annidati nel nodo da esso rappresentato. Mostriamo l’SNT dell’esempio finora considerato: blocco 1 blocco 2 blocco 3 blocco 5 blocco 4 Analizziamo adesso C4’’, ovvero la possibilità di definire una routine all’interno di un’altra come riportato nell’esempio seguente: //file start int x,y,z; f1() { int t,u; f2() { //blocco 0 //blocco 1 //blocco 2 int x,w; f3() { int y,w,t; … //blocco 3 12 } x = y + t + w + z; } //fine blocco 3 //fine blocco 2 … } main() { //fine blocco 1 //blocco 4 int z,t; … } //file end //fine blocco 4 //fine blocco 0 blocco 0 blocco 1 blocco 4 blocco 2 blocco 3 La routine f3 può essere chiamata soltanto all’interno di f2. Una chiamata ad f3 effettuata nel corpo di f2 è una chiamata locale. Poiché f3 è dichiarata all’interno di f2, f3 può essere anche chiamata da sé stessa. In questo caso si ha una chiamata non locale. Allo stesso modo f2 può essere chiamata all’interno di f1 (chiamata locale) oppure all’interno di f2 e f3 (chiamata non locale). Una routine può inoltre accedere alle variabili locali e a quelle globali dichiarate nelle routine che la racchiudono purché tali variabili no siano ridefinite. Nel nostro esempio, f3 può accedere alle variabili non locali x (dichiarata nel blocco 2) e u (dichiarata nel blocco 1), nonché alla variabile globale z. Esaminiamo l’effetto della seguente sequenza di chiamate: main chiama f1, f1 chiama f2, f2 chiama f3, f3 chiama f2. La pila dei record di attivazione può essere così sintetizzata: D ambiente globale CURRENT x, y, z main z, t f1 t, u f2 x, w f3 y, w, t f2 x, w link dinamici 13 La descrizione è semplificata per facilitare la leggibilità, comunque tutte le informazioni rilevanti sono riportate. Per ogni record di attivazione indichiamo il nome della routine corrispondente, il link dinamico, e i nomi delle variabili locali. Supponiamo che l’esecuzione su SIMPLESEM raggiunga l’istruzione x = y + t + w + z; contenuta in f2. Il traduttore è in grado di risalire alle variabili x e w utilizzando il loro offset all’interno del record di attivazione più recente (indicato da CURRENT), ma per quanto riguarda y, t e z? Poiché C4’’ supporta la regola dello scoping statico, y e z devono essere associate alle rispettive variabili dichiarate globalmente, mentre t si riferisce alla variabile dichiarata all’interno di f1. Un modo per risalire alle variabili non locali è quello di inserire in ogni record di attivazione di un’unità un puntatore (link statico) al record di attivazione dell’unità che la racchiude staticamente nel testo del programma. Useremo la locazione ad offset due in ogni record di attivazione per memorizzare il link statico. La situazione della memoria D di SIMPLESEM cambia, quindi, nel seguente modo: D ambiente globale main link statici x, y, z z, t f1 t, u f2 x, w f3 y, w, t f2 x, w link dinamici CURRENT La sequenza di link statici che si ottiene partendo dal record di attivazione corrente prende il nome di catena statica. Per trovare quindi il corretto binding tra una variabile e la sua effettiva rappresentazione in memoria (in regime di scoping statico) è necessario percorrere la catena statica finché non si trova il nome della variabile cercata. C’è da notare che in base a questo schema, l’ambiente globale è trattato alla stregua in un qualsiasi altro ambiente locale. In questo caso se si vuole mantenere la convenzione di utilizzare l’offset 2 per la memorizzazione del link statico, le locazioni ad offset 0 e 1 del record di attivazione di main non vanno utilizzate. In pratica effettuare la ricerca delle variabili lungo l’intera catena statica costa molto tempo e non è mai necessario. Una soluzione più efficiente è basata sul fatto che il record di attivazione contenente una variabile utilizzata in un’unità U è sempre ad una distanza fissata dal record di attivazione di U lungo la catena statica. Tale distanza può essere definita come il minimo numero di archi che bisogna attraversare partendo da U per arrivare all’unità in cui la variabile è definita nel SNT. Quindi, se la variabile è locale ad U la distanza è zero. L’attributo distanza associato ad ogni riferimento ad una variabile può essere calcolato a tempo di traduzione. Di conseguenza, ogni riferimento ad una variabile può essere associato staticamente ad una coppia < distanza, offset > all’interno del record di attivazione. Sulla base di questa coppia è possibile definire il seguente schema di indirizzamento per SIMPLESEM. Se d è il valore della distanza, a partire dall’indirizzo base del record di attivazione corrente (contenuto in CURRENT) si attraversano d link lungo la catena statica. Il valore dell’offset è quindi aggiunto all’indirizzo trovato e il risultato rappresenta l’indirizzo a tempo di esecuzione dell’oggetto non locale desiderato. Possiamo definire questo schema in maniera formale attraverso 14 una funzione ricorsiva fp(d) che può essere facilmente implementata in SIMPLESEM. La funzione fp(d), che sta per frame pointer, definisce un puntatore ad un record di attivazione che si trova lontano d link statici dal record di attivazione corrente. Può essere definita come: fp(d) = if d = 0 then D[0] else D[fp(d – 1) + 2]. L’accesso ad una variabile x la cui coppia < distanza, offset > è data da < d, o > si ottiene valutando la seguente espressione: D[fp(d) + o]. Quanto detto per le variabili può essere naturalmente esteso anche alle routine. Possiamo quindi definire il concetto di distanza tra la chiamata di una routine e la sua dichiarazione. Così, se f chiama una routine locale, la distanza è zero; se f chiama una routine dichiarata nel blocco che la contiene (come nel caso della ricorsione), la distanza è uno; e così via. Sulla base di queste osservazioni, dobbiamo modificare la semantica della chiamata di una routine come mostrato in seguito (le modifiche sono evidenziate): Chiamata di routine: set 1, D[1] + 1 alloca spazio sulla pila per il valore ritornato (nel caso in cui la routine chiamata restituisca un valore che occupi una cella di memoria); setta il valore del return pointer nel record di attivazione del chiamato (5 è il set D[1], ip + 5 numero di istruzioni ancora necessarie per implementare la chiamata); set D[1] + 1, D[0] setta il link dinamico nel record di attivazione del chiamato al valore dell’indirizzo base del record di attivazione del chiamante; set D[1] + 2, fp(d) setta il link statico (nel caso in cui la distanza tra chiamata e dichiarazione sia uguale a d); set 0, D[1] pone CURRENT uguale all’indirizzo base del record di attivazione della routine chiamata; set 1, D[1] + AR aggiorna FREE (qui AR sta ad indicare la dimensione del record di attivazione della routine chiamata); jump start_addr start_addr rappresenta l’indirizzo nella memoria C della prima istruzione della routine chiamata. 4.2.5 Il linguaggio C5 Finora abbiamo assunto che le richieste di memoria di ogni unità sono note a tempo di traduzione cosicché le dimensioni di ogni record di attivazione sono note a priori. Inoltre, come abbiamo visto, il binding delle variabili possa essere effettuato staticamente. Illustreremo ora, attraverso la classe C5, il comportamento dei linguaggi dinamici. Il primo aspetto che consideriamo è quello in cui le dimensioni del record di attivazione diventano note solo a tempo di esecuzione. Ciò avviene quando nell’unità sono dichiarati degli array dinamici, le cui dimensioni sono espresse in termini di variabili del programma i cui valori quindi diventano noti solo durante l’esecuzione. Ad esempio, il linguaggio ADA fornisce la possibilità di definire il tipo type VECTOR is array (INTEGER range <>); 15 e quindi di dichiarare la variabile A : VECTOR(N..M); a patto che N e M abbiano valori interi e N ≤ M nel momento in cui la dichiarazione di A è processata a tempo di esecuzione. L’implementazione di questa nuova potenzialità è molto semplice. A tempo di traduzione si riserva lo spazio necessario per il descrittore dell’array dinamico nel record di attivazione. Il descrittore include una locazione per memorizzare il puntatore allo spazio di memoria riservato all’array e due locazioni per memorizzare i limiti superiore e inferiore degli indici dell’array. Poiché il numero di dimensioni dell’array (in questo caso una) è noto, le dimensioni del descrittore sono note a tempo di traduzione. Tutti gli accessi all’array dinamico sono tradotti attraverso un riferimento indiretto ottenuto utilizzando il puntatore nel descrittore e un offset determinabile staticamente. A tempo di esecuzione il record di attivazione è allocato in passi successivi: 1. si alloca spazio per i dati le cui dimensioni sono note staticamente e per i descrittori degli array dinamici; 2. quando si incontra la dichiarazione di un array dinamico, se ne valutano le dimensioni e quindi il record di attivazione è esteso opportunamente (incrementando FREE). Questa espansione è possibile poiché tale record di attivazione si trova in cima alla pila. 3. Il puntatore nel descrittore dell’array dinamico è settato in modo da puntare all’inizio dell’area aggiuntiva appena allocata. Nell’esempio considerato, supponiamo che il descrittore di A sia allocato a partire dall’offset m. La cella ad offset m conterrà il puntatore all’effettiva area di memoria associata ad A, mentre quelle ad offset m+1 e m+2 conterranno rispettivamente il valore di N e M. All’atto del processamene della dichiarazione di A a tempo di esecuzione, si valutano N e M sulla cui base si aggiorna il descrittore ad offset m+1 e m+2, quindi si associa all’offset m il valore di FREE che viene quindi incrementato di M – N + 1 locazioni estendendo il record di attivazione. Assumendo che l sia una variabile locale memorizzate ad offset s, l’istruzione A[l] = 0; verrà tradotta in SIMPLESEM nel seguente modo: set (D[D[0] + m] + D[D[0] + s]), 0 dove D[D[0] + m] è l’indirizzo base di A e D[D[0] + s] è il valore di l. In tutti i linguaggi considerati fino a questo punto le variabili venivano gestite automaticamente: allocate all’ingresso del loro scope e deallocate all’uscita. In C5 invece si da al programmatore la possibilità di allocare esplicitamente una variabile attraverso un’apposita istruzione. Nel C, ad esempio, possiamo definire il tipo nodo di un albero binario: struct node { int info; node* left; node* right; } La seguente istruzione node* n = new node; 16 alloca esplicitamente una struttura con tre campi info, left, right e la rende accessibile attraverso il puntatore n. Questo tipo di dati non possono essere allocati sulla pila come si fa per le variabili automatiche. Supponiamo, ad esempio, che venga chiamata una funzione append_left che genera un nuovo nodo e lo rende accessibile attraverso il campo left del nodo puntato da n. Supponiamo, inoltre, che n sia visibile da append_left come variabile non locale. Se il nuovo nodo allocato da append_left fosse memorizzato sulla pila, verrebbe perso nel momento in cui append_left ritorna il controllo alla routine chiamante. La semantica dei dati allocati dinamicamente, invece, dice che il loro ciclo di vita non dipende dall’unità in cui avviene la loro allocazione, ma dura fino a quando essi risultano accessibili, cioè, risultano puntati da qualche puntatore ancora attivo. L’implementazione di questo concetto attraverso SIMPLESEM è piuttosto semplice e consiste nell’allocare i dati dinamici a partire dagli indirizzi più alti della memoria D. Questa area di memoria è detta heap. L’organizzazione della memoria D di SIMPLESEM diviene dunque la seguente: D stack Le frecce mostrano la direzione di crescita heap In un linguaggio che usa typing dinamico, il tipo di una variabile e quindi i metodi di accesso e le operazioni applicabili non possono essere determinate a tempo di traduzione. Abbiamo visto come sia necessario tenere un descrittore a tempo di esecuzione per gli array dinamici, dato che le loro dimensioni non sono note staticamente. In questo caso il descrittore deve contenere tutte le informazioni non note a tempo di traduzione, ovvero l’indirizzo di partenza dell’array e i suoi limiti. E’ comunque possibile tenere tale descrittore nel record di attivazione in quanto le sue dimensioni sono note a tempo di traduzione. Nel caso di variabili dinamiche, dobbiamo mantenere nel descrittore anche il tipo della variabile. Se il tipo di una variabile può cambiare a tempo di esecuzione, allora anche le dimensioni e il contenuto del proprio descrittore possono cambiare. Ad esempio, se una variabile cambia da un array unidimensionale ad un array bidimensionale, allora il descrittore deve espandersi per contenere i limiti della nuova dimensione. Ogni accesso ad una variabile dinamica deve essere preceduto da un controllo a tempo di esecuzione sul tipo della variabile, seguito da un opportuno calcolo dell’indirizzo sulla base del tipo attuale della variabile. Come cambia, quindi, la gestione delle variabili nei record di attivazione? Poiché non solo le dimensioni della variabile, ma anche quelle del suo descrittore possono cambiare durante l’esecuzione del programma, i descrittori vanno mantenuti nello heap. Per ogni variabile esisterà nel record di attivazione un puntatore al suo descrittore nello heap, il quale, a sua volta, conterrà un puntatore all’oggetto vero e proprio, sempre nello heap. 17 Per illustrare, invece, la gestione dello scoping dinamico si serviamo del seguente esempio scritto nel linguaggio APL: sub2() { declare x; … …x…; …y…; … } sub1() { declare y; … …x…; …z…; sub2(); … } main() { declare x,y,z; z = 0; x = 5; y = 7; sub1(); sub2(); … } Una dichiarazione di variabile introduce semplicemente un nome senza specificarne il tipo. Poiché lo scoping è dinamico, il tipo di una variabile dipenderà dalla catena dinamica e non da quella statica. Consideriamo la chiamata a sub1 in main. I riferimenti non locali a x e z all’interno di sub1 risultano relativi alle variabili globali x e z definiti in main. Quando sub2 è chiamata da sub1, il riferimento a y risulta relativo alla variabile y definita in sub1. Quando però sub2 restituisce il controllo a sub1 e questa, a sua volta, lo restituisce a main, si ha la deallocazione del record di attivazione di sub1. A questo punto, dunque, nel momento in cui main chiama sub2, la variabile y all’interno di sub2 risulta associata a quella globale definita nel main. Questo meccanismo può essere facilmente implementato utilizzando i link dinamici e la memorizzazione delle variabili dinamiche nello heap. Illustriamo la situazione della memoria D di SIMPLESEM al momento della chiamata di sub2 da parte di sub1. 18 D z main x y link dinamico sub1 y link dinamico sub2 x HEAP 4.2.6 Il passaggio dei parametri In questo paragrafo rimuoviamo l’assunzione fatta fino a questo punto che le routine non possano avere parametri. Per prima cosa considereremo il caso in cui i parametri di una routine siano semplici dati, per poi contemplare quello più generico in cui possano essere a loro volte delle routine. Ci sono diversi modi di passare dati alle routine. Essi possono essere predefiniti nel linguaggio oppure stabiliti opportunamente dal programmatore. In entrambi i casi è opportuno sapere che a scelte diverse corrispondono, di solito, risultati diversi. Considereremo le tre convenzioni fondamentali per il passaggio di tali parametri: chiamata per riferimento, chiamata per copia e chiamata per nome. Chiamata per riferimento (call by reference o call by sharing): l’unità chiamante passa all’unità chiamata l’indirizzo del parametro attuale. Qualsiasi riferimento al parametro formale nell’unità chiamata è trattato come un riferimento alla locazione di memoria il cui indirizzo è oggetto del passaggio. Quindi, una variabile trasmessa come parametro attuale risulta condivisa da entrambe le unità: ogni modifica effettuata sul parametro formale si ripercuote definitivamente su quello attuale. Se il parametro attuale non è una variabile, ad esempio è un’espressione o una costante, l’unità chiamata riceve l’indirizzo di una locazione temporanea all’interno del record di attivazione del chiamante che contiene il valore del parametro attuale. Com’è facile intuire, questa è una situazione piuttosto estrema: non a caso alcuni linguaggi la trattano come una situazione di errore. Vediamo ora come l’aggiunta della chiamata per riferimento in C4 può essere implementata in SIMPLESEM. Il record di attivazione dell’unità chiamata deve contenere una cella per ogni parametro. Al momento della chiamata, il chiamante deve inizializzare il contenuto della cella in modo che contenga il parametro attuale corrispondente. Se tale cella è ad offset off e il parametro 19 attuale, che risulta associato alla coppia < d, o > non è a sua volta un parametro passato per riferimento, il chiamante effettuerà la seguente operazione: set D[0] + off, fp(d) + o; Se, invece, il parametro attuale è un parametro passato per riferimento, l’azione diventa: set D[0] + off, D[fp(d) + o]; Quando il corpo della routine è eseguito, l’accesso al parametro è effettuato via indirizzamento indiretto. Se dunque x è un parametro formale e il suo offset è off, l’istruzione x = 0; è tradotta come set D[D[0] + off], 0; Chiamata per copia (call by copy): i parametri formali non condividono la loro area di memoria con i parametri attuali; essi si comportano piuttosto come variabili locali. La chiamata per copia protegge quindi l’unità chiamante da modifiche più o meno intenzionali sui parametri attuali. La chiamata per copia può essere ulteriormente classificata in tre sottoschemi sulla base del modo in cui le variabili locali corrispondenti ai parametri formali sono inizializzati o del modo in cui i loro valori finali si ripercuotono sui parametri attuali. Distinguiamo quindi tra chiamata per valore, per risultato e per valore-risultato. • • • Nella chiamata per valore, il chiamante valuta i parametri attuali e li usa per inizializzare i corrispondenti parametri formali. La chiamata per valore non consente che alcun flusso di informazione torni indietro al chiamante dato che qualsiasi modifica dei parametri formali non interessano l’unità chiamante. Nella chiamata per risultato, i parametri formali non sono inizializzati al momento della chiamata, ma il loro valore viene copiato nella locazione del parametro attuale corrispondente quando l’unità chiamata termina. La chiamata per risultato non consente che alcun flusso di informazione passi dal chiamante al chiamato. Nella chiamata per valore-risultato, i parametri formali sono inizializzati al momento della chiamata (come per la chiamata per valore) e quindi copiati alla terminazione in quelli attuali (come per la chiamata per risultato). Il flusso di informazione, quindi, va dal chiamante al chiamato e viceversa. La descrizione della semantica della chiamata per copia in termini di operazioni su SIMPLESEM è molto semplice: basta riservare spazio per i parametri formali nel record di attivazione del chiamato come fossero normali variabili locali e quindi gestire opportunamente il flusso di informazione tra chiamante e chiamato. Ci si può chiedere se la chiamata per riferimento e quella per valore-risultato siano equivalenti. Si può mostrare come ciò non avvenga nei seguenti due casi: 1. Due parametri formali diventano aliases. 2. Un parametro formale e una variabile non locale visibile sia dal chiamato che dal chiamante diventano aliases. 20 Mostriamo due esempi per motivare queste affermazioni. Consideriamo due parametri attuali interi a[i] e a[j] corrispondenti ai parametri formali x e y e supponiamo che i = j al momento della chiamata. In questo caso l’effetto della chiamata per riferimento è che x e y diventano aliases, visto che si riferiscono allo stesso elemento. Se la routine chiamata contiene le istruzioni x = 0; y++; il risultato della chiamata è che l’elemento di indice i (e quindi j) dell’array è posto a 1. Nel caso di chiamata per valore-risultato, supponiamo che a[i] = 10 al momento della chiamata. La chiamata inizializza x e y al valore 10. Dopo di che x diventa 0 e y diventa 11. Infine, al momento della terminazione, 0 è copiato in a[i] e 11 è copiato in a[j], cioè di nuovo in a[i]. Il risultato dei due tipi di passaggi di parametri è quindi diverso. Come esempio del secondo caso, supponiamo di avere una routine con parametro formale intero x. Supponiamo di chiamare la routine con parametro attuale a (una variabile non locale visibile dalla routine) e che la routine contenga le istruzioni a = 1; x = x + a; Nel caso di chiamata per riferimento, l’effetto è che a è posta a 2. Nel caso di chiamata per valorerisultato, se supponiamo che a valga 10 al momento della chiamata, il risultato è che a è posta a 11. Chiamata per nome (call by name): ogni occorrenza del parametro formale viene sostituita da un’occorrenza del parametro attuale corrispondente. Questo implica che, come nella chiamata per riferimento, un parametro formale denota una locazione di memoria nel record di attivazione del chiamante. A differenza della chiamata per riferimento, però, il parametro formale non è associato ad una locazione una volta per tutte all’atto della chiamata, ma tale associazione viene ricalcolata ogni volta che il parametro formale viene utilizzato. L’utilizzo della chiamata per nome può condurre ad un certo numero di situazioni indesiderate come mostrato nei seguenti esempi. swap(int a, b) { int temp; temp = a; a = b; b = temp; } L’effetto della chiamata swap(i,a[i]) sarà quello di generare la seguente sequenza di istruzioni: temp = i; i = a[i]; a[i] = temp; Se i = 3 e a[3] = 4 prima della chiamata, otteniamo i = 4 e a[4] = 3, cioè a[3] non viene modificato. Un altro caso si verifica quando c’è un’omonimia tra un parametro attuale e una variabile non locale come mostrato dal seguente esempio. Supponiamo di voler aggiungere alla routine swap una variabile che conti il numero di volte che swap viene chiamata e che essa sia inclusa nel seguente frammento di programma: 21 int c; //variabile globale … swap(int a, b) { int temp; temp = a; a = b; b = temp; c++; } y() { int c,d; swap(c,d); } Quando swap è chiamata da y, si generano le seguenti istruzioni: temp = c; c = d; d = temp; c++; C’è però differenza tra la variabile c contenuta nell’ultima istruzione e tutte le altre. Questo vuol dire che effettuare una semplice sostituzione nominale non costituisce una corretta implementazione della chiamata per nome. La chiamata per nome, quindi, può dar vita a programmi che risultano difficili da leggere e risulta, sorprendentemente, anche piuttosto difficile da implementare. A causa di queste difficoltà, la chiamata per nome assume più che altro valore storico e teorico ed è stata abbandonata dai principali linguaggi di programmazione. Per quanto riguarda il passaggio di routine come parametri, daremo soltanto un piccolo accenno alle problematiche che possono sorgere in questo caso. Consideriamo il seguente esempio: int u,v; a() { int y; … } b(routine x) { int u,v,y; c() { y = …; } x(); b(c); } main() { b(a); } La routine b è chiamata in main con parametro attuale a; la chiamata a x all’interno di b corrisponderà quindi a una chiamata ad a. Quando a è chiamata, dovrebbe essere in grado di poter eseguire normalmente proprio come se fosse stata chiamata direttamente. In particolare, a deve 22 essere in grado di accedere al proprio ambiente non locale, in questo caso le variabili globali u e v. Purtroppo, però, queste variabili non sono visibili in b poiché mascherate dalle variabili locali di b aventi lo stesso nome. Ne consegue che la nostra implementazione delle chiamate di routine non è valida in questo caso, ma si richiedono tecniche più sofisticate il cui studio non costituirà oggetto della nostra attenzione. 23