Variabili, riferimenti e puntatori in C++ - E

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.