Debugging

Il debugging è l’insieme di tecniche che mirano a localizzare e rimuovere le anomalie che sono la causa di malfunzionamenti riscontrati nel programma. Come già detto, esso non è invece utilizzato per rilevare tali malfunzionamenti.

Il debugging richiede una comprensione approfondita del codice e del funzionamento del programma e può essere un processo complesso e articolato. Tuttavia, può contribuire in modo significativo a migliorare la qualità e la stabilità del codice, oltre che a risolvere malfunzionamenti.

Trattandosi di ricerca delle anomalie che generano malfunzionamenti noti, l’attività è definita per un programma e un insieme di dati che causano malfunzionamenti. Essa si basa infatti sulla riproducibilità del malfunzionamento, verificando prima che non sia dovuto in realtà a specifiche in errate.

Si tratta di un’attività molto complicata, come fa notare Brian W. Kernighan nella sua famosa citazione:

“Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it”.

È dunque importante scrivere codice più semplice possibile in modo tale da poterne fare un altrettanto semplice debugging laddove necessario.

Perché è così difficile?

L’attività di debugging è particolarmente complessa soprattutto perché non è sempre possibile individuare con precisione la relazione anomalia-malfunzionamento. Non è un legame banale, in quanto potrebbero esserci anomalie che prima di manifestarsi sotto forma di malfunzionamenti abbiano avuto molte evoluzioni.

Inoltre, non esiste una relazione biunivoca tra anomalie e malfunzionamenti: non è detto che un’anomalia causi un unico malfunzionamento, ma nemmeno che un malfunzionamento sia causato da un’unica anomalia.

Un altro problema è dovuto al fatto che la correzione di anomalie non garantisce affatto un software migliore o con meno errori: per correggere un’anomalia è necessario per forza di cose anche modificare il codice sorgente, ma ogni volta che viene fatto si apre la possibilità di introdurre nuove anomalie nel codice stesso.

Tecnica naïve

La tecnica di debugging maggiormente utilizzata dai programmatori consiste nell’introdurre nel modulo in esame una serie di comandi di output (es. print) che stampino su console il valore intermedio assunto dalle sue variabili. Questo permetterebbe di osservare l’evoluzione dei dati e, si spera, di comprendere la causa del malfunzionamento a partire da tale storia.

Nonostante sia facile da applicare, si tratta in realtà di una tecnica molto debole: non solo essa richiede la modifica del codice (e quindi una rimozione di tali modifiche al termine), ma è poco flessibile in quanto richiede una nuova compilazione per ogni stato esaminato.
Bisogna inoltre considerare che questa tecnica testa un programma diverso da quello originale che presenta delle print aggiuntive solo apparentemente innocue e senza effetti collaterali.

L’unico scenario (irrealistico) in cui la tecnica potrebbe essere considerata sufficiente sarebbe nel caso in cui il codice sia progettato talmente bene e il modulo così ben isolato che basterebbe scrivere un’unica print per risalire all’anomalia.

Tecnica naïve avanzata

Un miglioramento parziale alla tecnica appena descritta si può ottenere sfruttando le funzionalità del linguaggio oppure alcuni tool specifici per il debug, come per esempio:

  • #ifdef e gcc -D per il linguaggio C;
  • librerie di logging (con diverso livello), che permettono peraltro di rimuovere i messaggi di log in fase di produzione del codice;
  • asserzioni, ovvero check interni al codice di specifiche proprietà: possono essere visti anche come “oracoli” interni al codice che permettono di segnalare facilmente stati illegali.

Ciò non toglie che la tecnica sia comunque naïve, in quanto si sta ancora modificando il codice in modo che fornisca informazioni aggiuntive.

Dump di memoria

Una tecnica lievemente più interessante è quella dei dump di memoria, che consiste nel produrre un’immagine esatta della memoria del programma dopo un passo di esecuzione: si scrive cioè su un file l’intero contenuto della memoria a livello di linguaggio macchina (nei sistemi a 32 bit, la dimensione dei dump può arrivare fino a 4GB).

Segmentation fault (core dumped)

Sebbene questa tecnica non richieda la modifica del codice, essa è spesso difficile da applicare a causa della differenza tra la rappresentazione astratta dello stato (legata alle strutture dati del linguaggio utilizzato) e la rappresentazione a livello di memoria di tale stato. Viene inoltre prodotta una enorme mole di dati per la maggior parte inutili.

Debugging simbolico

Il prossimo passo è invece il cosiddetto debugging simbolico, un’attività che utilizza tool specifici di debugging per semplificare la ricerca delle anomalie che causano il malfunzionamento in esame. Tali strumenti permettono di osservare in tempo reale l’esecuzione del programma, sollevando una cortina e rendendo possibile analizzare l’evoluzione del valore delle variabili passo per passo: questi tool non alterano il codice ma come esso è eseguito.

A tal proposito, i debugger simbolici forniscono informazioni sullo stato delle variabili utilizzando lo stesso livello di astrazione del linguaggio utilizzato per scrivere il codice: gli stati sono cioè rappresentati con stessi simboli per cui le locazioni di memoria sono state definite (stesse strutture dati), rendendo quindi utile e semplice l’attività di ispezione dello stato.

In aggiunta, i debugger simbolici forniscono ulteriori strumenti che permettono di visualizzare il comportamento del programma in maniera selettiva, come per esempio watch e spy monitor.

Per regolare il flusso del programma è poi possibile inserire breakpoint e watchpoint su certe linee di codice che ne arrestino l’esecuzione in uno specifico punto, eventualmente rendendoli dipendenti dal valore di variabili. Volendo poi riprendere l’esecuzione si può invece scegliere la granularità del successivo passo:

  • singolo: si procede alla linea successiva;
  • dentro una funzione: si salta al codice eseguito dalle funzioni richiamate sulla riga corrente;
  • drop/reset del frame: vengono scartate le variabili nel frame d’esecuzione ritornando ad una situazione precedente.

Debugging per prova

Molti debugging simbolici permettono non solo di visualizzare gli stati ottenuti, ma anche di esaminarli automaticamente in modo da verificarne la correttezza.

In particolare, utilizzando watch condizionali è possibile aggiungere asserzioni a livello di monitor, verificando così che certe proprietà continuino a valere durante l’intera esecuzione.
Così, per esempio, è possibile chiedere al monitor (l’esecutore del programma) di controllare che gli indici di un array siano sempre interni all’intervallo di definizione.

Altre funzionalità dei debugger

Ma non finisce qui! I debugger moderni sono strumenti veramente molto interessanti, che permettono per esempio anche di:

  • modificare il contenuto di una variabile (o zona di memoria) a runtime;
  • modificare il codice: nonostante non sia sempre possibile, può essere comodo per esempio dopo tante iterazioni di un ciclo;
  • ottenere rappresentazioni grafiche dei dati: strutture dinamiche come puntatori, alberi e grafi possono essere rappresentate graficamente per migliorare la comprensione dello stato.

Automazione

Visti tutti questi fantastici tool può sorgere una domanda: l’attività di debugging può essere automatizzata?

Andreas Zeller tratta questo argomento in maniera approfondita nel suo Debugging Book, proponendo alcune direzioni di sviluppo di ipotetici strumenti di debugging automatico.
I due concetti principali della trattazione sono i seguenti:

  • shrinking input: dato un input molto grande e complesso che causa un malfunzionamento, strumenti automatici possono aiutare a ridurlo il più possibile in modo da semplificare il debugging;
  • differential debugging: dato lo stesso input, in maniera automatica vengono esplorati gli stati del programma mutando ad ogni iterazione piccole porzioni di codice per individuare dove è più probabile che si trovi l’anomalia.

Purtroppo per il momento la prospettiva di debugger automatici è ancora lontana.
Tuttavia, esiste già qualcosa di simile, vale a dire il comando git bisect di Git: data una versione vecchia in cui il bug non è presente, una versione nuova in cui esso si è manifestato e un oracolo che stabilisce se il bug è presente o meno, Git esegue una ricerca dicotomica per trovare la versione che ha introdotto il problema. Sebbene non sia proprio la stessa cosa, si tratta sicuramente di uno strumento utile.