I puntatori nel C++

Ricordate quando abbiamo parlato degli array, di cosa avviene in memoria durante l'allocazione di un array (così come di una variabile) e di come fa il programma a muoversi di elemento in elemento?
Spero di sì, perché in questo post riprenderemo un po' quel discorso.

Ricordiamo cosa avviene durante la definizione di una variabile:

int funghetti;

La definizione di una variabile è composta di due parti, il tipo e il nome della variabile.
Il tipo dice al programma di riservare uno spazio di memoria di 32 bit, in un punto preciso della RAM, identificato da un indirizzo di memoria.
Il nome della variabile non è altro che un'etichetta collegata a quel preciso indirizzo di memoria, così che il programmatore possa usarlo per accedere allo spazio in memoria senza dover conoscere e ricordare l'indirizzo.

In altre parole "funghetti" non è altro che un collegamento ad uno spazio di memoria di 32 bit, che inizia alla locazione di memoria identificata dall'indirizzo di memoria di cui è all'atto pratico il nome della variabile è un alias.

In C++ esiste un operatore per conoscere l'indirizzo di una variabile, è l'operatore di indirizzo, rappresentato da una e commerciale: &
Basta far precedere l'operatore di indirizzo ad una variabile per conoscere l'indirizzo della locazione di memoria a cui fa riferimento:

int variabileIntera;

cout << &variabile intera; //stampa l'indirizzo della variabile

Eseguite il programma e vedrete a schermo un valore in esadecimale, nel nostro esempio utilizzeremo 0x00001234, ma il valore varia ad ogni esecuzione del programma, poiché il programma utilizzerà ogni volta delle locazioni di memoria diverse (quelle che trova libere).

Ora, cosa significa tutto questo? Significa che variabileIntera è un riferimento alla locazione di memoria 00001234, ed ogni volta che utilizziamo la variabile, in realtà stiamo accedendo a quella locazione di memoria.
Il tipo di dato, in questo caso int, dice al programma che, a partire da quella locazione, deve leggere tutto ciò che trova per i prossimi 32 bit.

Per verificarlo basterà utilizzare un array:

int mioArray[2];

cout << &mioArray[0] << endl;
cout << &mioArray[1] << endl;

In questo modo stampiamo gli indirizzi dei due elementi dell'array di int.
Otterremo qualcosa come:

/>00001234; //l'indirizzo del primo elemento
/>00001238; //l'indirizzo del secondo elemento

Come possiamo vedere, tra il primo elemento ed il secondo ci sono esattamente 4 locazioni di differenza. Questo perché un elemento di tipo int occupa 32 bit, e cioé 4 locazioni di memoria (ricordiamo che ogni locazione di memoria RAM è di 8 bit).
Ad esempio il primo elemento dell'array occuperà 4 locazioni di memoria a partire dalla locazione 00001234.

LOCAZIONI| DATI
00001234 | Inizio mioArray[0]
00001235 | ...
00001236 | ...
00001237 | Fine mioArray[0]
00001238 | Inizio mioArray[1]
00001239 | ...
0000123A | ...
0000123B | Fine mioArray[1]

Se non specificassimo il tipo, il programma si limiterebbe a conoscere la locazione di partenza, ma non saprebbe quante locazioni di memoria occupa il dato salvato nella variabile.
Ad esempio nei tipi composti come le strutture, una variabile di quel tipo, contenendo vari membri, va ad occupare parecchia memoria, che si traduce in varie locazioni di memoria consecutive.
Se il programma non conoscesse il tipo a cui appartiene la variabile, si limiterebbe a leggere il contenuto della prima locazione di memoria a cui fa riferimento la variabile, e poi si fermerebbe, leggendo così solo la prima parte del dato salvato nella variabile, e tralasciando tutto ciò che non è nei primi 8 bit.

Invece grazie al nome della variabile il programma sa da che locazione di memoria iniziare a leggere, mentre grazie al tipo sa per quanti bit deve andare avanti a leggere prima di fermarsi.

Negli array invece è il nome stesso dell'array a fare riferimento alla prima locazione di memoria, infatti:

cout << &mioArray[0]; << endl;
cout << mioArray; << endl;

le due righe stampano lo stesso indirizzo.

Grazie al tipo il programma sa quanto occupa ogni elemento dell'array, mentre grazie all'indice il programma capisce di quanto deve muoversi in avanti nella memoria, per accedere al contenuto dell'elemento dell'array identificato da quel particolare indice:

int altroArray[50]; //ogni elemento richiede 32 bit di memoria

