CodeGym /Corsi /C++ SELF /Restituire un riferimento a una variabile locale

Restituire un riferimento a una variabile locale

C++ SELF
Livello 46, Lezione 3
Disponibile

1. Il problema: «l'indirizzo c'è, ma l'oggetto non c'è più»

Se il C++ fosse un film horror, il dangling reference sarebbe il momento in cui il protagonista apre la porta con sicurezza e c'è il vuoto... ma lui comunque fa un passo. Nel codice è la stessa cosa: avete un riferimento o un puntatore (cioè la «porta»), ma dietro quella porta non c'è più un oggetto vivo.

Importante per l'intuizione: il tipo T* o T& dice solo «come accedere», ma non garantisce «che cosa è ancora vivo». Il compilatore spesso non può dimostrare che avete restituito una «riferimento al defunto», quindi accetta il codice — e da lì comincia la lotteria dell'UB.

Dangling pointer / dangling reference è un puntatore o riferimento che punta a un oggetto la cui durata di vita è terminata (l'oggetto è stato distrutto).

La causa più comune è il ritorno di riferimento/puntatore a una variabile locale della funzione.

2. Come «muore» una variabile locale al momento del return

Adesso un punto importante: il return non è solo «inviare un valore all'esterno». È anche «chiudere il negozio», cioè uscire dal blocco della funzione. E quindi distruggere tutte le variabili locali che vivevano in quella funzione.

Immaginate che la funzione sia una camera d'albergo. Le variabili locali sono gli ospiti. Finché siete dentro la funzione, gli ospiti sono lì. Appena uscite (return) la cameriera (i distruttori) arriva e sfratta tutti. E voi cercate di restituire all'esterno «il numero della camera dell'ospite» sperando che rimanga.

Piccolo schema (semplificato ma funzionale):

flowchart TD
    A[Ingresso nella funzione] --> B[Vengono create le variabili locali]
    B --> C[Lavoriamo con esse]
    C --> D[return ...]
    D --> E[Uscita dalla funzione]
    E --> F[Le variabili locali vengono distrutte]
    F --> G[All'esterno è rimasto un riferimento/puntatore a «niente»]

Idea chiave: dopo il return le locali sono già distrutte, anche se «subito dopo» usate quello che avete restituito.

3. Return sbagliati: dove nasce il dangling

Anti-esempio: ritornare un T* su una variabile locale

Ecco un esempio che sembra «logico» fino al momento in cui capite che la vita della variabile è finita.

#include <iostream>

int* bad_ptr() {
    int x = 42;
    return &x; // dangling: x verrà distrutto all'uscita dalla funzione
}

int main() {
    int* p = bad_ptr();
    std::cout << *p << '\n'; // UB: può stampare 42, oppure caratteri casuali, oppure crashare
}

Perché il compilatore spesso non si lamenta? Perché a livello di tipi tutto è coerente: &x è un int*. Formalmente restituisci davvero un puntatore a int. Ma il linguaggio non è obbligato a salvarvi se restituite l'indirizzo di qualcosa che sta per sparire.

E sì, la parte più insidiosa dell'UB è: «a volte funziona». Specialmente in debug. Specialmente «sulla mia macchina». Specialmente fino al rilascio.

Anti-esempio: ritornare un T&

Con i riferimenti la situazione è ancora più drammatica: semanticamente un riferimento «dovrebbe» riferirsi a un oggetto. Ma C++ non verifica che l'oggetto sia vivo — si limita a credervi.

#include <iostream>

int& bad_ref() {
    int x = 7;
    return x; // dangling: x verrà distrutto all'uscita dalla funzione
}

int main() {
    int& r = bad_ref();
    std::cout << r << '\n'; // UB
}

Nota la trappola psicologica: il riferimento sembra «più sicuro» (dopotutto non è nullptr). Ma è una falsa sensazione. Un riferimento può essere dangling tanto quanto un puntatore.

Perché «lo uso subito dopo il return» non salva

Una speranza comune dei principianti è: «Lo dereferenzio subito, alla riga successiva, ce la faccio!»

Purtroppo, «subito dopo il return» è già «dopo l'uscita dalla funzione». Nel momento in cui il controllo è tornato al chiamante, le variabili locali sono già state distrutte.

Cioè questo:

int* p = bad_ptr();
std::cout << *p << '\n';

non significa «ci sono riuscito». Significa «sto leggendo attraverso un puntatore che punta a un oggetto già distrutto».

4. Default corretto: restituire per valore

Buona notizia: nella maggior parte dei casi pratici il contratto più giusto e più semplice è restituire per valore.

Partiamo dall'esempio base:

#include <iostream>

int good_value() {
    int x = 42;
    return x; // OK: restituisce una copia del valore
}

int main() {
    int v = good_value();
    std::cout << v << '\n'; // 42
}

Qui non importa che x sia locale. Hai inviato all'esterno un valore, non «un riferimento all'ospite della stanza».

E un punto importante per chi teme per le prestazioni: nel C++ moderno il ritorno per valore viene spesso ottimizzato molto bene (copy elision/NRVO). Ma anche senza ottimizzazioni, prima viene la correttezza. Un programma che «cade velocemente» non è considerato veloce.

5. Quando è comunque accettabile restituire riferimento/puntatore

A volte un riferimento o un puntatore è una buona interfaccia. Ma allora dovete avere una risposta solida alla domanda: chi è il proprietario dei dati e quanto a lungo vive.

Se la funzione restituisce un riferimento, questo quasi sempre significa: «sto restituendo un riferimento a un oggetto che vive da qualche altra parte, fuori dalla funzione».

Il caso più chiaro è restituire un riferimento a qualcosa passato come parametro per riferimento:

#include <string>

const std::string& pick_name(const std::string& a, const std::string& b, bool first) {
    return first ? a : b; // OK: a e b vivono nel chiamante (se il chiamante li conserva)
}

Qui la sicurezza non è magica ma contrattuale: il chiamante deve assicurarsi di non distruggere a/b mentre usa il risultato.

E questo è un buon punto di riflessione: riferimento/puntatore è un «prestito», e per i prestiti serve disciplina.

6. Promemoria: quali return sono di solito sicuri

Per non tenere tutto a mente, è utile avere una semplice «cheat sheet» (non una dogma, ma un buon punto di partenza):

Cosa si restituisce Di solito sicuro? Senso (contratto)
T
Sì «Ecco un risultato autonomo, è vostro»
std::optional<T>
Sì «Il risultato può non esserci, ma se c'è — è vostro»
T& / const T&
Qualche volta «Do un riferimento a un oggetto di qualcuno; non uccidete il proprietario»
T*
Qualche volta «L'indirizzo può essere nullptr; il proprietario è da qualche parte fuori»
T* na lokalnuyu peremennuyu
No «Do l'indirizzo di qualcosa che sparirà subito»

7. Esempio dall'app TaskBook: bug tipico

Fingiamo di avere una semplice applicazione console didattica che conserva una lista di task. Non la riscriviamo da zero, aggiungiamo solo alcune funzioni per mostrare un errore tipico nella pratica.

Supponiamo che il modello per una task sia:

#include <string>

struct Task {
    int id{};
    std::string title;
    bool done{};
};

E le task sono in un vettore:

#include <vector>

std::vector<Task> tasks;

Ora il reale desiderio: «Trovare una task per id». Un principiante a volte scrive cosi:

#include <vector>

const Task& find_task_bad(const std::vector<Task>& tasks, int id) {
    Task temp{ id, "not found", false };
    for (const Task& t : tasks) {
        if (t.id == id) return t;
    }
    return temp; // dangling: temp è locale
}

Questo sembra quasi plausibile: «se non trovo, ritorno un segnaposto temporaneo». Ma temp è una variabile locale. Appena la funzione finisce, temp viene distrutto. Il riferimento rimane, ma l'oggetto non esiste più.

Questo è il bug che può manifestarsi «dopo una settimana», quando cambiate leggermente le ottimizzazioni del compilatore, e improvvisamente la stringa "not found" diventa un insieme di caratteri casuali.

8. Come correggere: due opzioni sicure

Correzione: restituire std::optional<Task> per valore

Se la task può non essere trovata, è naturale esprimere questo tramite std::optional.

#include <optional>
#include <vector>

std::optional<Task> find_task_value(const std::vector<Task>& tasks, int id) {
    for (const Task& t : tasks) {
        if (t.id == id) return t; // copia di Task
    }
    return std::nullopt;
}

Il senso del contratto ora è cristallino: la funzione o restituisce un oggetto autonomo Task, oppure segnala «nessun risultato». Nessun riferimento a memoria altrui. Nessuna dipendenza dalla durata di vita.

Correzione: restituire un puntatore all'elemento del vettore (nullable)

A volte non volete copiare la Task (es. la struttura è grande). Allora potete restituire un puntatore a un elemento all'interno del contenitore passato:

#include <vector>

const Task* find_task_ptr(const std::vector<Task>& tasks, int id) {
    for (const Task& t : tasks) {
        if (t.id == id) return &t; // indirizzo dell'elemento che vive in tasks
    }
    return nullptr;
}

Questo codice non restituisce l'indirizzo di una variabile locale, e questo è l'essenziale. Ma appare un contratto: il puntatore è valido finché tasks esiste e finché il contenitore non viene modificato così da spostare gli elementi (dell'invalidazione avete già sentito parlare dalla lezione su vector). Per lo scenario «leggi e usa subito» di solito va bene.

Esempio d'uso:

#include <iostream>

void print_task(const Task* t) {
    if (!t) {
        std::cout << "Task not found\n";
        return;
    }
    std::cout << t->id << ": " << t->title << '\n';
}

Notate come nullptr ci costringa a gestire onestamente l'assenza del risultato. Non è «-1 come id magico», ma un contratto leggibile e normale.

9. Come sospettare rapidamente un dangling dal codice

C'è una regola-paranoia semplice (in senso buono): se vedete return &x; o return x;, dove x è una variabile locale, e il tipo di ritorno è riferimento/puntatore, allora nella testa deve scattare il segnale «controlla la durata di vita».

A volte è utile dirsi esplicitamente: «Dove vive questo oggetto?»
Se la risposta è: «dentro questa funzione» — allora non si può ritornare riferimento/puntatore ad esso.

Se volete individuare questi punti più velocemente, aiuta il trucco della «rinominazione per onestà». Per esempio, invece di temp chiamarlo will_die_soon. Scherzo, naturalmente... ma a volte salva davvero: il cervello smette di ingannarsi con nomi «neutri».

10. Errori tipici

Errore №1: «Restituire un riferimento a una variabile locale — è più veloce che copiare».
È una delle ottimizzazioni più costose nella vita di uno sviluppatore: guadagni nanosecondi ipotetici e ottieni UB. Se l'oggetto è creato dentro la funzione, il default sicuro è restituirlo per valore. Il C++ moderno spesso ottimizza questo meglio dei vostri tentativi manuali di «risparmiare copie».

Errore №2: «Creo un oggetto locale e poi ritorno il suo &obj — un puntatore è solo un indirizzo».
Sì, un puntatore è un indirizzo. Ma l'indirizzo può puntare a memoria dove l'oggetto non esiste più. La memoria può essere riutilizzata, sovrascritta, e a volte sembra ancora simile al vecchio valore, rendendo il bug particolarmente subdolo.

Errore №3: «Se non trovo — restituisco un riferimento a un segnaposto temporaneo».
Il pattern «restituire riferimento a temp» sembra bello sulla carta ma fallisce per la durata di vita. Per lo scenario «il risultato può non esserci» è meglio usare std::optional<T>, un T* con nullptr, o un altro modo esplicito per esprimere l'assenza del valore — ma non un riferimento a qualcosa che sparirà.

Errore №4: confondere la sicurezza dei tipi con la sicurezza della durata di vita.
Il codice può essere perfettamente corretto a livello di tipi e tuttavia sbagliato riguardo alla durata di vita. Il compilatore non è telepatico e non deve indovinare cosa volevate. Per questo è un'abitudine del programmatore verificare la durata di vita separatamente dai tipi: «chi è il proprietario?» e «quando viene distrutto?».

Errore №5: «Ho restituito const T&, quindi nessuno potrà rompere niente».
const protegge solo dalla modifica attraverso quel riferimento. Non protegge affatto dalla distruzione dell'oggetto. Si può ottenere un dangling const T& tanto facilmente quanto un dangling T&. const è su «non modificare», non su «vivere per sempre».

1
Compito
C++ SELF, livello 46, lezione 3
Bloccato
Quadrato senza rischio
Quadrato senza rischio
1
Compito
C++ SELF, livello 46, lezione 3
Bloccato
Massimo per riferimento
Massimo per riferimento
1
Compito
C++ SELF, livello 46, lezione 3
Bloccato
Ricerca per indirizzo
Ricerca per indirizzo
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION