Sommario
< Home
Stampa

Puntatori

Il processo in esecuzione

Come abbiamo già visto, quando viene compilata una applicazione questa viene convertita in un pacchetto che contiene direttamente il codice macchina che può essere direttamente eseguito dalla CPU del computer. Questo prodotto è l’applicazione eseguibile.

Quando diamo il comando di esecuzione ad una applicazione eseguibile, il sistema operativo predispone uno spazio di memoria all’esecuzione dell’applicazione, in cui carica il codice macchina e lo dedica all’applicazione.

La memoria di una applicazione è come un grande array, organizzato in celle dove ogni cella occupa un byte di memoria (1), ed ogni cella è contraddistinta da un indirizzo.

L’applicazione in esecuzione insieme alla sua memoria dedicata costituiscono un processo.

Codice e spazio dedicato ai dati sono condivisi nella stessa area di memoria, come previsto dall’architettura di Von Neumann. Quando quindi creiamo una variabile l’applicazione (tramite il sistema operativo) trova una locazione di memoria grande abbastanza per contenerla (si ricorda che le variabili occupano più o meno bytes in base al loro tipo) e associa a questo spazio di memoria un simbolo, ovvero il nome della variabile. Ad esempio se vogliamo creare una variabile di tipo int in memoria, quando la inizializziamo il sistema operativo alloca da qualche parte in memoria uno spazio di tot bytes per memorizzarne il valore (nei sistemi a 64 bit sono 8 bytes). Viene quindi aggiornata una tabella di simboli che associa ad ogni simbolo la posizione di memoria del dato ad esso associato.

La parte della memoria dedicata ai dati memorizzati come variabili viene chiamata “heap”. Nello heap le variabili vengono create assegnando loro un indirizzo che contiene il primo byte della variabile.

Esiste poi una porzione speciale della memoria, detta stack, che viene utilizzata dai dati usati dalle funzioni, come vedremo più avanti.

Qui lo schema di memoria di un processo.

notion image

Puntatori e riferimenti in C/C++

Come abbiamo appena visto le variabili occupano uno spazio di memoria, determinato da un indirizzo e in C/C++ le memorizziamo in variabili. Ad esempio l’istruzione:

int x = 4;

memorizza in una struttura dati il valore 4 e l’associa al simbolo “x”, e memorizza nella tabella dei simboli l’associazione tra x e l’indirizzo dove si trova il valore di x.

Quando assegnamo un valore ad x, ad esempio con:

x = 25

quello che fa l’applicazione è di andare a memorizzare il valore 25 nell’indirizzo di memoria indicato da x.

Col C/C++ è possibile accedere direttamente agli indirizzi di memoria delle variabili, tramite l’accesso per riferimento i tipi puntatore. Vediamo come.

Riferimento

Viene definito con il simbolo & (chiamato “ampersand”) l’operatore unario di referenziazione, detto anche riferimento. Questo operatore applicato ad una variabile di qualsiasi tipo restituisce l’indirizzo di memoria della variabile stessa. Ad esempio se definiamo:

float x = 3.1415; 
cout << &x; 

con l’espressione &x otteniamo l’indirizzo di memoria dove viene memorizzata x.

Ipotizziamo per esempio che l’indirizzo di x (ovvero &x) si trovi all’indirizzo 0x108 (il prefisso 0x indica che l’indirizzo è espresso in notazione esadecimale). Allora le espressioni x e &x valgono rispettivamente:

Espressionevalore
x3.1415
&x0x108

Puntatore

Possiamo memorizzare l’indirizzo di memoria dove si trova una variabile in una variabile. che memorizza quindi non il valore della variabile ma appunto l’indirizzo di memoria dove essa si trova. Ma questo non è sufficiente per identificare la variabile. Infatti è necessario sapere anche cosa c’è in quell’indirizzo. Questo perché l’indirizzo di memoria ci dice solo la posizione dove si trova quella variabile, ma non ci da alcuna informazione sul tipo di variabile.

Il tipo di variabile che memorizza un indirizzo deve quindi indicare sia che è un indirizzo, sia cosa c’è a quell’indirizzo. Questo nuovo tipo di dato si chiama puntatore.

Riprendiamo l’esempio precedente:

