1. Modello di esecuzione e pila delle chiamate
Quando scrivi doSomething();, sembra che il computer semplicemente «salti» in un'altra parte del programma, faccia il lavoro e torni. Ma in che modo esattamente torna? Da dove «ricorda» dove tornare? E perché le variabili locali all'interno della funzione spariscono dopo il return, come se fossero state «spente dalla realtà»?
Ora costruiremo un modello semplice e funzionante dell'esecuzione, che poi sarà molto utile (soprattutto quando passeremo alla ricorsione, ma per ora senza dettagli).
A livello principiante è sufficiente sapere: l'esecuzione del programma è una sola «traccia» che procede dall'alto verso il basso e talvolta fa «diramazioni» nelle funzioni. Per evitare che queste diramazioni diventino un labirinto senza uscita, il processo di esecuzione ha una struttura di memoria speciale — la pila delle chiamate.
Pila delle chiamate: pila di piatti e regola LIFO
La parola «stack» — in italiano «stack» o pila — è letteralmente una «pila». Immagina che ogni chiamata di funzione metta sul tavolo un piatto con un foglietto: «io sono la chiamata della funzione foo(), ecco le mie variabili locali, ecco i miei parametri, e questo è l'indirizzo — dove tornare». Quando la funzione finisce, il piatto viene rimosso.
È importante che venga rimosso proprio l'ultimo piatto messo. Questa regola si chiama LIFO: Last In, First Out — «ultimo entrato, primo uscito».
Questa regola si adatta perfettamente alle chiamate di funzione: se main() ha chiamato f(), e f() ha chiamato g(), allora ha senso che prima termini g(), poi riprenda f(), e solo dopo riprenda main().
Frame di pila: cosa è memorizzato «sul piatto»
In questa sezione smonteremo un po' il piatto. Non a livello di processore o registri (quello è un altro universo), ma a livello del modello che aiuta a scrivere codice senza misteri.
Il frame di pila sono i dati che appartengono a una specifica chiamata di funzione: i suoi parametri, le variabili locali e la cosa più importante — il punto di ritorno (dove continuare l'esecuzione dopo il return).
È comodo immaginare il frame di pila come una tabella:
| Parte del frame di pila | Cos'è | Perché serve |
|---|---|---|
| Parametri | ciò che è arrivato nella funzione | perché la funzione sappia con cosa lavorare |
| Variabili locali | ciò che è stato dichiarato dentro le {} | posto di lavoro temporaneo della funzione |
| Punto di ritorno | l'«indirizzo» nella funzione chiamante | per continuare l'esecuzione dopo la fine della chiamata |
| Di servizio | «parti interne» | non abbiamo bisogno di modellarle adesso |
Idea chiave: ogni chiamata di funzione ha il proprio frame di pila. Anche se sono due chiamate della stessa funzione, sono due insiemi distinti di variabili locali.
Mini-dimostrazione: ordine di entrata e uscita dalle funzioni
Ora facciamo l'esperimento più onesto: stampiamo semplicemente start/end e osserviamo in quale ordine appaiono. Questo esempio è piccolo, ma illustra perfettamente la regola LIFO.
#include <iostream>
void h() {
std::cout << "h: start\n"; // h: start
std::cout << "h: end\n"; // h: end
}
void g() {
std::cout << "g: start\n"; // g: start
h();
std::cout << "g: end\n"; // g: end
}
void f() {
std::cout << "f: start\n"; // f: start
g();
std::cout << "f: end\n"; // f: end
}
int main() {
f();
}
Se esegui, vedrai più o meno questo:
f: start
g: start
h: start
h: end
g: end
f: end
Questa è la «pila di piatti» in azione: finché h() non termina, g() è «in pausa» esattamente sulla riga h();, e f() è sulla riga g();.
Punto di ritorno: perché la funzione chiamante «si blocca» sulla chiamata
Quasi tutti i principianti inizialmente hanno l'idea strana che le funzioni lavorino «in parallelo». È normale — il cervello fa semplificazioni. Ma nel codice single-thread tutto è più semplice: non c'è parallelismo, l'esecuzione è strettamente sequenziale.
Quando viene eseguita la riga:
int b = inner(a + 1);
la funzione chiamante (outer) deve ricordare che dopo che inner(...) termina, bisogna continuare con l'assegnazione al b e poi eseguire le righe successive. Per questo motivo nel frame di pila si conserva il punto di ritorno.
Guardiamo un esempio:
#include <iostream>
int inner(int x) {
std::cout << "inner: x=" << x << '\n'; // inner: x=11
return x * 2;
}
int outer(int a) {
std::cout << "outer: before\n"; // outer: before
int b = inner(a + 1);
std::cout << "outer: after\n"; // outer: after
return b + 3;
}
int main() {
std::cout << outer(10) << '\n'; // 25
}
Qui outer letteralmente «si arresta» sulla chiamata a inner, aspetta il return, riceve il valore e poi continua.
Vita delle variabili locali: vivono fino alla fine della chiamata
Ora un punto importante, che causa molti bug dall'aspetto «fantasmatico»: le variabili locali non esistono «per sempre», ma finché vive il frame di pila. Quando la funzione esegue il return, il suo frame viene rimosso dalla pila e le variabili locali di quella chiamata cessano di esistere.
Nel linguaggio del standard C++ questo è legato al concetto di automatic storage duration — così si descrivono le tipiche variabili locali che vivono all'interno di un blocco e vengono distrutte all'uscita da quel blocco.
Esempio:
#include <iostream>
int add10(int x) {
int y = x + 10; // variabile locale
return y;
}
int main() {
std::cout << add10(1) << '\n'; // 11
std::cout << add10(5) << '\n'; // 15
}
Qui non c'è «memoria tra le chiamate»: y viene creato di nuovo a ogni ingresso in add10(). Non si «accumula», non «invecchia», non «ricorda vite passate».
Due chiamate della stessa funzione — due mondi diversi di variabili locali
Questo sembra ovvio finché non ti trovi nella situazione «ma la mia variabile era 7, perché ora è di nuovo 0?». La risposta: è una variabile diversa, appartenente a un altro frame di pila.
Facciamo un piccolo trucco: stampiamo il valore di una variabile locale. (Non abbiamo ancora studiato a fondo i puntatori, quindi eviteremo gli indirizzi — la logica è chiara anche senza.)
#include <iostream>
void demo(int x) {
int local = x * 10;
std::cout << "local=" << local << '\n'; // local=20 (poi local=50)
}
int main() {
demo(2);
demo(5);
}
Anche senza guardare gli indirizzi in memoria, l'idea è la stessa: ogni chiamata crea il proprio local.
2. Tecnica pratica: tracciare le chiamate con rientri
In questa sezione realizzeremo qualcosa che piace sia ai docenti sia ai debugger: stamperemo gli ingressi/uscite dalle funzioni in modo che la profondità delle chiamate sia visibile a occhio. È quasi una «pila manuale», solo senza magia.
Creeremo due piccole funzioni trace_enter e trace_exit, e terremo la profondità in una variabile depth passata per riferimento (così che le modifiche siano visibili a tutta la catena di chiamate).
#include <iostream>
#include <string>
void trace_enter(const std::string& name, int& depth) {
for (int i = 0; i < depth; ++i) std::cout << " ";
std::cout << "-> " << name << '\n';
++depth;
}
void trace_exit(const std::string& name, int& depth) {
--depth;
for (int i = 0; i < depth; ++i) std::cout << " ";
std::cout << "<- " << name << '\n';
}
Nota: depth non è «parte della pila», è solo la nostra torcia didattica. Mettiamo in evidenza ciò che già avviene dentro il programma.
3. Mini-progetto: lista di cose da fare in console e main snello
Da ora collegheremo gli esempi in una piccola applicazione facilmente estendibile. Non faremo nulla di troppo complesso: solo una lista di stringhe (std::vector<std::string>) e i comandi add/list/exit. L'obiettivo principale è vedere come le chiamate di funzione «vivono» nella pila e perché è bene mantenere il main() snello.
Struttura dell'applicazione
Iniziamo dallo scheletro:
#include <iostream>
#include <string>
#include <vector>
void run_app() {
std::vector<std::string> tasks;
std::cout << "TodoApp started\n"; // TodoApp started
}
int main() {
run_app();
}
Per ora è noioso — ma è un buon «scheletro»: main() non si occupa della logica di business, ma passa il controllo a run_app().
Aggiungiamo i comandi e osserviamo la pila tramite la traccia
Ora creiamo le funzioni: mostrare il menu, leggere il comando, elaborare il comando. Aggiungeremo la traccia per vedere come run_app() chiama le altre funzioni.
#include <iostream>
#include <string>
void trace_enter(const std::string& name, int& depth) {
for (int i = 0; i < depth; ++i) std::cout << " ";
std::cout << "-> " << name << '\n';
++depth;
}
void trace_exit(const std::string& name, int& depth) {
--depth;
for (int i = 0; i < depth; ++i) std::cout << " ";
std::cout << "<- " << name << '\n';
}
void print_menu(int& depth) {
trace_enter("print_menu", depth);
std::cout << "Commands: add, list, exit\n";
trace_exit("print_menu", depth);
}
std::string read_command(int& depth) {
trace_enter("read_command", depth);
std::string cmd;
std::getline(std::cin, cmd);
trace_exit("read_command", depth);
return cmd;
}
L'idea è semplice: quando vedrai l'output con i rientri, avrai una «radiografia» per comprendere la nidificazione delle chiamate.
Colleghiamo tutto in run_app e osserviamo i frame di pila
Ora assembleremo la catena: main() → run_app() → print_menu() → read_command(). Anche senza ricorsione la pila già funziona ed è importante.
#include <iostream>
#include <string>
#include <vector>
void trace_enter(const std::string& name, int& depth) {
for (int i = 0; i < depth; ++i) std::cout << " ";
std::cout << "-> " << name << '\n';
++depth;
}
void trace_exit(const std::string& name, int& depth) {
--depth;
for (int i = 0; i < depth; ++i) std::cout << " ";
std::cout << "<- " << name << '\n';
}
void print_menu(int& depth) {
trace_enter("print_menu", depth);
std::cout << "Commands: add, list, exit\n"; // Commands: add, list, exit
trace_exit("print_menu", depth);
}
std::string read_command(int& depth) {
trace_enter("read_command", depth);
std::string cmd;
std::getline(std::cin, cmd);
trace_exit("read_command", depth);
return cmd;
}
void run_app() {
int depth = 0;
trace_enter("run_app", depth);
std::vector<std::string> tasks;
print_menu(depth);
std::cout << "Enter command: "; // Enter command:
std::string cmd = read_command(depth);
std::cout << "You typed: " << cmd << '\n'; // per esempio: You typed: add
trace_exit("run_app", depth);
}
int main() {
run_app();
}
Se digiti add, vedrai una schermata simile a questa:
-> run_app
-> print_menu
Commands: add, list, exit
<- print_menu
Enter command: -> read_command
<- read_command
You typed: add
<- run_app
Questa è la visualizzazione della pila: mentre siamo in read_command(), le funzioni precedenti non scompaiono — sono «in attesa» di continuare nel loro punto di ritorno.
4. Cosa significa realmente return
Qui è importante fermarsi e spiegare a parole cosa fa return. Non «teletrasporta» il valore. Termina la funzione corrente, il suo frame di pila viene rimosso, e l'esecuzione continua nella funzione chiamante al punto di ritorno salvato.
Praticamente questo comporta due cose. Primo, tutto ciò che era locale nella funzione corrente non esiste più: le variabili se ne vanno insieme al frame. Secondo, la funzione chiamante torna ad essere «attiva» e l'esecuzione prosegue dalla riga immediatamente successiva alla chiamata.
Per questo percepiamo le variabili locali come «temporanee»: sono comode, ma non possono essere usate dopo l'uscita dalla funzione, perché non sopravvivono alla fine della chiamata. Il legame tra oggetti locali con durata di memorizzazione automatica e l'uscita dallo scope è una delle fondamenta del modello di esecuzione.
5. Schema visivo della pila durante chiamate nidificate
Per fissare definitivamente, disegneremo uno schema semplice. Non riguarda il layout esatto in byte, ma il senso.
flowchart TD
A["main()"] --> B["run_app()"]
B --> C["print_menu()"]
B --> D["read_command()"]
E nel momento in cui siamo dentro read_command(), la «pila» appare più o meno così:
[ top ]
read_command frame
run_app frame
main frame
[ bottom ]
Quando read_command() esegue return, il suo frame viene rimosso e ci ritroviamo nuovamente in run_app().
6. Errori tipici
Errore n.1: aspettarsi che la funzione chiamante continui a eseguire «in parallelo».
A volte sembra che run_app() possa «fare ancora qualcosa» mentre viene eseguito read_command(). Nel codice single-thread non è così: la funzione chiamante è in pausa fino al return. Tenendo a mente la «traccia di esecuzione unica» e il «punto di ritorno», le confusioni diminuiscono.
Errore n.2: pensare che le variabili locali «ricordino» il valore tra le chiamate.
La variabile locale appartiene al frame di pila specifico. Una nuova chiamata = un nuovo frame = nuove variabili locali. Se devi mantenere stato tra le chiamate, quella deve essere una variabile «più in alto» (per esempio in run_app()) oppure un parametro/contenitore passato tra le funzioni.
Errore n.3: confondere l'ordine di chiamata con l'ordine di terminazione.
Le chiamate vanno «in profondità», le terminazioni vanno «all'esterno». Quindi nella catena main → run_app → read_command terminerà prima read_command, poi continuerà run_app, e solo dopo terminerà main. Questa è la regola LIFO — «ultimo entrato, primo uscito» — utile da ripetere come mantra, senza mistica.
Errore n.4: usare return come «semplice uscita» senza comprendere le conseguenze.
Un return precoce spesso semplifica il codice, ma è importante ricordare: termina subito la funzione, il frame viene rimosso, le variabili locali vengono distrutte e la funzione chiamante riprende. Se prima del return non hai aggiornato lo stato necessario (per esempio non hai scritto il risultato in un contenitore che vive più in alto), allora è «troppo tardi».
Errore n.5: cercare di spiegare la pila come una «memoria globale magica».
A volte i principianti dicono: «le variabili stanno da qualche parte». È più utile mantenere un modello concreto: ogni chiamata ha il suo frame di pila con parametri, variabili locali e punto di ritorno. Questo spiega l'ordine di esecuzione, la vita delle variabili e perché la nidificazione delle chiamate funziona in modo prevedibile.
GO TO FULL VERSION