Riepilogo
Un programma che dà un risultato sbagliato non si corregge cambiando righe a caso, ma indagando. Questa lezione presenta il debugging come un metodo sperimentale: riprodurre il malfunzionamento, formulare un’ipotesi sulla sua causa, progettare un esperimento che la confermi o la scarti, e circoscrivere il punto del difetto; la bisezione lo trova in un numero di passi che cresce come il logaritmo della dimensione del programma. Sviluppa lo strumento (la compilazione con -g e senza ottimizzazione, la configurazione di Visual Studio Code sui tre sistemi e una sessione reale di GDB che mostra ciò che l’editor fa dietro le quinte), i punti di interruzione semplici e condizionali, i passi, lo stack delle chiamate e l’ispezione dello stato, con un esempio centrale: la mediana che sbaglia solo con un numero pari di dati. Chiude con le strategie che valgono più dello strumento (ridurre il caso, cercare l’ultimo punto corretto, verificare invarianti con assert, leggere il rapporto del sanitizer) e con tre casi: un errore di uno, una ricorsione che non termina e un riferimento invalidato.
Obiettivi di apprendimento
- Configurare la compilazione con informazioni di debug (
-g, senza ottimizzazione) e avviare il debugger da Visual Studio Code sul proprio sistema. - Usare punti di interruzione semplici e condizionali, l’esecuzione passo passo (entrare, saltare, uscire) e l’ispezione di variabili, di espressioni e dello stack delle chiamate.
- Applicare una strategia sistematica (riprodurre, ridurre, bisecare, formulare e mettere alla prova ipotesi) per localizzare un difetto dato, documentando ogni ipotesi.
- Combinare il debugger con i sanitizer e con i test per localizzare un errore di memoria a partire dal suo rapporto.
Fare il debug è indagare
Il difetto e il malfunzionamento
Il malfunzionamento è ciò che si osserva: un risultato sbagliato, un programma che non termina, un rapporto del sanitizer. Il difetto (o errore) è la causa: una riga, una condizione o una decisione di progetto che non corrisponde alla specifica. Il malfunzionamento si vede; il difetto bisogna trovarlo, e può essere lontano da esso. Ne segue che correggere dove si vede il malfunzionamento, per esempio sottraendo 2 al risultato perché «viene sempre 2 in più», corregge il sintomo e lascia la causa, che tornerà con altri dati.
Se è così, fare il debug è un’indagine sulla causa, e il suo metodo è quello delle scienze sperimentali:
- Osservare e riprodurre. Descrivere con precisione il malfunzionamento (con quale input, che cosa ci si aspettava, che cosa si è ottenuto) e fare in modo che si verifichi ogni volta che si vuole. Un difetto che non si riproduce non può essere indagato, né si può verificare che sia stato corretto.
- Formulare un’ipotesi. Un’affermazione sulla causa che possa risultare falsa: «l’ordinamento non ordina», «l’indice del secondo dato centrale è di uno troppo grande».
- Progettare un esperimento. Scegliere un punto del programma e prevedere quale stato si dovrebbe vedere lì se l’ipotesi è vera e quale se è falsa.
- Concludere e circoscrivere. Scartare l’ipotesi o confermarla; in entrambi i casi, il tratto sospetto si riduce. Si ripete finché il tratto è una riga.
Il metodo, però, esige disciplina: cambiare una sola cosa alla volta, annotare ogni ipotesi con il suo risultato, e non toccare il codice finché non si sa che cosa si cerca. Cambiare righe a caso finché l’output sembra buono può nascondere il malfunzionamento senza togliere il difetto.
La mediana di una lista di dati è il dato centrale della lista ordinata se la quantità è dispari, e la media dei due centrali se è pari. La lezione Testare ha mostrato che i test scoprono quando una funzione sbaglia, ma non dove sta il suo difetto. La funzione seguente supera il test con cinque dati e non con quattro.
#include <algorithm>#include <cstddef>#include <print>#include <vector>// Mediana di una lista di dati.// Precondizione: values non è vuoto.// Postcondizione: restituisce il dato centrale della lista ordinata, o la media dei due centrali.double median(std::vector<double> values){ std::ranges::sort(values); const std::size_t n{values.size()}; const std::size_t middle{n / 2}; if (n % 2 == 1) { return values[middle]; } return (values[middle] + values[middle + 1]) / 2.0;}int main(){ const std::vector<double> odd{7.0, 1.0, 5.0, 3.0, 9.0}; const std::vector<double> even{7.0, 1.0, 5.0, 3.0}; std::println("Mediana di {}: {}", odd, median(odd)); std::println("Mediana di {}: {}", even, median(even));}Mediana di [7, 1, 5, 3, 9]: 5Mediana di [7, 1, 5, 3]: 6L’osservazione è precisa: con , ordinati , ci si aspettava e si è ottenuto 6; con cinque dati, il risultato è corretto. Sono possibili almeno tre ipotesi: (H1) l’ordinamento non ordina; (H2) gli indici dei due dati centrali sono sbagliati; (H3) la somma o la divisione sono scritte male. Ciascuna prevede qualcosa di diverso alla riga 17, prima di eseguirla: se H1 è vera, values non è ordinato; se lo è H2, values[middle] e values[middle + 1] non sono 3 e 5; se lo è H3, i due dati sono 3 e 5 e tuttavia il conto dà 6. Che il caso dispari funzioni rende H1 improbabile, ma «improbabile» non è «scartata»; l’esperimento, con il debugger, consiste nel fermare il programma alla riga 17 e guardare.
La bisezione
Quando non c’è un’ipotesi chiara, resta un metodo che non fallisce: la bisezione. Si suppone che lo stato del programma sia corretto all’inizio e sbagliato alla fine, e che in ogni punto intermedio si possa decidere se è corretto (confrontandolo con un valore calcolato a mano, o verificando un invariante). Si esamina lo stato a metà del tratto sospetto: se è corretto, il difetto è nella seconda metà; altrimenti, nella prima. È la ricerca binaria della lezione Cercare e ordinare, applicata alle righe di un programma (o alle iterazioni di un ciclo, o alle versioni di un file).
La figura segue la bisezione di sedici righe con il difetto nella 11; ogni tacca è un esperimento sullo stato dopo quella riga: corretto (✓) o sbagliato (✗).
Dimostrazione
All’inizio, tutte le sedici righe sono sospette: si sa che lo stato è corretto prima della riga 1 e sbagliato dopo la 16.
Dopo la riga 8, lo stato è corretto: le righe da 1 a 8 sono scagionate.
Dopo la 12, lo stato è già scorretto: il difetto è tra la 9 e la 12.
Dopo la 10, corretto: restano la 11 e la 12.
Dopo la 11, scorretto: l’ultimo punto corretto e il primo scorretto sono vicini, e il difetto è nella riga 11.
Il conteggio è quello della ricerca binaria: ogni esperimento divide il tratto per due, sicché esperimenti bastano per righe. Per un programma di mille righe ne bastano dieci, perché . Di qui il vantaggio del metodo: il suo costo cresce come il logaritmo della dimensione, e non richiede di comprendere tutto il programma, ma di saper decidere, in un punto, se lo stato è quello dovuto. Quella decisione, però, è la parte difficile: le specifiche e gli invarianti sono il criterio con cui si giudica ogni stato intermedio.
Caricamento del contenuto…