I puntatori sono lo spauracchio di chiunque cominci a studiare il C++. Oggi tento un’impresa titanica: renderli semplici e comprensibili. Il trucco, secondo me, è non partire dalla sintassi ma da come un programma compilato usa la memoria mentre gira. Capito dove finiscono i dati, l’asterisco e la e commerciale diventano quasi ovvi.
Come un programma usa la memoria
Quando un programma C++ è in esecuzione, la memoria che il sistema gli assegna è divisa in zone, ognuna con il suo scopo:
- il codice, che può essere solo letto ed eseguito;
- i valori letterali, cioè le costanti che non cambiano mai, in sola lettura;
- le zone per i dati inizializzati e per quelli non inizializzati;
- le due memorie di lavoro, quelle che ci interessano davvero: lo stack e lo heap.
Stack e heap sono le aree coinvolte quando diamo un valore a una variabile e quando creiamo un puntatore. Vediamo come funzionano.
Lo stack
Prendiamo un programma che usa soltanto lo stack, con tre blocchi di codice uno dentro l’altro:
int main() {
int a = 1;
{
int b = 2;
{
int c = 3;
}
}
}
Quando viene creata a, il suo valore finisce nello stack. Apriamo un blocco e creiamo b: anche b va nello stack, sopra ad a. Apriamo un altro blocco e creiamo c, che va in cima: a questo punto lo stack contiene c, b e a.
Lo stack funziona con una logica LIFO, last in first out: l’ultimo arrivato è il primo a uscire. Questo lo rende velocissimo ed efficiente, ma non è il posto giusto per archiviare grandi quantità di dati.
Ecco, questo è molto importante: la vita di una variabile nello stack è limitata al blocco di codice in cui viene creata. Quando la prima graffa si chiude, c viene distrutta e nello stack restano b e a. Alla seconda graffa sparisce b, all’ultima anche a, e lo stack resta vuoto. Ma a quel punto è finito anche il programma.
Lo heap E I puntatori
Cosa succede se invece di una variabile a dichiariamo un puntatore? Con l’asterisco diciamo che a non è più una semplice variabile:
int *a = new int(1);
Il valore stavolta va nello heap, mentre nello stack finisce soltanto il suo indirizzo. È questo che fa la dichiarazione di un puntatore: memorizza nello stack l’indirizzo che serve per trovare nello heap il valore.
Il resto del programma procede uguale, con un’attenzione: le variabili dello stack vengono distrutte alla chiusura dei blocchi. Quando sparisce a, cioè l’indirizzo, il valore a cui puntava resta nello heap, e nessuno sa più dove sia. È quello che i programmatori chiamano memory leak: un dato nello heap che non è più referenziato da nessun puntatore nello stack.
Per evitarlo, prima che il puntatore sparisca, dobbiamo cancellare esplicitamente anche il valore a cui punta, con delete:
delete a;
È una tecnica comodissima: nello stack, che è una memoria veloce e snella, teniamo solo l’indirizzo, mentre il dato, che può essere anche molto grande e ingombrante, lo mettiamo nello heap e ci arriviamo tramite il suo puntatore.
Valore E indirizzo di una variabile
Passiamo alla pratica. Apriamo l’editor e scriviamo un programmino che ci aiuti a capire cosa viene memorizzato e dove. Includiamo il solito iostream, dichiariamo una variabile e ne stampiamo il valore come già sappiamo fare:
#include <iostream>
int main() {
int a = 7;
std::cout << "a: " << a << std::endl;
}
Compilando e lanciando, il programma stampa 7. Ma a non è soltanto un valore: come ogni variabile ha anche un indirizzo in memoria. Per leggerlo c’è l’operatore e commerciale, che in un certo senso è la controparte dell’asterisco:
std::cout << "&a: " << &a << std::endl;
Stavolta otteniamo qualcosa del genere:
a: 7
&a: 0x7fff88a85820
Il primo è il valore di a, il secondo il suo indirizzo. Siccome a è dichiarata in modo statico, il suo valore sta nello stack, e quello è un indirizzo dello stack ( gli indirizzi che vedete voi saranno diversi, cambiano a ogni esecuzione ).
Un puntatore A una variabile
Dichiariamo adesso una variabile puntatore, la chiamiamo ptr, e ci mettiamo dentro l’indirizzo di a, letto proprio con la e commerciale. Poi stampiamo sia ptr sia il valore a cui punta, che si legge con l’asterisco:
int *ptr = &a;
std::cout << "ptr: " << ptr << std::endl;
std::cout << "*ptr: " << *ptr << std::endl;
L’output:
ptr: 0x7fff88a85820
*ptr: 7
ptr contiene lo stesso indirizzo di a, perché è esattamente quello che gli abbiamo detto di fare, mentre il valore puntato da ptr è 7. Notate che il 7 è sempre lui, sempre nello stack: ci stiamo solo accedendo in due modi diversi, attraverso a e attraverso l’indirizzo contenuto in ptr.
Con l’asterisco il valore puntato si usa come qualunque altro valore. Possiamo per esempio metterlo in una nuova variabile b:
int b = *ptr;
std::cout << "b: " << b << std::endl;
E b contiene 7. Ma attenzione: b è allocata staticamente, quindi anche il suo 7 sta nello stack, ed è una copia. Adesso nello stack ci sono due sette: quello di a e quello di b.
Un valore nello heap
Ultimo caso: come si mette un valore nello heap? Molto semplicemente dichiarando direttamente una variabile puntatore, e creando il valore con new:
int *c = new int(5);
std::cout << "c: " << c << std::endl;
std::cout << "*c: " << *c << std::endl;
Cosa ci aspettiamo? Che c contenga un indirizzo e stia nello stack, mentre *c, il valore a cui punta, stia nello heap e valga 5:
c: 0x5a32171812c0
*c: 5
Guardate l’indirizzo: è molto diverso dai primi due, perché stavolta punta nello heap. A differenza dei sette di a e b, il 5 non sta nello stack.
Il programma completo
Ecco il main.cpp intero:
#include <iostream>
int main() {
int a = 7;
std::cout << "a: " << a << std::endl;
std::cout << "&a: " << &a << std::endl;
int *ptr = &a;
std::cout << "ptr: " << ptr << std::endl;
std::cout << "*ptr: " << *ptr << std::endl;
int b = *ptr;
std::cout << "b: " << b << std::endl;
int *c = new int(5);
std::cout << "c: " << c << std::endl;
std::cout << "*c: " << *c << std::endl;
}
Per compilarlo uso il solito Makefile che conoscete dalle puntate precedenti:
# nome dell'eseguibile da generare
TARGET = main
# lista dei file sorgente
SRCS = main.cpp
# flag del compilatore
CXXFLAGS = -g
# nome del compilatore
CXX = g++
# regola di default
all: $(TARGET)
# regola per generare l'eseguibile
$(TARGET): $(SRCS)
$(CXX) $(CXXFLAGS) -o $(TARGET) $(SRCS)
clean:
rm -f $(TARGET)
( attenzione: le righe dei comandi nel Makefile cominciano con un tab, non con degli spazi ). Poi si compila e si lancia:
make
./main
Se non volete usare make, va benissimo anche g++ a mano:
g++ -g -o main main.cpp
Il programma lascia il 5 nello heap senza un delete: qui finisce subito e il sistema si riprende tutto, ma in un programma vero andrebbe liberato con delete c prima che c sparisca.
Per chiudere
Questo breve video non esaurisce il mondo vastissimo dell’algebra dei puntatori in C++, ma spero di avervi chiarito le basi: come è fatta la memoria di un programma in esecuzione, la differenza fra stack e heap, l’operatore asterisco e l’operatore e commerciale. La cosa migliore da fare adesso la sapete: aprite la vostra Linux box e cominciate a giocare con i puntatori, perché sono cose che si imparano solo con la pratica.
Link e risorse
- il video su YouTube
- esempi e comandi della puntata su GitHub
- Puntatori, cppreference — dichiarazione e uso dei puntatori
- new, cppreference — allocare un valore nello heap
- delete, cppreference — liberare la memoria e i memory leak
- Puntatore (programmazione) — Wikipedia in italiano
Sostieni il canale
PER SOSTENERE IL CANALE BASTANO UN LIKE E UN’ISCRIZIONE!
ma se vuoi offrirmi un caffè, mi aiuterà a rimanere sveglio mentre registro i video 😁☕
clicca qui per scoprire come https://buymeacoffee.com/thelinuxnerd
questi link sono affiliati Amazon, acquistando i libri che ti consiglio sostieni il canale!
