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