altroArray[9]; //si muove di 32 bit in avanti per 10 volte

Le righe superiori rendono bene l'idea.
Creiamo un array di int, dove quindi ogni elemento fa riferimento ad uno spazio in memoria di 32 bit.
Quando cerchiamo di accedere al decimo elemento dell'array (ricordiamo che gli array partono da 0 e non da 1), il programma:

- legge la locazione a cui fa riferimento l'array altroArray, ad esempio 00001234;

- legge che l'array è di tipo int e capisce che ogni elemento occupa 32 bit;

- legge l'indice e capisce che si deve spostare in avanti di 32 bit per 10 volte a partire dalla locazione 00001234;

All'inizio può sembrare un po' complicato, ma una volta entrati nella logica del meccanismo il tutto appare molto più chiaro, ci vuole solo un po' di tempo per assimilare bene le nostre nuove conoscenze in fatto di memoria.

Ora che abbiamo capito come funziona la gestione della memoria e delle variabili in C++, possiamo parlare dei puntatori.

Abbiamo detto che una comune variabile contiene un determinato dato, perciò utilizzando il nome della variabile in un programma potremo conoscerne il dato contenuto in essa.
Mentre per conoscere il suo indirizzo dovremo utilizzare l'operatore di indirizzo & .

int nomeVariabile = 10;

cout << nomeVariabile; //stampa l'intero 10
cout << &nomeVariabile; //stampa l'indirizzo a cui è collegata la variabile

Un puntatore invece, a differenza delle normali variabili, non andrà a contenere un dato ma un indirizzo di memoria.
Dal momento che un puntatore può essere utilizzato per spostarsi nella memoria, è necessario specificare il tipo a cui punta.
A tale scopo è necessario utilizzare l'operatore di referenziazione rappresentato da un asterisco: *

int* mioPuntatore; //creo un puntatore a int

Possiamo leggere questa riga di codice come "mioPuntatore è un puntatore (*) ad int".
L'asterisco può essere messo anche attaccato al nome del puntatore, ma personalmente trovo che questo crei confusione.
Tuttavia in caso di dichiarazione di più puntatori nella stessa riga, è necessario riscrivere l'operatore di referenziazione per ogni puntatore:

int* p1, p2, p3, p4; //solo p1 è un puntatore, le altre sono normali variabili di tipo int

int *p1, *p2, *p3, *p4; //ora sono tutti dei puntatori a int

Quanto appena mostrato potrebbe contraddirmi.
Ad occhio è più difficile fraintendere quello che io ho definito il metodo più confuso.
Tuttavia l'operatore * non viene utilizzato solo durante la definizione di un puntatore, ma anche per assegnare al puntatore l'indirizzo di una variabile, e per accedere all'indirizzo stesso:

int variabile = 56; //definisco una variabile di tipo int

int* puntatore; //definisco un puntatore a int

puntatore = &variabile; //assegno a puntatore l'indirizzo (&) di variabile

cout << puntatore; //stamperà l'indirizzo a cui punta, che è lo stesso di &variabile

cout << *puntatore; //stamperà il numero 56

*puntatore = 31; //assegna alla variabile a cui punta il numero 31

cout << variabile; //stamperà il numero 31

Prima vediamo ogni passo nel dettaglio, dopodiché vi farò vedere come avviene l'inizializzazione di un puntatore, e capirete perché mettere l'asterisco attaccato al puntatore durante la sua dichiarazione può confondere le idee.

Prima di tutto abbiamo creato una variabile di nome variabile (che fantasia!), e l'abbiamo inizializzata al valore 56.
Ora, sappiamo che variabile fa riferimento ad un indirizzo di memoria, e quindi da un punto di vista fisico il dato 56 è contenuto nello spazio di memoria che inizia a quell'indidizzo. Ipotizziamo che l'indirizzo in questione sia 00001230.

Nella riga successiva dichiariamo un puntatore ad intero, e lo chiamiamo puntatore (no comment!).

Nella riga successiva assegniamo a puntatore l'indirizzo di variabile:

puntatore = &variabile;

Come possiamo vedere il puntatore non è preceduto dall'operatore di referenziazione *
Questo perché all'atto pratico un puntatore è una variabile, e come tale la trattiamo.
Si tratta solo di una variabile speciale, che non contiene dati ma indirizzi.

