Una variabile che contiene un indirizzo
Ogni variabile vive da qualche parte in memoria, in una posizione numerata chiamata indirizzo. Di solito non ti interessa dove: usi semplicemente il nome della variabile. Un puntatore ribalta la situazione: è una variabile il cui valore è un indirizzo. Invece di contenere 42, contiene "il posto in cui è memorizzato 42".
Questa indirezione è ciò che rende potenti i puntatori. Le funzioni possono modificare una variabile del chiamante tramite un puntatore, le strutture dati come le liste collegate concatenano i nodi con i puntatori e (come vedrai con la memoria dinamica) sono il modo per raggiungere la memoria che allochi a runtime.
La & in &score è l'operatore indirizzo di: produce la posizione di score. L'* in *p è l'operatore di dereferenziazione: segue l'indirizzo fino al valore che vi si trova.
I due operatori: & e *
La cosa più confusa in assoluto per chi inizia è che * significa due cose diverse a seconda di dove si trova. Tienile ben distinte:
int* p; // DICHIARAZIONE: "p è un puntatore a int"
p = &x; // & = indirizzo di: memorizza in p l'indirizzo di x
int y = *p; // * = dereferenziazione: legge il valore a cui punta p
*p = 99; // dereferenziazione a sinistra: scrive tramite il puntatore
In una dichiarazione, * fa parte del tipo. In un'espressione, * compie un'azione. Una volta impostato un puntatore, dereferenziarlo ti dà pieno accesso in lettura e scrittura alla variabile originale:
Nota che dopo la prima riga non hai mai toccato health per nome, eppure il suo valore ha continuato a cambiare. È proprio questo il punto: hp è un alias della stessa area di memoria. Gli spazi (int* p, int *p, int*p) sono solo estetica e per il compilatore sono identici; questa guida usa int* p.
nullptr: puntare a nulla
Un puntatore che non punta da nessuna parte andrebbe impostato a nullptr (C++11). È un modo chiaro e type-safe per dire "nessun obiettivo per ora", e ti dà qualcosa con cui confrontarti prima di dereferenziare.
Preferisci nullptr alla vecchia macro NULL o a un semplice 0. Poiché nullptr ha un vero tipo puntatore, non viene mai scambiato per l'intero 0 durante la risoluzione degli overload, un bug sottile che il vecchio stile poteva causare.
Trappola: dereferenziare un puntatore nullo. Leggere o scrivere tramite un puntatore nullo (o non inizializzato) è undefined behavior, e di solito causa un crash immediato:
int* p = nullptr;
cout << *p; // CRASH: dereferenziare un puntatore nullo è undefined behavior
Proteggiti sempre con if (p) (o if (p != nullptr)) prima di dereferenziare qualsiasi cosa che potrebbe essere nulla.
Puntatori e array
Il nome di un array decade in un puntatore al suo primo elemento, quindi puntatori e array sono profondamente intrecciati. Aggiungere 1 a un puntatore non aggiunge un byte: avanza di un elemento, ed è questo che fa funzionare l'aritmetica dei puntatori:
p[i] e *(p + i) sono letteralmente la stessa espressione, ed è per questa equivalenza che gli array partono dall'indice zero. Il bug classico qui è andare oltre la fine: nums + 4 è un marcatore valido "uno oltre la fine" con cui confrontarsi, ma dereferenziare *(nums + 4) legge fuori dai limiti. Gli errori di uno con i puntatori sono tra le cause principali di crash e corruzione silenziosa dei dati, quindi scegli con cura la condizione di arresto.
const e puntatori
const può applicarsi a ciò a cui punta il puntatore, al puntatore stesso o a entrambi. Leggi la dichiarazione da destra a sinistra per decifrarla:
const int* p; // puntatore a const int: non puoi cambiare *p, puoi far puntare p altrove
int* const p = &x; // puntatore const a int: puoi cambiare *p, non puoi far puntare p altrove
const int* const p = &x; // entrambi bloccati
Nel codice reale conta di continuo. Una funzione che promette di non modificare i tuoi dati riceve un puntatore a const:
Marcare come const l'oggetto puntato documenta l'intenzione e permette al compilatore di bloccare le scritture accidentali: sicurezza gratuita, a costo zero in fase di esecuzione.
La grande trappola: i puntatori pendenti
Un puntatore pendente (dangling pointer) punta a memoria che non contiene più il valore che ti aspetti: la variabile è uscita dallo scope, oppure la memoria è stata liberata. Dereferenziarlo è undefined behavior, e la parte peggiore è che spesso sembra funzionare, finché non smette di farlo.
int* makeBad() {
int local = 5;
return &local; // BUG: local muore quando la funzione termina
} // il puntatore restituito ora è pendente
L'indirizzo è ancora un numero valido, ma punta a uno slot dello stack che è stato recuperato: leggerlo dà spazzatura o un crash. Lo stesso accade se tieni un puntatore a un oggetto dell'heap su cui hai fatto delete o a un elemento di un vector che in seguito viene riallocato.
Tre regole ti tengono al sicuro:
- Non restituire mai l'indirizzo di una variabile locale. Restituisci per valore, oppure lascia che sia il chiamante a possedere la memoria.
- Imposta un puntatore a
nullptrquando ciò a cui punta non esiste più, e controllalo prima di usarlo. - Per la proprietà e la durata degli oggetti, usa gli smart pointer invece di
new/deletegrezzi: liberano la memoria automaticamente e riducono drasticamente questa intera categoria di bug.
Prossimo passo: riferimenti vs puntatori
I puntatori non sono l'unico modo per riferirsi indirettamente a un'altra variabile. C++ ha anche i riferimenti, che sembrano simili ma non possono essere nulli, non possono essere riassegnati e hanno una sintassi più pulita. Nella prossima pagina, riferimenti vs puntatori, li metteremo a confronto così saprai esattamente quale strumento usare, e perché il C++ moderno preferisce i riferimenti quando può usarli.
Domande frequenti
Cos'è un puntatore in C++?
Un puntatore è una variabile che memorizza l'indirizzo di memoria di un altro valore invece del valore stesso. Lo dichiari con * (per esempio int* p), ottieni un indirizzo con l'operatore & (p = &x) e leggi o scrivi il valore a cui punta dereferenziandolo con *p.
Qual è la differenza tra & e * nei puntatori C++?
Nel contesto dei puntatori, & è l'operatore indirizzo di: &x ti dà l'indirizzo di x. * ha due compiti: in una dichiarazione (int* p) indica che la variabile è un puntatore, e in un'espressione (*p) dereferenzia il puntatore per raggiungere il valore memorizzato a quell'indirizzo.
Cos'è nullptr in C++ e perché usarlo al posto di NULL?
nullptr è un letterale di puntatore nullo type-safe introdotto in C++11. Significa "non punta a nulla". Preferiscilo al vecchio NULL o a 0 perché nullptr ha un vero tipo puntatore, quindi non viene mai scambiato per un intero nella risoluzione degli overload. Controlla sempre if (p) prima di dereferenziare: dereferenziare un puntatore nullo è undefined behavior.