1. Perché const con i puntatori confonde tanto
Se avete già avuto la sensazione che const a volte sembri «attaccato da qualche parte a sinistra, ma il significato è a destra», è normale. I puntatori aggiungono un secondo livello di complessità: anche il puntatore è un valore che può essere modificato. Perciò improvvisamente emergono due domande diverse: «si può modificare l'oggetto?» e «si può riassegnare l'indirizzo?».
Immaginate il puntatore come un biglietto con un indirizzo. Sul biglietto è scritto: «via Pushkin, casa Kolotushkin». Potete o cambiare l'indirizzo sul biglietto (riscrivere dove punta), oppure andare all'indirizzo e spostare i mobili nell'appartamento (modificare l'oggetto). const può vietare una cosa, l'altra o entrambe le azioni.
Due punti di applicazione: *p e p
Quando scriviamo int x = 10;, x è una variabile-valore. Quando scriviamo int* p = &x;, p è anch'essa una variabile-valore, solo che conserva non il numero 10, ma l'indirizzo.
Un mini-modello utile da tenere in testa:
- *p — è «ciò che si trova all'indirizzo».
- p — è «l'indirizzo stesso» (il valore del puntatore).
E poi — poniamo sempre due domande.
Domanda A. Si può modificare l'oggetto tramite *p?
Domanda B. Si può modificare il puntatore stesso (fare p = &other)?
Se tenete queste due domande a mente, tutto il «menagerie» const T*, T* const, const T* const diventa uno schema comprensibile, e non un incantesimo.
2. Tre forme base: const T*, T* const, const T* const
const T* (o T const*): puntatore a costante
La forma più comune che vedrete nei parametri di funzione, quando la funzione promette «io guardo solo».
La notazione const int* p si legge: «p punta a un int const». Cioè tramite p non si può modificare il numero. Ma lo stesso p è una variabile normale, quindi può essere riassegnato a un altro indirizzo.
#include <iostream>
int main() {
int a = 1;
int b = 2;
const int* p = &a; // puntatore a const int
// *p = 10; // non permesso: oggetto "sola lettura"
p = &b; // permesso: il puntatore NON è const
std::cout << *p << '\n'; // 2
}
Importante: a non deve essere per forza costante. È un normale int. Semplicemente tramite p vi negate volontariamente il «permesso di scrittura». È un'idea molto utile: non chiedere il permesso di modificare se non è necessario.
T* const: puntatore costante
Ora capovolgiamo la situazione: a volte serve che il puntatore guardi sempre lo stesso posto, ma che l'oggetto all'indirizzo sia modificabile.
Allora rendiamo p costante e l'oggetto non: int* const p.
Dettaglio chiave: const si trova dopo il *, quindi si riferisce al puntatore stesso (alla variabile che contiene l'indirizzo).
#include <iostream>
int main() {
int a = 1;
int b = 2;
int* const p = &a; // const-puntatore a int
*p = 10; // permesso: l'oggetto NON è const
// p = &b; // non permesso: p non può essere riassegnato
std::cout << a << '\n'; // 10
}
Questa variante è spesso utile all'interno dell'implementazione (dentro una funzione/metodo) per proteggersi da riassegnazioni accidentali dell'indirizzo.
const T* const: non si può modificare né l'oggetto né l'indirizzo
La forma più severa: non si può modificare né l'oggetto all'indirizzo né l'indirizzo stesso. Cioè «puntatore inchiodato, e accesso solo in lettura».
#include <iostream>
int main() {
int a = 5;
int b = 7;
const int* const p = &a;
// *p = 1; // non permesso: oggetto const
// p = &b; // non permesso: p const
std::cout << *p << '\n'; // 5
}
Dal punto di vista pratico è particolarmente comodo in parti complesse del codice dove volete garantire che la variabile p non «scappi» verso un altro indirizzo, e attraverso di essa i dati non vengano rovinati.
3. Tabella: cosa è permesso e cosa no
Una tabella che è comoda da richiamare mentalmente ogni volta che vedete * const:
| Dichiarazione di tipo | Come si interpreta semanticamente | Si può modificare *p | Si può fare p = ... |
|---|---|---|---|
|
puntatore a |
sì | sì |
|
puntatore a |
no | sì |
|
-puntatore a |
sì | no |
|
-puntatore a |
no | no |
4. Come leggere le dichiarazioni senza panico
Per far sì che la testa smetta di interpretare const come caos, è utile sviluppare un'abitudine meccanica di lettura: prima cerchiamo il nome della variabile, poi guardiamo i simboli attorno ad esso.
Regola per il principiante (molto pratica):
- const a sinistra del tipo base rende costante l'oggetto (ciò che è «all'indirizzo»).
- const a destra di * rende costante il puntatore stesso (cioè la variabile-indirizzo).
Per esempio, nella dichiarazione:
const int* p;
const riguarda int, quindi p punta a int const (non si può modificare *p).
E qui:
int* const p;
const è attaccato al nome p, quindi non si può riassegnare lo stesso p.
Sì, potreste incontrare la «lettura da destra a sinistra» tra chi è più esperto, ma a questo stadio vi basta l'abitudine: rispondete alle due domande su *p e su p — e capirete tutto.
5. «Restringere i permessi»: perché si può trasformare T* in const T*, ma non il contrario
In C++ è permesso convertire implicitamente un «puntatore normale» in un «puntatore a const»:
- prima: int*
- dopo: const int*
Perché? Perché è sicuro: avete ridotto i permessi. Prima si poteva modificare, ora non più.
Ma il contrario (da const int* a int*) il compilatore normalmente lo vieta: sarebbe un «aumento di permessi». Infatti, se avete const int* p, non dovreste improvvisamente ottenere la possibilità di fare *p = 123.
#include <iostream>
int main() {
int x = 10;
int* p = &x;
const int* cp = p; // ok: si restringono i permessi (ora "solo lettura")
std::cout << *cp << '\n'; // 10
// int* p2 = cp; // non permesso: si aumenterebbero i permessi di nuovo
}
Questa è una delle ragioni per cui la correttezza rispetto a const aiuta davvero: potete passare un puntatore a una funzione che legge, e il compilatore garantisce che la funzione non «scriva» qualcosa di indesiderato.
6. Esempio: task e contratti di funzione
Per non restare sulla teoria, proseguiamo con l'idea di una semplice applicazione da console: immaginiamo di avere una lista di task, e di voler talvolta stampare il task e talvolta contrassegnarlo come completato.
Il modello dei dati — il più semplice possibile:
#include <string>
struct Task {
int id = 0;
std::string title;
bool done = false;
};
Funzione di sola lettura e funzione che modifica
Creiamo due funzioni: la prima stampa il task e promette di non modificarlo. La seconda modifica il task (imposta done = true). Ed è qui che const nei puntatori comincia a «parlare» per voi.
#include <iostream>
#include <string>
struct Task {
int id = 0;
std::string title;
bool done = false;
};
void PrintTask(const Task* t) {
if (t == nullptr) return;
std::cout << t->id << ": " << t->title << '\n';
}
void MarkDone(Task* t) {
if (t == nullptr) return;
t->done = true;
}
Il contratto si legge direttamente dalla firma:
- PrintTask(const Task* t) dice: «posso ricevere nullptr, e non modificherò il task».
- MarkDone(Task* t) dice: «posso ricevere nullptr, ma se il task esiste — lo modificherò».
E un piccolo main per vedere che tutto si incastra:
#include <iostream>
#include <string>
struct Task {
int id = 0;
std::string title;
bool done = false;
};
void PrintTask(const Task* t) {
if (!t) return;
std::cout << t->id << ": " << t->title << " done=" << t->done << '\n';
}
void MarkDone(Task* t) {
if (!t) return;
t->done = true;
}
int main() {
Task t{1, "Read about const pointers", false};
PrintTask(&t); // 1: Read about const pointers done=0
MarkDone(&t);
PrintTask(&t); // 1: Read about const pointers done=1
}
Qui è importante che &t di tipo Task* venga passato tranquillamente a PrintTask(const Task*), perché è uno «restringimento dei permessi»: la funzione promette di non modificare.
Dove torna utile T* const nella vita reale
const T* di solito «arriva» subito: è comodo nell'interfaccia e esprime bene il contratto «solo lettura».
Mentre T* const è più utile nell'implementazione. Immaginate che dentro una funzione abbiate scelto il «task corrente» e poi ci lavoriate diverse volte. È importante non riassegnare per errore il puntatore al task sbagliato. Allora potete «inchiodare» l'indirizzo:
#include <iostream>
#include <string>
struct Task {
int id = 0;
std::string title;
bool done = false;
};
int main() {
Task t{1, "Do not reassign pointer", false};
Task* const current = &t; // indirizzo fissato
current->done = true; // l'oggetto può essere modificato
// current = nullptr; // non permesso
std::cout << current->done << '\n'; // 1
}
Quindi T* const è prima di tutto una protezione contro riassegnazioni accidentali, non un elemento obbligatorio dell'interfaccia pubblica.
7. Errori tipici
Errore n.1: confondere const T* e T* const.
Un const messo nel posto sbagliato inverte il significato. Si risolve non con la memorizzazione a memoria, ma con l'abitudine di porsi due domande: «posso fare *p = ...?» e «posso fare p = ...?». Se una delle risposte deve essere «no» — mettete const dove impedisce quell'azione.
Errore n.2: pensare che const T* renda l'oggetto globalmente costante.
No, l'oggetto non diventa un blocco di ghiaccio. Diventa «bloccato tramite questo accesso specifico». Altro codice può ancora modificare l'oggetto se ha accesso non-const. Questo è normale: const è uno strumento di gestione dei permessi, non una magia che vieta le modifiche in tutto il programma.
Errore n.3: provare a convertire const T* in T*.
Di solito il compilatore genera un errore, ed è una buona cosa: il compilatore vi salva. Se una funzione richiede T*, si aspetta di modificare l'oggetto. Quindi o avete sbagliato e la funzione dovrebbe accettare const T*, oppure volete davvero modificare — ma allora dovete avere accesso non-const fin dall'inizio, non "forzare" i tipi.
Errore n.4: dereferenziare un puntatore che può essere nullptr.
Finché il tipo è T*, nullptr è un candidato legittimo (a meno che non sia stabilito diversamente). Quindi dereferenziare senza controllo è una lotteria. Anche se «sembrava che arrivi sempre non-nullptr», dopo un mese vi potreste sorprendere da soli. Disciplina minima: se il parametro/variabile può essere vuoto, il controllo deve essere vicino all'utilizzo.
Errore n.5: usare const come sostituto della progettazione.
A volte il principiante mette const «dappertutto dove ha paura», sperando che questo renda il codice sicuro. Ma const risponde solo alla domanda «si può modificare?», non risolve la domanda «l'oggetto esiste ancora?» o «l'indirizzo è valido?». Perciò const è un ottimo contratto, ma non un giubbotto antiproiettile contro tutti i problemi.
GO TO FULL VERSION