L'operatore * serve solo durante la dichiarazione per dire al compilatore che non si tratta di una variabile di tipo int, ma di un puntatore che conterrà l'indirizzo di una variabile int.
Infatti durante la dichiarazione il compilatore non riserva al puntatore uno spazio di 32 bit, ma uno spazio abbastanza grande da poter memorizzare un indirizzo.
Perciò un puntatore ad int e un puntatore a long avranno allocata la stessa quantità di memoria, perché entrambi dovranno memorizzare la cifra binaria che identifica una locazione di memoria. Nessuno dei due conterrà mai un int o un long, al massimo punteranno l'uno all'indirizzo di una variabile int, e l'altro all'indirizzo di una variabile long. Ma entrambi gli indirizzi occuperanno lo stesso spazio di memoria, il fatto che siano associati a variabili int o long non significa niente (se non avete capito non vi preoccupate, lo spiegherò meglio dopo con un esempio).

Torniamo al nostro esempio. Ora puntatore contiene l'indirizzo di variabile.
Infatti nella riga successiva:

cout << puntatore;

verrà stampato l'indirizzo contenuto da puntatore, che è l'indirizzo di variabile.

Invece nella riga successiva:

cout << *puntatore;

utilizziamo l'operatore * . Questo dice al programma di stampare il contenuto, non del puntatore (l'indirizzo di variabile), ma della variabile a cui punta puntatore.

In altre parole, il primo cout dice:

"stampa il contenuto di puntatore"

Mentre la seconda dice:

"stampa il contenuto della locazione di memoria a cui punti"

La riga successiva invece:

*puntatore = 31;

è qualcosa come:

"inserisci il numero 31 nella locazione di memoria a cui punti"

Essendo la locazione a cui puntatore punta, la stessa locazione a cui fa riferimento variabile, nel momento in cui inseriamo 31 in quella data locazione, modifichiamo anche il contenuto di variabile.

Basterà infatti stampare il contenuto di variabile per vedere che non è più 56 ma 31.

Se invece volessimo inizializzare un puntatore? E cioé assegnarli l'indirizzo di una variabile durante la sua dichiarazione?

int miaVariabile;

int *mioPuntatore = &miaVariabile;

Ma non avevamo detto che l'operatore * che precede un puntatore serve per modificare il contenuto della variabile a cui punta? Perché ora gli stiamo assegnando l'indirizzo di miaVariabile? Non dovremmo invece scrivere mioPuntatore = &miaVariabile, senza l'operatore * ?
Infatti a guardare questa dichiarazione, la presenza di quell'asterisco attaccato al nome del puntatore, ci porta a leggere qualcosa come:

"crea un puntatore a int / inserisci nella locazione (*) puntata da mioPuntatore l'indirizzo di miaVariabile"

In realtà durante la dichiarazione di un puntatore, come abbiamo già detto, l'operatore * serve solo a dire al compilatore che si tratta di un puntatore a int, e non di una variabile int.
Per questo dicevo che è meglio mettere l'asterisco appiccicato al tipo, e non al nome del puntatore:

int* mioPuntatore = &miaVariabile;

Così è molto più chiaro. Possiamo leggere qualcosa come:

"crea un puntatore a int / assegna al puntatore l'indirizzo di miaVariabile"

Rivediamo brevemente quanto imparato sino ad ora:

Un puntatore è una variabile che contiene indirizzi di memoria.

Un puntatore serve quindi per memorizzare l'indirizzo di memoria a cui fa riferimento una variabile.

Nel gergo tecnico si dice che il puntatore punta all'indirizzo di quella variabile.

Durante la dichiarazione di un puntatore è necessario indicare il tipo a cui punta, e lo si fa utilizzando l'operatore di referenziazione * :

int* unPuntatore;

L'indirizzo a cui fa riferimento una variabile è accessibile tramite l'operatore di indirizzo & :

&unaVariabile;

Quindi per assegnare ad un puntatore l'indirizzo di una variabile, basta fare:

unPuntatore = &unaVariabile;

Da questo momento in poi unPuntatore punterà all'indirizzo di unaVariabile:

cout << unPuntatore;
cout << &unaVariabile;

Le due righe precedenti stampano entrambe lo stesso indirizzo di memoria.
Ora, per accedere a quell'indirizzo di memoria possiamo utilizzare sia la variabile che il puntatore. Ammettiamo che l'indirizzo sia 00001234:

unaVariabile = 56;
*unPuntatore = 31;

In entrambi i casi andiamo a memorizzare un dato nello spazio di memoria di 32 bit che inizia alla locazione di memoria 00001234.
In entrambi i casi il programma sa che lo spazio di memoria allocato è di 32 bit, nel primo caso perché unaVariabile è una variabile int; nel scondo caso perché unPuntatore è un puntatore a int.

