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