1. Introduzione
Quando scrivi una piccola applicazione e hai una lista di entità (task, note, utenti), la logica "ho trovato l'elemento giusto — memorizzo l'indirizzo — poi vi accedo rapidamente" sembra naturale. È proprio come nella vita reale: trovi una casa, annoti l'indirizzo e pensi che la casa non si sposterà. In programmazione, purtroppo, il "trasloco della casa" è un'operazione del tutto comune, specialmente in std::vector.
Immaginiamo di creare un mini‑manager di task in console. Nelle lezioni precedenti abbiamo già usato struct, std::vector, std::optional, funzioni, input/output. Continuiamo nello stesso spirito e svilupperemo la stessa applicazione.
Vogliamo avere un "task selezionato" per esempio per marcarlo come completato o rinominarlo. La prima idea (molto umana) è memorizzare un puntatore.
2. Antipattern: "task selezionato" come Task*
In questa sezione faremo apposta un po' "sbagliato" per vedere il problema con i fatti, non solo a parole. Non eseguiremo le righe pericolose (le commentiamo), perché il nostro obiettivo è imparare a evitare la UB, non mettere alla prova la fortuna.
#include <iostream>
#include <string>
#include <vector>
struct Task {
int id{};
std::string title;
bool done{false};
};
struct AppState {
std::vector<Task> tasks;
Task* selected = nullptr; // <-- comodo, vero!
};
Lo scenario d'uso è di solito così: trovi il task — memorizzi il suo indirizzo.
Task* FindTaskById(std::vector<Task>& tasks, int id) {
for (Task& t : tasks) {
if (t.id == id) return &t;
}
return nullptr;
}
E nel main (o nel handler di un comando):
state.selected = FindTaskById(state.tasks, 42);
if (state.selected) {
std::cout << "Selected: " << state.selected->title << "\n";
}
Ed è qui che il principiante ha la sensazione: "Ok, tengo il task in mano". In realtà tieni un indirizzo nel buffer del vector, e questo può diventare spazzatura dopo le operazioni ordinarie sulla lista dei task.
3. Cosa si rompe esattamente: reallocation e erase
Ora arriva un punto importante, spesso sottovalutato: un puntatore può diventare non valido non perché hai fatto qualcosa di "estremo", ma perché hai fatto la cosa più ordinaria: hai aggiunto o rimosso un elemento.
Reallocation durante la crescita
std::vector memorizza gli elementi in un unico blocco contiguo di memoria. Quando lo spazio (capacity) non basta, alloca un nuovo buffer (di solito più grande), sposta/copìa gli elementi e libera il vecchio. I vecchi indirizzi diventano invalidi.
Schema illustrativo:
flowchart LR
A["vector buffer (old)"] -->|push_back, spazio esaurito| B["alloca nuovo buffer"]
B --> C["sposta/copia elementi"]
C --> D["libera vecchio buffer"]
D --> E["i vecchi &v[i] non sono più validi"]
Potresti non accorgerti del trasloco: il programma "a volte funziona" e poi rompe improvvisamente. Per questo in C++ si discute molto della "validità di iteratori/puntatori" dopo le operazioni sui container — non è un'osservazione pedante, è un vero problema ingegneristico. Anche nelle specifiche dello standard ci sono discussioni su come certe operazioni influenzino la validità (per esempio l'effetto di shrink_to_fit).
Spostamento degli elementi dopo erase
erase rimuove un elemento e sposta la coda per non lasciare "buchi". Di conseguenza, gli elementi dopo l'elemento rimosso cambiano indice e indirizzo.
Schema:
flowchart TD
A["[10][20][30][40]"] -->|erase 20| B["[10][30][40] + spostamento"]
B --> C["l'indirizzo dell'ex 30 è cambiato"]
E qui la chiave del giorno: un puntatore a un elemento di vector è un accordo "per ora", non "per sempre".
4. Bug nella nostra app: il puntatore "task selezionato" è diventato dangling
Riproduciamo una situazione tipica: abbiamo selezionato un task e poi ne abbiamo aggiunto uno nuovo. Anche senza fare erase, un solo push_back a volte basta.
#include <vector>
#include <string>
void AddTask(std::vector<Task>& tasks, int id, std::string title) {
tasks.push_back(Task{id, std::move(title), false});
}
void MarkSelectedDone(AppState& state) {
if (!state.selected) return;
state.selected->done = true; // <-- può diventare UB se selected non è valido
}
Ora la sequenza:
state.selected = FindTaskById(state.tasks, 1);
AddTask(state.tasks, 99, "New task"); // può causare reallocation
// MarkSelectedDone(state); // potenzialmente UB
L'inganno è che potresti non catturare subito il bug. Può manifestarsi solo quando il vector decide di espandersi proprio in quel passo. Questi bug generano poi leggende: "C++ è strano, si è rotto da solo". No: abbiamo chiesto al vector una stabilità degli indirizzi che non garantisce.
5. Regola del progetto: non memorizzare Task* agli elementi di vector nello stato
In questa sezione formuliamo la regola non come uno spauracchio, ma come un patto pratico con noi stessi: vogliamo che l'applicazione sia prevedibile, quindi non deve dipendere dal "è andata bene/è andata male con la riallocazione".
La regola è: nello stato persistente dell'applicazione (in AppState, nei campi delle strutture di stato, tra comandi/iterazioni del ciclo) non memorizziamo puntatori raw o riferimenti agli elementi di std::vector. Se è necessario accedere a un elemento, o lo troviamo di nuovo, o conserviamo un marcatore stabile che permetta di riottenere l'accesso in modo sicuro.
Importante: la regola non dice "non prendere mai l'indirizzo di un elemento". Puoi farlo. Non conservarlo a lungo. È ok prendere un Task* e usarlo immediatamente fintanto che sai che il container non cambia. Il problema è quando il puntatore sopravvive alle modifiche del container.
6. Sostituto №1: memorizzare l'indice invece del puntatore
Il marcatore più semplice è l'indice. Sopravvive alla reallocation (perché l'indice è un numero), ma richiede disciplina: controllare i limiti e ricordare che erase cambia gli indici.
Definiamo lo stato così:
#include <cstddef>
#include <optional>
#include <vector>
struct AppState {
std::vector<Task> tasks;
std::optional<std::size_t> selectedIndex; // invece di Task*
};
La selezione ora si fa così: trovi l'indice — lo salvi.
#include <optional>
#include <cstddef>
#include <vector>
std::optional<std::size_t> FindTaskIndexById(const std::vector<Task>& tasks, int id) {
for (std::size_t i = 0; i < tasks.size(); ++i) {
if (tasks[i].id == id) return i;
}
return std::nullopt;
}
Uso:
void MarkSelectedDone(AppState& state) {
if (!state.selectedIndex) return;
const std::size_t i = *state.selectedIndex;
if (i >= state.tasks.size()) return; // protezione da out-of-range
state.tasks[i].done = true;
}
Qui è già difficile ottenere un puntatore dangling a causa della reallocation: ogni volta accediamo nuovamente a tasks[i] nel buffer attuale.
7. Ma l'indice non è una "identità": cosa fare se c'è stato un erase
Con l'indice c'è un punto sottile. Non si rompe con la reallocation, ma può cominciare a puntare a un "altro elemento" dopo la rimozione di qualcosa prima nella lista. Non è UB, ma è un errore logico: hai selezionato "Comprare latte", e dopo la cancellazione del primo elemento la selezione potrebbe riferirsi a un altro task.
Quindi l'indice è una buona scelta se controlli gli scenari di cancellazione o sei pronto a aggiornare la selection durante le erase. Di solito per un progetto didattico scegliamo una di due strategie.
La prima strategia — dopo qualsiasi operazione che può cambiare ordine/indici (per esempio dopo erase) resettiamo la selezione. Non è la UX più "intelligente", ma è onesta e sicura. La seconda è memorizzare non l'indice ma l'identificatore (id) e ritrovare l'indice a partire da quello.
Cominciamo con la soluzione semplice e onesta: resettare la selezione.
#include <vector>
void RemoveTaskByIndex(AppState& state, std::size_t idx) {
if (idx >= state.tasks.size()) return;
state.tasks.erase(state.tasks.begin() + static_cast<std::ptrdiff_t>(idx));
state.selectedIndex = std::nullopt; // reset: gli indici potrebbero essersi spostati
}
Sì, sembra "duro", ma è già una soluzione matura: meglio perdere la selezione che selezionare in silenzio l'elemento sbagliato.
8. Sostituto №2: memorizzare id e riottenere l'accesso
Quando un elemento ha bisogno di un "passaporto" semantico (identità), l'indice diventa un cattivo candidato: parla di posizione, non di entità. Allora memorizziamo selectedId e, quando serve, troviamo ogni volta l'elemento aggiornato.
Stato:
#include <optional>
struct AppState {
std::vector<Task> tasks;
std::optional<int> selectedId; // "selezionato il task con questo id"
};
Selezione:
void SelectTask(AppState& state, int id) {
state.selectedId = id;
}
Accesso "al momento":
Task* GetSelectedTaskPtr(AppState& state) {
if (!state.selectedId) return nullptr;
for (Task& t : state.tasks) {
if (t.id == *state.selectedId) return &t;
}
return nullptr;
}
Uso:
#include <iostream>
void MarkSelectedDone(AppState& state) {
Task* t = GetSelectedTaskPtr(state);
if (!t) {
std::cout << "No selected task\n"; // No selected task
return;
}
t->done = true;
}
Punto chiave: ritorniamo un puntatore come accesso temporaneo, ma non lo conserviamo nello stato. Il puntatore vive "dentro il comando", non "tra i comandi".
Tabella‑promemoria: cosa memorizzare invece di T*
In questa sezione è utile fissare una volta sola come scegliere la rappresentazione del "riferimento a elemento" senza usare puntatori o riferimenti nello stato. È simile a decidere come scrivere un indirizzo per la consegna: puoi scrivere "coordinate sulla mappa" (indice), oppure il "numero d'ordine" (id) e risalire alle coordinate in seguito.
| Cosa vuoi esprimere | Idea sbagliata | Idea funzionante | Perché |
|---|---|---|---|
| "Hai selezionato un elemento della lista proprio ora" | memorizzare T* in AppState | memorizzare std::optional<std::size_t> | l'indice non si rompe a causa della reallocation |
| "Hai selezionato un elemento e deve rimanere lo stesso anche se la lista cambia" | T*/size_t senza logica aggiuntiva | memorizzare std::optional<Id> | l'identità è separata dalla posizione |
| "Potrebbe non esserci un elemento selezionato" | size_t = -1 | std::optional<std::size_t> / std::optional<Id> | l'assenza è esplicita nel tipo |
| "Devi fornire accesso all'elemento all'esterno" | restituire T& senza garanzie | restituire T* (nullable) e usarlo subito | il pointer evidenzia "potrebbe non esserci" e "non conservarmi a lungo" |
Piccolo incastro della logica dell'app
Ora compiliamo un frammento che puoi immaginare integrato nel tuo ciclo CLI di comandi. Non ci sarà input complesso — solo la dimostrazione delle relazioni.
#include <iostream>
#include <optional>
#include <string>
#include <vector>
struct Task {
int id{};
std::string title;
bool done{false};
};
struct AppState {
std::vector<Task> tasks;
std::optional<int> selectedId;
};
Task* FindTaskById(std::vector<Task>& tasks, int id) {
for (Task& t : tasks) if (t.id == id) return &t;
return nullptr;
}
Task* GetSelectedTask(AppState& state) {
if (!state.selectedId) return nullptr;
return FindTaskById(state.tasks, *state.selectedId);
}
Esempio di "comandi":
void PrintSelected(const AppState& state) {
if (!state.selectedId) {
std::cout << "Selected: none\n"; // Selected: none
return;
}
std::cout << "Selected id: " << *state.selectedId << "\n"; // es.: Selected id: 2
}
void MarkSelectedDone(AppState& state) {
Task* t = GetSelectedTask(state);
if (!t) return;
t->done = true;
}
Nota sullo stile: stampiamo "selected id", non l'indirizzo. L'indirizzo non è importante per l'utente; conta l'entità.
Nota importante: i "puntatori raw" qui sono pericolosi non per via di delete
Quando il principiante sente "evita puntatori raw", pensa spesso solo a new/delete e alle perdite di memoria. Il problema di oggi è di un'altra categoria: un puntatore può diventare dangling anche senza new e senza delete nel tuo codice. Il container gestisce la memoria, rialloca il buffer e sposta gli elementi. Il tuo indirizzo salvato nel frattempo è il "biglietto per il treno già partito".
Ecco perché la regola è formulata così: "non memorizzare puntatori agli elementi dei container". Questo riguarda la validità, non la proprietà.
9. Errori tipici
Errore №1: "Memorizzo const Task*, quindi è sicuro".
const impedisce di modificare l'oggetto tramite il puntatore, ma non garantisce che l'oggetto resti allo stesso indirizzo. Dopo push_back o erase il tuo const Task* può altrettanto facilmente diventare dangling. const riguarda i permessi di scrittura, non la durata di vita o la stabilità dell'indirizzo.
Errore №2: Salvare il puntatore "per poco", poi per sbaglio fare push_back.
Spesso succede così: trovi un Task* p, poi "tra le altre cose" aggiungi un nuovo task (es. la logica "se vuoto — aggiungi un template"), e poi riusi p. Il problema è che push_back potrebbe provocare reallocation non sempre, ma a volte, perciò l'errore è intermittente e difficile da debuggare.
Errore №3: Passare all'indice ma dimenticare i controlli di bordo.
L'indice non è magia. È comodo perché non dipende dall'indirizzo, ma usarlo richiede attenzione: i < tasks.size() deve essere vicino all'uso, non un controllo fatto "da qualche parte, tempo prima". Soprattutto se l'indice è memorizzato nello stato e può diventare obsoleto dopo cancellazioni.
Errore №4: Passare all'indice e non considerare erase, ottenendo "selezione dell'elemento sbagliato".
Questo non è UB, il programma non crasha, ma il comportamento diventa strano: dopo la rimozione di qualcosa all'inizio della lista la selezione può riferirsi a un altro task. Se usi l'indice come marcatore persistente devi o aggiornarlo alle cancellazioni, o resettare la selezione, o memorizzare id e cercare di nuovo.
Errore №5: Restituire all'esterno Task&/Task* e permettere al codice chiamante di conservarlo.
Anche se internamente in AppState non memorizzi puntatori, puoi accidentalmente "far filtrare" questo problema tramite l'API: una funzione ha restituito Task*, il codice esterno lo ha conservato, poi aggiungi un task — e tutto si rompe. Buona regola: se restituisci un puntatore a un elemento di vector, considera che va usato subito, e progetta l'API per rendere questo ovvio.
GO TO FULL VERSION