Ricordate che un int richiede l'allocazione di 32 bit di memoria, mentre il tipo char richiede l'allocazione di 8 bit?
Proviamo a vedere cosa succede se creiamo una variabile a int, e poi creiamo un puntatore a char:

int variabileInt;
char* puntatoreChar;

La variabile è di tipo int quindi richiede l'allocazione di 32 bit di memoria.
Il puntatore invece punterà ad un tipo char, il che suggerisce al programma che puntatoreChar punterà a variabili di tipo char, e quindi ad uno spazio in memoria di 8 bit.

Ora memorizziamo nella variabileInt un numero abbastanza grande da essere sicuri che occuperà più di 8 bit.

Il numero più alto che può essere contenuto in 8 bit di memoria è il numero 255, 11111111 in binario. Basta contare il numero di cifre per capire che 255 occuperà 8 bit.
Se prendiamo invece il numero 23456, vedremo che in binario è 101101110100000, e quindi occuperà 15 bit.
Ok procediamo:

int variabileInt; //dichiariamo una variabile int(32bit)
char* puntatoreChar; //dichiariamo un puntatore a char(8bit)

variabileInt = 23456; //memorizziamo il numero nella variabile

puntatoreChar = &variabileInt; //puntatoreChar punta all'indirizzo di variabileInt

cout
<< variabileInt;
cout << *puntatoreChar;

Il compilatore per fortuna restituisce errore, e ci fa notare che stiamo facendo puntare a una variabile int un puntatore che dovrebbe puntare ad una variabile char.
Se il compilatore non ci avesse fermato, il primo cout avrebbe stampato tutto il numero 23456, mentre il secondo cout avrebbe stampato solo la parte del numero salvata nei primi 8 bit di memoria.

Abbiamo quasi finito per oggi, ricordate solo che in una variabile il suo nome serve per accedere al dato, mentre il nome preceduto da & serve per accedere all'indirizzo.
Mentre per i puntatori il nome serve per accedere ll'indirizzo, mentre il nome preceduto da * serve per accedere al dato.

Ad esempio:

int var = 93;
int* punt = &var;

cout << var; //stampa il dato, 93
cout << &var; //stampa l'indirizzo, ad esempio 00001234

cout << *punt; //stampa 93
cout << punt; //stampa 00001234

punt = &altravar; //modifica l'indirizzo a cui punta punt
//ora punt non punta più all'indirizzo di var
//ma all'indirizzo di altravar

*punt = 55; //inserisce 55 nell'area di memoria puntata da punt,
//che è lo stesso della variabile altravar.
//di conseguenza anche il contenuto di altravar sarà 55,
//poiché si riferiscono entrambi alla stessa area di memoria
Infine è importante spendere due parole sui puntatori a struttura, poiché la forma utilizzata da questi puntatori è leggermente diversa da quella utilizzata per le normali variabili.
Ipotizziamo di avere la nostra cara vecchia struttura Nemico, e di aver creato una variabile vampiro di tipo Nemico.
Creiamo poi un puntatore al tipo Nemico, e facciamolo puntare all'indirizzo di vampiro.

Nemico vampiro;
Nemico* nPunt;

nPunt = &vampiro;

Se ora volessimo accedere all'area di memoria puntata da nPunt per modificare il contenuto di vampiro, potremmo utilizzare queste due forme:

(*nPunt).armatura = 500; //modifichiamo il membro armatura di vampiro, ma tramite nPunt

nPunt->arma = "canini appuntiti"; //stessa cosa, ma in una forma diversa

In entrambi i casi facciamo la stessa cosa:

nel primo caso utilizziamo l'operatore di referenziazione * per accedere al contenuto dell'area di memoria puntata da nPunt; è molto simile a come avviene per i puntatori alle variabili comuni, tranne che il nome del puntatore è racchiuso tra parentesi tonde;

nel secondo caso utilizziamo l'operatore freccia -> per fare la stessa cosa.
Come vedete non è necessario far precedere il nome del puntatore dall'asterisco per indicare che si sta accedendo all'area di memoria puntata, e non al suo indirizzo.
Questo perché l'operatore -> rende palese il fatto che si sta accedendo a dei dati, e non ad un indirizzo. Senza considerare che la forma stessa della freccia fa venire in mente che si tratta di un puntatore.

La seconda forma è quella più usata, al punto da considerare raro l'utilizzo della prima forma.

Ok, per oggi può bastare. Nel prossimo post parleremo dell'aritmetica dei puntatori, ma prima affronteremo una breve parentesi sullo spazio utilizzato per memorizzare un indirizzo.

Nessun commento:

Posta un commento