Chiamata di funzione e gestione dello stack di attivazione

Transcript

Chiamata di funzione e gestione dello stack di attivazione
Chiamata di funzione e gestione dello
stack di attivazione
Dario Tamascelli
3 novembre 2009
Le funzioni costituiscono un elemento fondamentale del linguaggio di
programmazione C e C++. L’utilità delle funzioni è molteplice: esse facilitano la scrittura, la lettura, il mantenimento e la riusabilità del codice.
In questo approfondimento impareremo a dichiarare, definire e usare una
funzione. Allo scopo di chiarire il funzionamento del passaggio di informazioni dalla funzione chiamante a quella chiamata, vedremo anche un abbozzo della gestione dello stack di attivazione delle funzioni, una struttura
dati usata dal calcolatore per gestire il meccanismo delle funzioni.
1 Disclaimer
Questa dispensa vuole fornire uno strumento per la comprensione del funzionamento
delle funzioni in C++. Dove necessario, quindi, abbiamo voluto barattare precisione in
cambio di una maggiore chiarezza espositiva. Il lettore tecnicamente preparato voglia
quindi scusare l’inesattezza di taluni passaggi, di cui il solo responsabile è l’autore.
Per qualsiasi informazione o chiarimento contattate: [email protected].
2 Il concetto di funzione in C++
Tutti voi avete già incontrato un’espressione di questo tipo:
g :R×N→R
g(x, y) = x + y 2 .
Il significato delle due linee qui sopra è il seguente: la funzione g prende un numero
reale e un naturale e restituisce un numero reale; in particolare g agisce su due generici
elementi x ed y, appartenti rispettivamente a R e N, sommando il primo al quadrato
del secondo.
Nella prima riga, quindi, dichiariamo il nome, il dominio e il codominio della funzione;
1
g
(x,y)
g(x,y)
Figura 1: Funzione come rapporto ingresso uscita.
nella seconda parte definiamo come la funzione trasformi un elemento del suo dominio
in un elemento del suo codominio. Una funzione, in estrema sintesi, è una relazione di
ingresso uscita: in ingresso vengono passati dei valori, questi vengono elaborati, ed in
uscita troviamo il risultato dell’elaborazione.
Consideriamo ora il seguente, semplicissimo, programma per il calcolo della media di
5 numeri:
#include <iostream>
#include <cmath>
using namespace std;
int main(){
int vettore = {1,2,3,4,5}; //Dichiaro un vettore di lunghezza 5 e lo inizializzo.
int dimensione =5;
float mean;
// Inizio calcolo della media
int accu=0;
for (int i=0; i< dimensione; i++)
accu +=vettore[i];
mean = (float)accu/dimensione;
//fine calcolo della media
cout << endl <<"Media del vettore: " << mean << endl;
return 0;
}
2
Il calcolo della media viene fatto nella porzione di codice indicata. Quale è il rapporto
ingresso/uscita di quel blocco di codice? In ingresso prendo un vettore contenente
i dati e la sua dimensione; in uscita trovo la media del vettore memorizzata nella
variabile mean.
Volendo esprimere il suddetto blocco di codice come funzione possiamo cominciare a
dire che:
calcola_media : vettore × intero → razionale
dove calcola_media è il nome della funzione.
Volendo essere più precisi nella definizione del dominio e codominio, calcola_media
prende in ingresso un vettore di 5 interi, int[5] , (vettore) e un numero intero,
int, che indica il numero dei dati (dimensione) e restituisce un razionale, float,
corrispondente la media dei dati; la relazione ingresso uscita è realizzata attraverso la
sequenza di istruzioni evidenziate nel codice qui sopra. Possiamo quindi scrivere:
calcola_media : int[5] × int → f loat
calcola_media(vett,dim) = {
int appo=0;
for (int i=0; i< dim; i++)
appo +=vett[i];
return ((float)appo/dim);
}
Osserviamo che nella parte di dichiarazione di dominio e codominio abbiamo indicato
solo il tipo dei dati di ingresso e uscita. Nella parte in cui abbiamo definito come la
funzione calcoli la media, abbiamo indicato con vett e dim i nomi delle variabili di
ingresso. La scelta dei nomi delle variabili che usiamo nella definizione di una funzione
è arbitraria (riflettete: la funzione definita da f (x, y) = x + y 2 è analoga alla funzione
f (giovanni, antonio) = giovanni + antonio2 ).
Per finire, l’istruzione return non fa altro che segnalare quale grandezza deve essere
restituita in usicta dalla funzione.
Dichiarata la funzione calcola_media e definito come questa lavori sul suo input per
produrre l’output, potremmo riscrivere il programma come:
3
#include <iostream>
#include <cmath>
using namespace std;
int main(){
int vettore = {1,2,3,4,5}; //Dichiaro un vettore di lunghezza 5 e lo inizializzo.
int dimensione =5;
float mean;
mean = calcola_media(vettore,dimensione);
cout << endl <<"Media del vettore: " << mean << endl;
return 0;
}
L’istruzione mean = calcola_media(vettore,dimensione); ha il seguente significato: valuta la funzione calcola_media sui valori attualmente “contenuti” nelle variabili
vettore e dimensione; assegna il valore restituito dalla funzione (((float)appo/dim))
alla variabile mean. Usando ancora l’analogia con il linguaggio matematico, l’istruzione
in esame è analoga alla scrittura y = f (x).
Osserviamo che all’interno della descrizione della funzione, cioè delle operazioni che
trasformano l’input in output, abbiamo usato delle variabili ausiliarie o di appoggio,
int appo e int i, che esauriscono la loro funzione con il terminare della funzione.
3 Sintassi e terminologia
Il codice prodotto fino ad ora è un’ottima approssimazione del codice che useremmo
in C + + per dichiarare e definire la funzione calcola_media. Quello che segue è un
programma compilabile in C++.
#include <iostream>
#include <cmath>
using namespace std;
/*===================================================*/
/*DICHIARAZIONE DELLA FUNZIONE*/
/*===================================================*/
float calcola_media(int [], int);
4
/*===================================================*/
int main(){
int vettore = {1,2,3,4,5}; //Dichiaro un vettore di lunghezza 5 e lo inizializzo.
int dimensione =5;
float mean;
mean = calcola_media(vettore,dimensione);
cout << endl <<"Media del vettore: " << mean << endl;
return 0;
}
/*===================================================*/
/*DEFINIZIONES DELLA FUNZIONE*/
/*===================================================*/
float calcola_media(int vett[], int dim) {
int appo=0;
for (int i=0; i< dim; i++)
appo +=vett[i];
return ((float)appo/dim);
}
/*===================================================*/
La riga:
float calcola_media(int [], int);
è la dichiarazione della funzione calcola_media. In questa parte del codice diamo
un nome univoco alla funzione, ovvero definiamo f : D → C.1 In accordo con quanto
visto in altri corsi, cambiare anche uno solo degli elementi, nome, dominio, codomino,
cambia la funzione.
Notiamo che il codominio appare in posizione insolita: esso viene scritto sulla sinistra.
Questa “stranezza” trova la sua ragione nella lettura dell’operazione di assegnamento,
ma questa è un’altra storia. . . . Qui limitiamoci a rilevarla e impariamo ad attenerci
1 La
dichiarazione di una funzione viene anche detta, in informatichese stretto, prototipo della
funzione.
5
alla regola.
La dichiarazione di una funzione va sempre messa prima della definizione di qualsiasi
funzione che la chiami. La sua definizione, invece, può essere messa indifferentemente
prima o dopo il main. Noi adottiamo la convenzione di mettere tutte le dichiarazioni
prima del main e tutte le definizioni delle funzioni dopo la funzione main (o in un file
separato... ma questo, per ora, non fatelo!).
Ma torniamo al nostro semplice programma.
D’ora in poi ci riferiremo alla funzione main (ebbene sı̀: è anch’essa una funzione!),
che a un certo punto chiama la funzione calcola_media, come funzione chiamante,
mentre calcola_media sarà la funzione chiamata.
Come già anticipato, se nella dichiarazione della funzione calcola_media ci limitiamo
a dare i tipi dei dati che passeremo al momento di chiamare la funzione, nella sua
definizione gli diamo anche dei nomi (vett e dim). vett e dim sono i parametri della
funzione. I parametri sono delle variabili normalissime che vengono però inizializzate quando la funzione viene chiamata: nel nostro esempio quando il main chiama la
funzione calcola_media i parametri vett e dim vengono inizializzati con i valori correnti di vettore (... qualsiasi sia il valore corrente di vettore....) e dimensione. Da
quel momento in poi vett e dim possono essere usati come normali variabili, di tipo
int [5] e int rispettivamente, senza che questo abbia effetti sulle variabili vettore e
dimensione della funzione chiamante. I parametri di una funzione sono detti parametri formali. I valori che vengono passati alla funzione quando questa viene chiamata
si dicono parametri attuali. Per esempio: dimensione è un parametro formale, 5 è il
parametro attuale, cioè il valore passato effettivamente alla funzione.
Le variabili int appo e int i dichiarate nel corpo della funzione sono invece variabili
locali della funzione. La “vita” dei parametri e delle variabili locali di una funzione
inizia quando la funzione viene chiamata e finisce quando la funzione termina. Nella
sezione successiva cercheremo di capire cosa succede quando invochiamo una funzione
e la “biologia” delle variabili diventerà più chiara.
Il meccanismo del passaggio di parametri consente una comunicazione sicura tra le funzioni chiamanti e quelle chiamate. Infatti, le variabili locali delle diverse funzioni non
sono visibili tra di loro. Questo significa che il programmatore non può accidentalmente
modificare in una funzione una variabile locale di un’altra funzione.2
4 Lo stack di attivazione di funzione
Per chiarire il meccanismo di scoping (visibilità) delle variabili di una funzione, può
essere utile introdurre lo stack di attivazione di funzione.
Un programma è essenzialmente una sequenza di istruzioni che modificano delle informazioni, per esempio trasformando l’informazione in ingresso nell’output richiesto. Le
informazioni, come abbiamo sempre detto, sono contenute nelle variabili.
2 Vedremo
a breve che una funzione può modificare le variabili di altre funzioni attraverso l’uso dei
puntatori. Tuttavia questo meccanismo è esplicito e come tale deve essere gestito completamente
dal programmatore.
6
Parametri
calcola_media
vett
Istruzioni
for(int i=0;...)
appo+=vett[i];
return ((float) accu/n);
n
Variabili
appo
0
Punto di ritorno
Figura 2: Esempio di record di attivazione.
Le funzioni svolgono il ruolo di macroistruzioni (o microprogrammi): esse realizzano
un certo rapporto ingresso uscita. Esse, quindi, contengono delle variabili che vengono
modificate dalle istruzioni per produrre l’uscita desiderata.
Una funzione vive il suo ciclo di vita in un ambiente di esecuzione chiamato comunemente record di attivazione. Un record di attivazione è un contenitore di informazione
atto a rappresentare ciò di cui una funzione consiste: ingresso, variabili, istuzioni,
uscita. Essendo l’informazione in esso contenuto eterogenea, il record di attivazione è
una struttura. Un esempio di record di attivazione è fornito in figura 2.
Il record di attivazione di una funzione può essere visto come la biosfera in cui la
funzione vive. Nel record c’è tutto quello di cui la funzione ha bisogno: informazioni
e istruzioni. Ci sono anche delle informazioni che servono quando la funzione termina: il return point, per esempio, indica al programma quale istruzione della funzione
chiamante deve essere eseguita quando la funzione chiamata termina. Se la funzione
chiamata restutuisce qualche valore, questo valore viene scritto al posto della funzione
chiamata nell’istruzione indicata nel return point.
Vediamo come i record di attivazione vengono usati per gestire la chiamata, esecuzione
e terminazione delle funzioni.
La struttura dati (che ricordiamo essere la coppia (contenitore di informazioe + operazioni) fondamentale è lo stack di attivazione di funzione. Uno stack (o pila) è una
struttura del tipo LIFO (last in first out): l’ultimo elemento inserito nello stack è
anche il primo a essere rimosso (pensate a una pila di piatti: a parte qualche virtuoso,
nessuno prende il piatto dal fondo della pila!).
7
Parametri
Istruzioni
calcola_media
vett
n
Variabili
appo
0
Punto di ritorno
return (accu/n);
Istruzioni Main
conta
...
...
dim
...
media
media=mean(dati, dim);
cout << “Media: “ << media << endl ;
dati
Punto di ritorno
return 0;
Terminale
Figura 3: Stack di attivazione dopo la chiamata di main e di calcola media.
Quando il programma parte lo stack è vuoto. Viene subito chiamata la funzione main.
Il record della funzione main, con tutte le informazioni del caso, viene creato e messo
in cima alla pila (per ora vuota). L’operazione di aggiunta di un elemento si indica con
push. A questo punto le istruzioni della funzione main cominciano ad essere eseguite,
8
come abbiamo sempre visto. Ad un certo punto vediamo l’istruzione
mean = calcola_media(vettore,dimensione);
Viene quindi creato un record di attivazione per la funzione calcola_media, questo viene messo in cima allo stack (altra push) e il controllo passa alla funzione
calcola_media (vedi figura 3). Quando arriviamo all’istruzione return ((float)accu/dim);
la funzione calcola_media termina restituendo il valore calcolato. Al momento dell’attivazione del record di calcola_media, l’istruzione a cui tornare una volta terminata la
funzione calcola_media era stato salvato nel campo return point del record di attivazione di calcola_media. Quando la funzione termina, quindi, il valore restituito dalla
funzione viene messo al posto della funzione nel programma chiamante e il controllo
passa alla funzione main. La prima istruzione eseguita è quella referenziata dal return
point. Questa volta al posto della funzione troviamo però il valore da essa calcolato.
Prima della ripresa dell’esecuzione delle istruzioni del main il record di calcola_media
viene tolto dalla cima dello stack; l’operazione di rimozione di un elemento dalla pila
si chiama pop. Fatto il pop dalla pila l’esecuzione riprende normalmente.
Osservazione: lo stack vuoto, cioè quello che troviamo prima dell’attivazione del main
e dopo la sua conclusione non è privo di informazioni; esso infatti contiene alcune
informazioni di sistema e le variabili globali. In questo corso evitiamo l’uso di variabili
globali, ovvero di variabili visibili da tutte le funzioni del programma: la loro gestione
può infatti risultare insidiosa per un programmatore inesperto. Per quanto ci compete,
quindi, lo stack di attivazione è vuoto!
5 Passaggio di parametri: l’eccezione dei vettori.
Abbiamo detto che al momento della chiamata di una funzione ai parametri formali
vengono assegnati i valori dei parametri attuali e che questo consente di passare informazioni tra funzioni. Per esempio al parametro formale dim della nostra funzione
calcola_media viene passato il valore 5. E per il parametro vett cosa succede? Nel
record di attivazione di calcola_media c‘è un vettore di lunghezza opportuna? E di
che lunghezza (visto che questa non compare né nella dichiarazione né nella definizione della funzione!)? E i valori vengono tutti copiati nell’eventuale vettore locale di
calcola_media? NO!!!
Per capire esattamente cosa succede è necessario rivelare una dura verità : un vettore non è quello che sembra! Per verificarlo basta aggiungere al codice del nostro
programma la seguente istruzione:
cout << "\n Il vettore vale : << vettore << "! \n";
Il numero che viene stampato a video è sicuramente un valore intero e rappresenta
l’indirizzo di menoria in cui è memorizzato il primo elemento del vettore. Una variabile vettore è quindi un indirizzo di memoria!
Nell’invitarvi cordialmente a evitare crisi di panico, vi preannuncio che dedicheremo
9
all’argomento un tempo congruo. Per il momento, tuttavia, analizziamo solamente le
conseguenze di questo fatto.
Nella funzione main dichiariamo un vettore di 5 elementi. Quando la funzione viene chiamata il vettore viene creato in blocco in memoria e l’indirizzo del suo primo
elemento viene memorizzato nella variabile vettore. L’accesso ai singoli membri del
vettore è gestito dal programma. Quando scriviamo vettore[5] il programma sa come trovare l’informazione richiesta a partire dall’indirizzo contenuto in vettore e dal
fatto che vogliamo il sesto elemento. Fin qui, quindi, tutto come prima.
La scrittura int [],... che troviamo tra i parametri di calcola_media significa che
alla funzione passeremo l’indirizzo del primo elemento di un vettore di interi. Nella definizione della funzione scriviamo int vett[],... per dire che ci riferiremo a
quell’indirizzo con il nome di vett. Quando chiamiamo la funzione;
mean = calcola_media(vettore, dimensione);
al parametro formale vett viene passato l’indirizzo del primo elemento del vettore di
interi vettore del main. Da quel momento in poi, quando all’interno della funzione
calcola_media ci riferiremo a vett ci staremo in realtà riferendo al vettore del main.
Quindi tutte le modifiche fatte a vett sono in realtà modifiche fatte al vettore del
main (calcola_media non modifica nulla, ma è un caso).
6 Conclusioni
Le funzioni semplificano la scrittura, la lettura, il mantenimento e la riusabilità del
codice. Abbiamo visto che da un punto di vista sintattico la dichiarazione e definizione
delle funzioni è molto simile a quanto fatto per anni in analisi e come nell’analisi la
dichiarazione di una funzione, cioè il momento in cui definiamo il “nome proprio” della
funzione, deve anticipare il momento in cui la definiamo.
Per questioni legate al processo di compilazione una funzione, diciamola A usata da
un’altra funzione, per esempio B, deve essere dicharata prima che B sia definita. Onde
evitare problemi di visibilità , e per consentire un agile uso delle librerie di funzioni,
noi adotteremo la convenzione di dichiarare tutte le funzioni prima della funzione main
e di definirle dopo il main.
L’esecuzione di un programma che usa delle funzioni si avvale della struttura dati stack
di attivazione, in termini del quale le regole di visibilità delle variabili e il meccanismo
di passaggio per valore dei parametri diventa piuttosto chiaro.
Qualche considerazione a parte meritano i vettori, che costituiranno il punto di partenza del prossimo capitolo di questa dispensa...
10