float x = 3.1415;
cout << &x;
float* pointer = &x;

L’istruzione float* pointer = &x va quindi a definire un nuovo tipo di dato che va ad indicare che si tratta di un puntatore, e che a quell’indirizzo si trova una variabile di tipo float.

Ovvero la variabile puntatore è definita come un puntatore a float (float*) e ha come valore l’indirizzo di x.

Quindi le espressioni precedenti valgono:

Espressionevaloretipo
x3.1415float
&x0x108(indirizzo puro)
pointer0x108float*

Il simbolo * (chiamato “star”) l’operatore unario di dereferenziazione, che serve a gestire le variabili puntatore, che contengono l’indirizzo di memoria di una variabile. Queste variabili sono dichiarate quindi aggiungendo il simbolo * al tipo di dato a cui puntano.

Un indirizzo può anche essere vuoto, in questo caso non memorizza alcun indirizzo, ma questo può essere assegnato in un secondo momento:

int* pointer = NULL;

Uso dei puntatori nelle espressioni

Quando usiamo la variabile puntatore bisogna fare attenzione perché quando la usiamo in una espressione andiamo ad indicare il valore della variabile a cui punta. Quindi

float x = 3.1415;
cout << &x;
float* pointer = &x;
cout << pointer << endl; // stampa l'indirizzo
cout << *pointer << endl; // stampa il valore della variabile a cui punta pointer

*pointer indica quindi il “valore puntato” quando si trova in una espressione.

Quindi l’operatore star * ha due comportamenti diversi a seconda del contesto in cui è utilizzato:

  • quando è a sinistra dell’uguale (in una dichiarazione, quindi a sinistra dell’uguale) indica un puntatore ad uno specifico tipo di dato (“puntatore a…”);
  • quando è usato in una espressione (a destra dell’uguale, o come espressione da calcolare, stampare o inviare ad una funzione) viene usato per indicare il valore a cui punta (“valore puntato da”).

Quando sono usati quindi come espressioni il riferimento (&) ed il puntatore (*) sono quindi due operatori complementari tra loro. ù

Il primo (&) restituisce l’indirizzo della variabile.

il secondo (*) da il valore puntato dalla variabile.

Vediamo ad esempio questo codice:

int a = 6;
int *x = &a; // il cui valore sarà l'indirizzo di a
int b = 5;
int *y = &b; // il cui valore sarà l'indirizzo di b
int somma = *y + *x; // la somma farà 11 
int *psomma = &somma; // il cui valore è l'indirizzo di memoria dove si trova la somma

Questo è un esempio di memoria risultante.

0x106
0x105
0x104
0x103
0x102 (psomma, cioè &somma)11
0x101 (y, cioè &b)5
0x100 (x, cioè &a)6

I puntatori sono variabili numeriche, quindi è possibile usarli per accedere ad indirizzi di memoria. A dall’esempio precedente:

int* c = *(x+1); // indica l'indirizzo successivo a x, nell'esempio 0x101

Coi puntatori si ha quindi un accesso diretto alla memoria del processo, e si può quindi accedere ad ogni elemento della memoria, compreso lo stack e il codice. Non esiste alcun controllo e quindi è responsabilità del programmatore scrivere un programma che non modifichi se stesso (a meno che, ovviamente non lo voglia e sappia cosa sta facendo!).

Array e puntatori

Gli array sono dei puntatori. In altri termini. Sia dato il seguente array:

int x[3] = {10,20,30};

allora x è un puntatore ad intero (è di tipo int*) . In altri termini:

cout << *x; // stampa x[0] ovvero 10
cout << *(x+1); // stampa x[1] cioè 20

Peranto l’espressione x[i] è del tutto equivalente a *(x+i).

Ancora una volta si ricorda che non esiste controllo sugli indici di un array e quindi è possibile leggere e scrivere fuori dai limiti di un array.

Il C/C++ è un linguaggio che offre pieno e totale controllo della memoria.

  1. In realtà ogni cella occupa una parola (word) di memoria, che può contenere uno o più bytes, in base all’architettura del processore. Ad esempio nei sistemi con processori a 64 bit ogni parola occupa 8 bytes ed è identificata da un proprio indirizzo univoco. ↩︎