1. Perché serve il debugger
Quando un programma si comporta in modo strano, per un principiante di solito ci sono due strumenti: il panico e std::cout << "Sono qui!\n";. Il panico è inutile, mentre cout è utile, ma a volte trasforma il codice in una ghirlanda di stampe. Il debugger è uno strumento che permette di mettere in pausa il programma durante l'esecuzione e guardare con calma: «Cosa c'è nelle mie variabili? Quale ramo dell'if è stato realmente scelto? Perché il ciclo non termina?».
Il debugger è utile perché la maggior parte dei bug non è «il compilatore ha sbagliato», ma una delle tre cose:
- il programma viene eseguito in modo diverso da quello che pensate;
- i dati dentro il programma sono diversi da come li immaginate;
- il programma semplicemente non si sta eseguendo (attende input, è finito in un ciclo infinito, è crashato, ecc.).
La stampa (std::cout) vi mostra «qualcosa» solo dopo che avete inserito la stampa, ricompilato il progetto, avviato e catturato il momento. Il debugger vi mostra lo stato proprio adesso, e inoltre potete «scorrere» l'esecuzione in avanti per passi.
Per il modello mentale tenete una semplice idea: «std::cout è come “mi sono lasciato una nota”, mentre il debugger è “ho fermato il tempo e ho guardato dentro la testa del programma”».
2. Cosa fa il debugger in una sessione di debug
Immaginate che il vostro programma sia un treno che viaggia sui binari del codice sorgente. Di solito premete Run — e il treno «vola» da main() fino alla fine, e voi vedete solo le stazioni finali (output in console, risultato, crash).
Il debugger fa qualcosa di diverso: avvia lo stesso treno, ma vi dà il pannello di controllo. Potete:
- fermare il treno;
- continuare il movimento;
- fare un «passo» in avanti;
- guardare il «carico» (i valori delle variabili).
È molto importante capire un principio: il debugger controlla il file eseguibile (il binario), non il «testo del codice». Il testo è la mappa, ma il treno corre sui binari reali — le istruzioni macchina. Per far combaciare la mappa con i binari servono dei «segnali» speciali: i simboli di debug (ne parleremo più avanti).
Ecco uno schema semplice di quello che succede:
flowchart LR
A["Codice sorgente (.cpp)"] --> B["Compilazione (Debug)"]
B --> C["Binario + simboli di debug"]
C --> D["Avvio sotto debugger"]
D --> E["Pausa / ispezione variabili / passi"]
La parola sessione (debug session) significa: avvio del programma sotto il controllo del debugger. È proprio un avvio, solo con un livello di controllo aggiuntivo.
3. Cosa significa «programma in pausa»
La pausa nel debugger spesso spaventa: sembra che il programma «si sia rotto». In realtà la pausa è una modalità di lavoro: l'esecuzione è temporaneamente congelata, così potete osservare tutto con calma.
È importante capire: quando il debugger vi mostra una riga di codice (evidenziata), di solito questo significa: «La prossima cosa che verrà eseguita è questa riga (o questa operazione su questa riga)».
Quindi state guardando un punto nel tempo «un istante prima».
Da dove arriva la pausa
Non entriamo ora nei dettagli dei breakpoint (è una lezione a parte), ma bisogna conoscere il quadro generale. Il programma può entrare in pausa perché:
- avete premuto manualmente Pause (pausa);
- avviando il debug «all'inizio» l'IDE si ferma all'inizio di main() (alcune IDE lo fanno);
- il programma è arrivato in un punto precedentemente marcato (solitamente un breakpoint);
- il programma è terminato (il debugger semplicemente segnala «finito»);
- il programma è crashato (divisione per zero, out-of-bounds, ecc.);
- il programma «aspetta» — in pratica è in attesa di input (caso molto comune).
L'ultimo punto è particolarmente importante per i principianti: il programma non è obbligato a «correre». Può restare onestamente su una riga di input e aspettare che inseriate un valore. Il debugger aiuta a questo: vedete la riga esatta dove è in attesa.
4. Esecuzione passo a passo
Se avete mai messo in pausa un video e sfogliato frame per frame, allora siete praticamente pronti per lo «step». L'esecuzione passo a passo è l'esecuzione del programma a piccole porzioni, per vedere: quale riga è stata eseguita e come sono cambiati i valori.
Nelle diverse IDE i pulsanti hanno nomi leggermente diversi, ma l'idea di base è una sola: ci sono comandi per controllare il ritmo dell'esecuzione.
Per oggi ci basta il concetto di un passo:
- eseguire la prossima operazione «visibile»;
- fermarsi di nuovo;
- permettervi di osservare i valori.
Una tabella di riferimento (senza legarsi a una specifica IDE):
| Azione | Cosa succede | Come si percepisce |
|---|---|---|
| Continue / Resume | Il programma «corre» avanti | Come un avvio normale, ma con la possibilità di fermarsi di nuovo |
| Pause | Il programma si ferma nel punto corrente | Avete letteralmente «congelato» l'esecuzione |
| Stop | La sessione di debug termina | Il programma si chiude (almeno per la sessione corrente) |
| Step (un passo) | Viene eseguita la prossima operazione | «Un fotogramma avanti» al cinema, ma sul codice |
Perché una riga non è sempre un passo
Si vorrebbe credere che il debugger proceda rigorosamente riga per riga, come un insegnante che segna sul registro. Ma la realtà è un po' più furba: il compilatore può riorganizzare e ottimizzare piccoli dettagli, e una singola riga può contenere più azioni.
Perciò una buona abitudine (ripaga al 100%) è scrivere il codice in modo che in una riga ci sia una sola azione chiara, soprattutto se sospettate che quel punto verrà sottoposto a debug.
Per esempio, questo è «pesante»:
int z = (x + y) * (a - b);
E questo è più amichevole per il debugger e per il vostro futuro io:
int sum = x + y;
int diff = a - b;
int z = sum * diff;
Sì, le righe aumentano. Ma potete verificare passo dopo passo ogni idea: «sum è davvero quel valore? diff non è negativo?».
Come ragionare durante il debug passo a passo
Il debug passo a passo può trasformarsi in un inutile «clic-clac» sul pulsante Step, se non vi ponete le domande giuste. Perciò tenete a mente una triade molto pratica.
Prima formulate la domanda: «Perché cnt non cresce?» o «Perché non entro nel ramo if?». Poi fate un passo fino al punto in cui il valore dovrebbe cambiare. Dopo il passo registrate l'osservazione: «cnt è diventato 1 e non cambia più» o «la condizione è risultata false perché done è false».
È quasi come fare il detective, solo che il colpevole è una riga di codice e il suo alibi è robusto: il compilatore l'ha lasciata passare.
5. Simboli di debug e comportamenti strani del debugger
A volte il debugger si comporta come un gatto: fa finta di non conoscervi. Variabili «ottimizzate», passi saltati, righe che non corrispondono. Di solito non è mistero, ma una delle cause qui sotto.
Primo: potete star eseguendo la build sbagliata. Per esempio avete cambiato il codice ma state eseguendo il binario vecchio. Succede più spesso di quanto si pensi: l'IDE potrebbe non aver ricostruito il progetto, potreste aver lanciato il target «vecchio», o essere passati a una configurazione diversa.
Secondo: in Release (o con forti ottimizzazioni) il compilatore può:
- rimuovere una variabile se non è necessaria come «entità» (viene calcolata solo in registri);
- riordinare i calcoli;
- «comprimere» più righe in una sola.
E il debugger sinceramente vi dirà: «non posso garantirvi una bella corrispondenza riga-per-riga».
Regola del giorno: imparate a fare debugging meglio usando la build Debug, dove i simboli di debug sono inclusi e le ottimizzazioni sono disattivate o minime.
6. Mini-esempi per esercitarsi
Adesso faremo quello che vale più di mille parole: daremo al cervello la sensazione di perché servono pausa e step. Gli esempi sono corti: lo scopo non è scrivere un mega-progetto, ma imparare a vedere il movimento del programma.
Esempio 1: «dove sono ora?» — variabili di base
In questo esempio potete avviare il programma sotto debugger e percorrere gli assegnamenti passo passo. Ad ogni passo osservate come cambiano x, y, z.
#include <iostream>
int main() {
int x = 10;
int y = 20;
int z = x + y;
std::cout << z << '\n'; // 30
}
Se fate i passi dovreste vedere un quadro semplice: prima compare x, poi y, poi viene calcolato z. È noioso, ma è l'«alfabeto» del debug: imparate che l'esecuzione è una sequenza di azioni, non magia.
Esempio 2: «il programma è bloccato» e aspetta input
Questo è il caso che regolarmente sottrae ore di vita ai principianti. Il debugger salva mostrando che il programma è fermo sulla riga di input e attende i dati.
#include <iostream>
int main() {
std::cout << "Inserisci n: ";
int n = 0;
std::cin >> n; // qui il programma può rimanere in attesa
std::cout << "n=" << n << '\n'; // per esempio: n=5
}
Se avete avviato sotto debugger e siete arrivati alla riga std::cin >> n;, allora il «passo successivo» non avverrà finché non inserite un numero. Non è un bug. È letteralmente il contratto di input: «fammi avere i dati».
7. Esempio pratico: TodoCLI e un errore logico
Ora colleghiamo il debug a qualcosa di più «reale», simile a un progetto didattico. Immaginiamo di avere una piccola applicazione console TodoCLI: conserva le task in un std::vector, e ogni task ha un flag done.
Non progetteremo oggi architettura o interfacce. Prendiamo solo un frammento: il conteggio delle task completate. E inseriamo volutamente un errore che non rompe la compilazione, ma rompe il risultato — il candidato perfetto per il debugger.
Versione corretta: «come dovrebbe essere»
Prima la variante corretta, così avete un confronto. Nota: il codice è breve e prevedibile, quindi comodo per l'esecuzione passo a passo.
#include <cstddef>
#include <string>
#include <vector>
struct Task {
std::string title;
bool done;
};
int count_done(const std::vector<Task>& tasks) {
int cnt = 0;
for (std::size_t i = 0; i < tasks.size(); ++i) {
if (tasks[i].done) cnt += 1;
}
return cnt;
}
Se mai dovrete fare il debug di questa funzione, vi aspettate una dinamica semplice: i cresce, cnt aumenta solo quando done == true.
Versione con errore: «perché il risultato è sempre strano»
Ecco ora il piccolo errore che sembra quasi identico, compila, ma cambia il significato. Proprio la situazione in cui il debugger è il miglior amico.
#include <cstddef>
#include <string>
#include <vector>
struct Task {
std::string title;
bool done;
};
int count_done(const std::vector<Task>& tasks) {
int cnt = 0;
for (std::size_t i = 0; i < tasks.size(); ++i) {
if (tasks[i].done) cnt =+ 1; // ERRORE: dovrebbe essere cnt += 1;
}
return cnt;
}
Cosa fa cnt =+ 1;? Non è «aggiungi». Assegna il valore +1. Quindi ogni volta che una task è completata assegnate a cnt il valore 1;.
Come il debugger aiuta a scoprirlo passo a passo (meccanica pura): avviate il debug, arrivate al ciclo e fate passi osservando due valori: i e cnt. Vedrete che cnt diventa 1 sulla prima task eseguita… e resta 1 su tutte le successive, anche se logicamente dovrebbe aumentare. A quel punto capirete: «Ah, il problema è la riga che modifica cnt».
Mini-main, per avere qualcosa da debuggare
Per rendere questo frammento «vivo», ecco un piccolo main() che crea le task e stampa il risultato. È volutamente breve, perché alleniamo il debug, non realizziamo un menu completo.
#include <iostream>
#include <vector>
int count_done(const std::vector<struct Task>& tasks); // assumiamo la dichiarazione sopra
int main() {
std::vector<Task> tasks{
{"Read docs", true},
{"Fix bug", true},
{"Go outside", false}
};
std::cout << count_done(tasks) << '\n'; // ci aspettiamo 2
}
Se nella vostra versione count_done c'è l'errore =+, l'output sarà 1, ed è un ottimo motivo per aprire il debugger e vedere dove esattamente la logica non corrisponde alle aspettative.
8. Errori tipici
Errore #1: fare debug di un programma che non è stato ricompilato (o ricompilato nella configurazione sbagliata).
Scenario molto spiacevole: avete corretto una riga, siete sicuri che il bug sia sparito, avviate il debug — e tutto è come prima. Spesso la causa è banale: state eseguendo il binario vecchio. Abituatevi, prima del debug, a verificare che state davvero lanciando la build Debug e che il progetto è stato ricostruito.
Errore #2: pensare che «se il debugger si è fermato, allora il programma è rotto».
La pausa è una modalità di lavoro. Il debugger può fermarsi su vostra richiesta, all'inizio di main(), o perché il programma ha raggiunto un punto di controllo. Prima guardate dove e perché siete fermi, e solo dopo decidete «è un bug».
Errore #3: confondere «programma bloccato» con «programma in attesa di input».
Quando l'esecuzione è su std::cin >> ... o su std::getline(...), il programma non è «impallato», sta onestamente aspettando dati. Il debugger serve proprio a mostrare che la riga corrente è un input. Se non inserite nulla, il passo successivo non avverrà.
Errore #4: fare passi nel codice senza obiettivo e affogare nei dettagli.
Se premete Step 200 volte a caso, vi stancate e finite per odiare il debugger (che in realtà è utile). Prima definite quale valore vi sembra sospetto, dove dovrebbe cambiare, e andate lì. Anche per un principiante questo fa risparmiare molto tempo.
Errore #5: aspettarsi una corrispondenza riga-per-riga perfetta in una build ottimizzata.
Nella build Release il compilatore può «incollare» variabili e riorganizzare i calcoli. Ne consegue che vedrete salti tra le righe e variabili locali «scomparse». Per imparare e per la maggior parte delle indagini usate Debug: così è più semplice per il debugger collegare l'esecuzione al sorgente e mostrare i valori.
GO TO FULL VERSION