Variabili, riferimenti e puntatori in C++ Riferimenti e puntatori Una variabile è individuata da tre componenti: un nome, una locazione o indirizzo, ed un tipo. Ad esempio la dichiarazione int x; produce qualcosa che potremmo rappresentare graficamente con: 0x0064fddc x int Il nome è una stringa alfanumerica con la funzione di identificatore; la locazione è un indirizzo di memoria (in figura rappresentato in alto in esadecimale) e indica il punto di inizio di una porzione di memoria riservata ai valori che la variabile assume durante il calcolo; l’ampiezza di questa porzione, rappresentata in figura con un rettangolo, dipende dal tipo (predefinita per i tipi di base: 8 bit = 1 byte per il tipo char, 16 per int ecc.; calcolata per i tipi definiti dall’utente). L’uso principale delle variabili è nelle istruzioni di assegnamento che hanno la forma: L-value = R-value; dove L-value (R-value) significa: “espressione che può occorre a sinistra (destra) di un’ assegnazione”1. Una variabile è dunque sia un L-value che un R-value, ma nei due casi viene usata in modo diverso, tanto che potremmo affermare che ha un significato diverso. Nell’assegnazione: x = x + 7; la variabile x a destra di = sta per il suo valore, che infatti bisogna sia stato precedentemente definito (anche se il C++ non controlla che ciò sia effettivamente avvenuto); a sinistra invece indica la propria locazione di memoria. Questo diverso significato rende eseguibile il comando: l’espressione a destra deve infatti restituire un valore, mentre quella a sinistra deve dare una locazione di memoria in cui il valore della parte destra possa essere memorizzato. Già il C, e quindi anche il C++, consente di ottenere la locazione di una variabile, invece del suo valore, usando l’operatore &: se x è come nella figura in alto, allora &x è l’indirizzo 0x0064fdcc. Nel caso del C++ l’operatore & si può anche applicare al tipo, dichiarando dei riferimenti. Un riferimento è un alias, cioè una variabile che non possiede una propria locazione di memoria, ma che condivide quella di un’altra precedentemente dichiarata. Ad esempio: int x; int& rx = x; // ECCEZIONE : qui x non è considerato R-value 1 In letteratura si parla di L-value per tutte quelle espressioni che sono associate ad una locazione di memoria, scambiando dunque la funzione con il metodo per assolverla. Secondo questa accezione più ampia, anche const int n = 0; sarebbe un L-value perché memorizzato proprio come int n = 0; solo non modificabile, e dunque non utilizzabile in un contesto del tipo n = … . Si tratta tuttavia di un uso giustificato solo dalla terminologia di chi si occupa di compilatori, che in un corso di programmazione renderebbe oscura la trattazione: in questa nota L-value ha dunque il significato originario di “ciò che può stare a sinistra di =”. produce la situazione 0x0064fddc rx,x int dove si è aggiunto rx dinanzi ad x per indicare che questi due identificatori sono dei sinonimi per la stessa locazione di memoria. Di conseguenza tutte le assegnazioni sulla variabile rx avranno effetto sulla variabile x: infatti rx = 0; produrrà l’effetto di scrivere 0 alla locazione 0x0064fddc, che è quella associata ad x. Inoltre non sarà possibile ridefinire rx come alias di una diversa variabile, sia perché l’associazione identificatore-locazione non è modificabile in nessun caso, sia perché il significato di int n = 5; rx = n; è quello di assegnare il valore di n, 5 nell’esempio, ad rx, essendo n un R-value nella seconda istruzione. Né potremmo scrivere rx = &n; // ERRORE di tipo perché questo (che sarebbe segnalato come errore di tipo) avrebbe l’effetto di scrivere l’indirizzo di n alla locazione cui rx è associato, ossia ne farebbe il valore di rx, senza alterarne la locazione. Un puntatore è una variabile il cui dominio di valori sono gli indirizzi di memoria. Come ogni altra variabile, un puntatore ha una locazione di memoria di ampiezza pari alla lunghezza di un indirizzo, una costante che dipende dall’architettura della macchina e che dunque è predefinita dal compilatore. Supponiamo di avere dichiarato x come sopra e quindi di scrivere: int* p; p = &x; allora avremmo la situazione rappresentata in figura qui sotto: 0x0064fde0 p 0x0064fddc int* Anche p ha una locazione di memoria (in figura 0x0064fed0), che è fissata per tutto il tempo in cui p sussiste (vedi regole di persistenza e visibilità delle variabili), ma ha anche un valore 0x0064fddc, che nell’esempio è l’indirizzo di x, ma che può essere nuovamente assegnato a quello di un’altra variabile oppure ad una locazione nell’area dinamica della memoria mediante l’operatore new, proprio come accade per il valore di qualunque altra variabile. L’operazione caratteristica dei puntatori è la dereferenziazione, denotata con *. Con essa è possibile accedere in lettura e scrittura alla locazione di memoria che costituisce il valore corrente del puntatore: nell’esempio *p è un’alias per x perché il valore corrente di p è &x. Ne consegue che l’espressione *p è sia un R-value che un L-value, come accade nella porzione di codice qui di seguito: int x = 0; int* p; p = &x; // il tipo è corretto! *p = *p + 1; cout << x ; // produce 1 e non 0 L’esistenza dell’operazione di dereferenziazione motiva la distinzione tra i diversi tipi di puntatori. Dal punto di vista della dimensione della locazione di memoria associata ad un puntatore non occorrono altre specificazioni se non quella relativa all’ampiezza degli indirizzi, che però è costante sulla stessa architettura. Ma quanta memoria deve essere considerata a partire dall’indirizzo p? Se p punta ad un intero, ossia ha tipo int*, allora 16 bit (posto che questa sia la dimensione di un int), ma se p avesse tipo char* i bit sarebbero 8; inoltre se p punta ad un intero allora i 16 bit devono essere interpretati secondo la codifica degli interi (di solito in complemento a 2), mentre se p punta ad un carattere, allora si userà il codice ADSCII per interpretarne il significato. Riassumendo: la differenza tra riferimenti e puntatori consiste nel fatto che i primi sono solo degli alias per altre variabili, mentre i secondi sono delle variabili il cui valore, essendo esso stesso un indirizzo, consente di accedere ad altre locazioni di memoria (di variabili e non). La locazione di un alias, come quella di ogni altra variabile, è fissa, mentre il valore di un puntatore, come il valore di ogni altra variabile, può mutare. Passaggio di parametri Una procedura (in C++ una funzione) è un sottoprogramma parametrizzato: si tratta di una porzione di codice che può essere attivata in diversi punti di un programma (detti “chiamate”), ma che viene definita una volta per tutte, assegnandole un nome. I parametri sono delle variabili il cui ambito di visibilità è limitato al corpo della procedura e la cui allocazione/deallocazione (e quindi la persistenza) sono automatiche ed avvengono rispettivamente a monte e a valle di ciascuna chiamata della procedura. Quando una procedura viene chiamata, i parametri vengono inizializzati utilizzando le informazioni rappresentate nella chiamata, ciò che viene detto passaggio dei parametri. Il passaggio dei parametri può avvenire in due modi: per valore o per riferimento. Nel caso del passaggio per valore, nella chiamata il posto dei parametri usati nella definizione (che si dicono formali) è preso da espressioni il cui valore viene assegnato ai parametri prima di eseguire la procedura. Pertanto il codice int f(int n) {return ++n;} int m = 0; cout << f(m); // stampa 1 cout << m; // stampa 0 equivale a int m = int n = ++n; cout << cout << 0 ; m; n; m; // stampa 1 // stampa 0 dove è chiaro che il valore di m, essendo stato copiato in n ed essendo successivamente avvenuto l’incremento di n, è rimasto 0. Nel caso del passaggio di parametri per riferimento (che non è possibile in C, ma esiste in altri linguaggi come il PASCAL e che è stata introdotta in C++), invece, il parametro è un alias per l’espressione che lo sostituisce, che deve dunque essere un L-value: int g(int& n) {return ++n;} int m = 0; cout << g(m); // stampa 1 cout << m; // stampa 1 dove si noti la dichiarazione del parametro n come riferimento, equivale a int m = 0 ; int& n = m; ++n; cout << n; // stampa 1 cout << m; // stampa 1 da cui risulta evidente l’effetto collaterale sul valore di m. Il passaggio per riferimento è utile in casi quali lo scambio tra valori di due variabili, oppure quando si vogliono ritornare più valori (ad esempio quoziente e resto) senza costruire una struttura dati per contenerli. Il C, come altri linguaggi C-like quale ad esempio è Java, non prevede il passaggio per riferimento, ma solo per valore. L’escamotage utilizzato per ottenere un effetto simile a quello del passaggio per riferimento consiste nell’uso congiunto dei puntatori e dell’operatore di indirizzo &. La funzione swap, che scambia tra loro il valore di due variabili intere, in C si scrive: void swap(int* p, int* q) { int t = *p; *p = *q; *q = t ; } e si usa int n = 0, m = 1; swap(&n, &m); // l’uso di n ed m senza & dà errore cout << n; cout << m; // stampa 1 // stampa 0 Per questa ragione i programmatori C parlano di “passaggio per puntatore”, ma dovrebbe esser chiaro che si tratta solo di un caso particolare di passaggio per valore, che produce effetto collaterale non sui valori di p e q, ma sulle locazioni di memoria cui questi si riferiscono. La stessa funzione in C++ possiamo invece scriverla: void swap(int& x, int& y) { int t = x; x = y; y = t ; } ed usarla come segue: int n = swap(n, cout << cout << 0, m m); n; m; = 1; // qui l’uso di & darebbe errore // stampa 1 // stampa 0 Funzioni che restituiscono riferimenti Avendo introdotto i riferimenti, in C++ è possibile restituirli come valore di una funzione, la cui chiamata può dunque essere un L-value. L’esempio che segue è solo un’illustrazione di quanto detto: int& f(int& n) {return n;} int m = 0; f(m) = 5; cout << m; // stampa 5 Poiché il valore di f deve essere una referenza che persiste oltre l’esecuzione di f, questa non può essere la locazione di una variabile locale: int& f() {int x; return x;} è accettata dal compilatore, ma con il warning che è stata ritornato il riferimento ad una variabile locale; invece int& f() {int x; int& rx = x; return rx;} è accettata dal compilatore senza alcun warning, ma produce errore in esecuzione perché x sarà deallocata dopo l’esecuzione di f. A dispetto dei rischi di errore che tutto ciò introduce, la possibilità di ritornare riferimenti è essenziale nell’astrazione di strutture dati dinamiche e molto usata in combinazione con le classi. Ne sono esempio le funzioni Head e Tail usate nel corso per accedere alle liste: struct Node { int info; Node* next; } ; typedef Node* List ; T& Head(List L) { return L->info; } List& Tail(List L) { return L->next; } // L punti ad una lista non vuota, ed M abbia tipo List Heda(L) = 9; // rimpiazza con 9 la testa di L Tail(L) = M; // ridefinisce come M la coda di L Il vantaggio di queste definizioni consiste nel fatto che basta attenersi all’uso di funzioni (o metodi nel caso delle classi) con lo stesso nome per poter sostituire una realizzazione delle liste con un’altra, magari più opportuna in un dato contesto, senza dover riscrivere il programma che la usa: cosa che accadrebbe se, al posto di istruzioni quali Tail(L) = M scrivessimo L->next = M. Letture Si veda J. R. Hubbard, Programmare in C++, capitolo 7.
© Copyright 2024 Paperzz