CodeGym /Corsi /C++ SELF /Evitiamo di memorizzare puntatori agli elementi dei conta...

Evitiamo di memorizzare puntatori agli elementi dei container

C++ SELF
Livello 41, Lezione 2
Disponibile

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.

1
Compito
C++ SELF, livello 41, lezione 2
Bloccato
Indice della richiesta
Indice della richiesta
1
Compito
C++ SELF, livello 41, lezione 2
Bloccato
Numero selezionato
Numero selezionato
1
Compito
C++ SELF, livello 41, lezione 2
Bloccato
Cancellazione e azzeramento
Cancellazione e azzeramento
1
Compito
C++ SELF, livello 41, lezione 2
Bloccato
Selezione per id
Selezione per id
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION