I bachi (bug)

Leggenda vuole che il termine bug (insetto), oggi usato comunemente per indicare gli errori nei programmi, sia stato adottato nel gergo informatico all'epoca dei computer elettromeccanici in occasione del ritrovamento di un insetto (una falena) intrappolata dentro un interruttore a relè (1947, nel Mark II della Harvard University).

Il primo bug catalogato

In realtà l'uso del termine per indicare un insetto immaginario nascosto in un apparecchio e responsabile di tutti i malfunzionamenti era probabilmente già in auge dal secolo precedente.

I bachi di cui ci occuperemo non sono davvero insetti ma neppure immaginari.




Esistono programmi esenti da errori?

Come molte altre attività umane (e non), la scrittura di programmi non è esente da errori di varia natura che ne possono compromettere il corretto funzionamento.

Con la sempre maggiore diffusione che le applicazioni software hanno nelle nostre attività quotidiane (es. cellulari, bancomat, mezzi di trasporto, apparecchiature mediche) il verificarsi di errori non riscontrabili potrebbe influenzare pesantemente le nostre esistenze.

Un caso tristemente emblematico è quello del THERAC-25, un dispositivo computerizzato per la terapia a emissione di radiazioni destinata ai malati di cancro. A causa di un errore nel software di controllo, alcune di queste macchine rilasciarono dosi eccessive di radiazioni uccidendo alcuni pazienti e menomandone altri (negli anni 1985-1987). Secondo la versione ufficiale:
  • il programma fu scritto da un singolo programmatore non più rintracciabile e le cui qualifiche e livello di studi non furono mai accertati
  • l'elenco di specifiche non fu ritrovato
  • il programma era scarsamente documentato
  • il piano di collaudo non esisteva
  • gli errori erano dovuti a una soluzione ad hoc (dilettantistica) delle attività multitasking (controlli della tastiera, schermo, stampante e rilascio di radiazioni)
  • anche il software del modello precedente (THERAC-20) conteneva gli stessi errori, ma era dotato di blocchi hardware che impedivano meccanicamente il rilascio di radiazioni in dosi eccessive (nel THERAC-25 erano stati sostituiti da controlli software)

La domanda che sorge spontanea anche ad un profano dell'informatica è quindi come garantire che un sistema software sia esente da errori?

Purtroppo la risposta è che nessuno può darci tale garanzia assoluta... però è nostro dovere cercare di acquisire la maggiore fiducia possibile nel fatto che il programma che abbiamo scritto sia corretto, al punto da essere disposti ad affidare al programma la nostra incolumità.

Anche con la certezza matematica che un certo programma sia privo di errori, il suo funzionamento può dipendere da altri fattori, quali la correttezza del compilatore, dell'interprete, del sistema operativo, dei driver di alcuni dispositivi, dei componenti hardware... senza contare le condizioni climatiche e ambientali della macchina su cui il programma viene eseguito!

Tutti fattori sui quali il programmatore ha scarso controllo.

Tralasciando l'impiego di metodi formali (che non può essere oggetto di questo corso), le migliori armi a nostra disposizione per eliminare quanti più errori possibili sono il collaudo (testing) e il debug.




Il collaudo di unità

Qualsiasi applicazione composta da più classi e metodi può essere controllata più facilmente utilizzando (anche in fase di sviluppo) il collaudo delle singole unità separatamente (al livello di granularità del singolo metodo o di gruppi ristretti di metodi cooperanti).

Questa pratica è diffusa anche nella progettazione di schede elettroniche, dove infatti troverete dei punti di connessione che non servono direttamente per il funzionamento dell'apparecchiatura, ma che sono stati predisposti affinché il personale delle riparazioni possa collegarci strumenti di misurazione o altre schede, al fine di individuare il guasto.

Il collaudo procede compilando ciascuna classe assieme a un semplice programma detto infrastruttura di collaudo che invoca i metodi da collaudare con opportuni valori e ne osserva l'esito.

È buona pratica eseguire il collaudo in maniera preventiva, prima di aver terminato la realizzazione dell'intera applicazione, per poter intervenire tempestivamente anche sulla riprogettazione del sistema in seguito alla scoperta di errori difficili da gestire adeguatamente.

Gli argomenti per il collaudo possono provenire da fonti diverse:

  • un insieme di valori prefissato
  • un insieme di valori inseriti dall'utente
  • un insieme di valori generato casualmente
  • un insieme di valori letto da file

L'uso di valori memorizzati in un file è preferibile perché copre gli altri casi (es. il contenuto del file può essere generato in maniera pseudocasuale) e rende più facile e veloce ripetere l'esperimento dopo aver modificato e ricompilato il codice.

Anche se il programma è predisposto per leggere i dati in input da tastiera, possiamo usare l'opzione di redirezione dello standard input per fornire dati memorizzati in un file al momento in cui si esegue il test:

   java classeCollaudo < fileDatiInput

I valori utilizzati per il collaudo dovrebbero coprire le seguenti categorie:

  • casi di prova positivi (valori di ingresso validi per i quali ci si aspetta una corretta gestione da parte del programma)

    Il collaudo dovrebbe verificare che l'elaborazione di questi valori dia sempre risultati corretti.

  • casi limite (valori di ingresso considerati validi ma inusuali, sono quelli che comunemente causano errori dovuti a trascuratezza del programmatore, come divisioni per zero, accesso a posizioni inesistenti negli array vuoti e nelle stringhe vuote, riferimenti null)

    Il collaudo dovrebbe verificare che l'elaborazione di questi valori dia risultati corretti senza causare eccezioni.

  • casi di prova negativi (valori di ingresso non validi che dovrebbero essere rifiutati dal programma)

    Il collaudo dovrebbe verificare che l'elaborazione di questi valori sollevi le opportune eccezioni (senza causare la terminazione del programma di collaudo!).




I pacchetti di prova

Quando eseguiamo il collaudo sui casi di prova dobbiamo anche poter confrontare i valori prodotti in output con i risultati attesi.

Possiamo usare l'opzione di redirezione dello standard output per memorizzare i dati prodotti in output dal collaudo:

   java classeCollaudo < fileDatiInput > fileDatiOutput

Per poter verificare i dati in output è quindi conveniente aver memorizzato in un file i valori attesi per ogni caso di prova. Tali valori possono essere generati:

  • manualmente
  • in modo da verificare che i dati in input e quelli in output verifichino certe proprietà (ad esempio se un array è ordinato dopo avere eseguito un algoritmo di ordinamento, oppure se moltiplicando per se stesso l'output di un metodo che calcola la radice quadrata si riottiene il valore in ingresso)
  • tramite un oracolo (un programma magari che assolve lo stesso scopo di quello che stiamo collaudando, magari molto più lento ma che crediamo essere più affidabile... ad esempio una versione precedente dello stesso metodo)
L'insieme delle prove da ripetere per il collaudo è detto pacchetto di prova (test suite).

È importante conservare i casi di prova e i rispettivi output come testimonianza della validità del programma, per collaudare altre versioni del programma, per controllare se si ripresentano errori che credevamo di avere già corretto. Quest'ultimo è un fenomeno piuttosto diffuso che viene detto ciclicità (il controllo di assenza di vecchi errori viene detto collaudo regressivo).




Scatole nere e scatole bianche

Quando si collaudano le funzionalità del programma senza conoscere o considerare la sua struttura interna (es. software proprietario o troppo complesso) si parla di collaudo a scatola chiusa (black-box testing).

Per accertare il corretto funzionamento del programma su tutti i possibili valori in ingresso (positivi, limite e negativi) avremmo però bisogno di infiniti casi di prova a scatola chiusa, mentre dobbiamo accontentarci di un numero finito.

Quando si conosca la struttura del programma è invece opportuno utilizzare tecniche di collaudo trasparente (white-box testing), scegliendo i casi di prova in modo che ciascuna porzione del programma venga collaudata almeno una volta. Infatti poter offrire una migliore copertura del collaudo permette di aumentare la fiducia nella correttezza dell'intero programma.

Il collaudo può evidenziare solamente la presenza di errori, non la loro assenza.
                                    Edsger W. Dijkstra



A caccia di bachi

Nell'eseguire i casi di prova può succedere che si ottengano risultati inattesi o addirittura che il programma non termini.

Scoperto il malfunzionamento dobbiamo attivarci per identificare le possibili cause e correggere il codice incriminato. Questa attività è detta debugging.

Oltre alla tecnica elementare del tracciamento con messaggi sullo standard output, Java 5 mette a disposizione meccanismi più eleganti e sofisticati (stack tracing, logging e asserzioni), ed Eclipse offre un potente ambiente integrato (debugger) per esaminare passo-passo l'esecuzione del codice offrendo la possibilità di correggere il programma mentre viene eseguito.




Tracciamento

La tecnica del tracciamento è quella più banale per cercare gli errori e probabilmente l'avete già adottata in questi mesi.

Il tracciamento consiste nell'inserire opportuni messaggi di stampa per evidenziare il valore di alcune variabili nei punti dove l'errore sembra manifestarsi, oppure anche solo per controllare che certe porzioni di codice vengano effettivamente eseguite.

Il codice inserito per il tracciamento è solitamente del tipo:

   System.out.println("messaggio di tracciamento");

Nel seguire il flusso di esecuzione di un programma con messaggi di tracciamento è buona regola visualizzare i valori dei parametri ricevuti in ingresso da ogni metodo invocato e il valore che sta per essere restituito all'uscita dal metodo.

Il problema principale è che questi messaggi vengono introdotti solo per il debug e quindi devono essere tolti prima di rilasciare il programma, col rischio di:

  • doverli reinserire ogni volta che il problema si ripresenta
  • dimenticarsi di rimuoverne alcuni

Un problema secondario è che i messaggi inseriti in punti diversi del programma dovrebbero essere differenti, altrimenti potrebbero essere confusi.




Stack Tracing

Per risolvere il problema secondario descritto sopra, in Java basta stampare una traccia della pila di esecuzione, che descrive come il programma sia giunto in un certo punto.

Il codice inserito per il tracciamento dello stack è solitamente del tipo:

   System.out.println("messaggio di tracciamento");
   (new Throwable()).printStackTrace(System.out);

Permane però il problema principale della tecnica di tracciamento, cioè che tutte le modifiche apportate al codice originale devono essere rimosse manualmente prima del rilascio.




Logging

A partire da Java 1.4, la classe Logger permette di inserire messaggi di tracciamento che possono essere attivati e disattivati più facilmente (senza dover ricorrere a soluzioni ad hoc comunque possibili, come l'introduzione di metodi ausiliari).

L'idea è che invece di stampare direttamente sul flusso System.out si utilizzi un oggetto di logging.

Il codice inserito per il logging è solitamente del tipo:

import java.util.logging.*;

   ...
   Logger log = Logger.getLogger("global");
   log.info("messaggio di tracciamento");
Per impostazione predefinita il messaggio di tracciamento viene visualizzato, ma basta invocare

   log.setLevel(Level.off);
Per disattivare la visualizzazione dei messaggi di logging.

L'uso della classe Logger prevede quindi che i messaggi di tracciamento possano eventualmente restare nel codice rilasciato, purché disattivati.




Le asserzioni

Molti errori sono dovuti alla violazione di alcune ipotesi ritenute implicite dal programmatore, ma non garantite di fatto dal codice scritto (es. accredito di importi negativi su un conto).

Un'asserzione è una condizione logica che si ritiene essere valida in un preciso punto del programma.

A partire da Java 1.4 è stato aggiunto il comando assert che verifica se un'asserzione è violata e solleva un'opportuna eccezione quando questo accade.

La sintassi del comando assert è la seguente:

   assert booleanExpression : expression;
Dove expression (opzionale) è una qualsiasi espressione che produca un valore di qualsiasi tipo purché non void (tipicamente può essere un messaggio di tracciamento).

La semantica consiste nel valutare l'asserzione booleanExpression e nel caso questa risulti falsa nel sollevare un'eccezione di tipo AssertionError che reca come messaggio (la rappresentazione come stringa di) il valore prodotto da expression.

Dal punto di vista semantico è quindi equivalente a:


   if (!booleanExpression) throw new AssertionError(expression);

Però è possibile decidere al momento in cui il programma viene eseguito se abilitare o disabilitare le asserzioni (come default sono disabilitate) e se non sono abilitate l'interprete le ignora senza compromettere minimamente la performance del sistema (invece i comandi condizionali richiederebbero comunque la valutazione dell'espressione).

Per compilare un programma con le asserzioni utilizzate il comando


   javac -source 1.4 nomeClasse.java

Per eseguire un programma con le asserzioni abilitate utilizzate il comando


   java -enableassertions nomeClasse

Le asserzioni devono essere usate con cautela, tenendo in mente che servono specialmente nella fase di progettazione del codice, ma saranno disabilitate in fase di utilizzo dell'applicazione.

Ad esempio è buona norma:

  • non utilizzarle al posto di controlli che debbano garantire il corretto funzionamento del programma (una volta disabilitate il programma si comporterebbe in maniera imprevista)
  • utilizzarle per controllare che i parametri in ingresso a metodi privati siano corretti, ma non utilizzarle per controlli analoghi su metodi pubblici (che invece devono essere fatti esplicitamente con comandi condizionali non disabilitabili).
  • utilizzarle per annotare parti di codice che riteniamo non sia possibile raggiungere
  • utilizzarle con espressioni che non provochino side-effect (altrimenti il programma si comporterebbe diversamente a seconda che le asserzioni siano abilitate o no)
  • utilizzarle per controllare la consistenza di alcuni invarianti sullo stato di certi oggetti (es. per verificare il corretto ordinamento di una collezione di oggetti prima e dopo l'esecuzione di certe operazioni critiche)




Il debugger

L'inserimento di messaggi di tracciamento comporta tipicamente una grossa perdita di tempo (decidere dove e quali messaggi inserire, analizzare l'output, attivarli e disattivarli, ...).

I programmatori professionisti si affidano invece ai cosiddetti debugger: sono programmi speciali che vengono utilizzati per eseguire un altro programma e analizzare il suo stato e comportamento durante l'esecuzione di singole istruzioni.

I moderni ambienti di sviluppo contengono debugger integrati che permettono di arrestare l'esecuzione del programma in punti precisi, ispezionare la pila di esecuzione, il contenuto di variabili locali, la stato degli oggetti creati, e molto altro ancora.

Per utilizzare un debugger basta comprendere tre concetti basilari:

  • come impostare alcuni punti di arresto (breakpoint)
  • come ispezionare il contenuto delle variabili
  • come far proseguire il programma una riga alla volta (single step)




Punti di arresto

In Eclipse, i breakpoint si impostano con un semplice doppio click col pulsante sinistro a lato della riga desiderata (vedrete comparire un pallino azzurro). Come avvertenza generale, è sconsigliabile avere più comandi sulla stessa riga (altrimenti non sarà possibile associare breakpoint ad alcuni di essi).

Con un click del pulsante destro sopra il pallino di un breakpoint potrete impostare delle guardie avanzate sul punto di arresto scegliendo breakpoint properties e poi Common (ad esempio per fare in modo che il programma si interrompa solo quando una certa istruzione viene eseguita per la seconda volta, oppure se certe variabili hanno valori opportuni). Se vengono impostate delle guardie il pallino verrà affiancato da un punto interrogativo.

Sempre col pulsante destro del mouse, potrete disabilitare breakpoint selettivamente senza rimuoverli (il pallino diventerà bianco). Per rimuoverli invece è sufficiente un doppio click col pulsante sinistro.

Una volta impostati i breakpoint ritenuti utili, per eseguire il debugger basta scegliere

    Run -> Debug As -> Java Application
oppure usare la combinazione di tasti Shift+Alt+D J

Quando viene raggiunto il primo breakpoint (senza guardia o la cui guardia sia soddisfatta), si aprirà la prospettiva di debug (previa vostra conferma), che presenta molte viste interessanti.

La vista Debug (in alto a sinistra) mostra il contenuto della pila di esecuzione, dove compaiono tutte le istanze dei vari metodi attualmente sospesi (tipicamente quello in fondo alla pila sarà il metodo main e quello in cima alla pila il metodo contenente il breakpoint).

La vista Variables mostra le variabili locali del metodo e permette di ispezionarne il valore. Se le variabili riferiscono oggetti è possibile conoscere l'identificatore univoco del'oggetto (id = ...) e ispezionarne lo stato con un click sul triangolino che compare a sinistra del nome della variabile.

Con un click del pulsante destro sul nome della variabile è possibile modificarne il contenuto prima di riprendere l'esecuzione.




Resume, step-into e step-over

L'esecuzione può essere riattivata con modalità diverse associate alle icone che compaiono in cima alla vista di debug.

Il rettangolino giallo affiancato dal triangolo verde (Resume) riprende l'esecuzione normale fino al prossimo breakpoint impostato (o alla terminazione del programma).

Il consueto quadratino rosso (Terminate) provoca la terminazione del programma.

Sottolineiamo infine due importanti modalità single-step (indicate con diversi tipi di frecce gialle):

  • step-into che riprende l'esecuzione del programma una riga alla volta e che in caso di invocazioni di metodi porta all'interno di essi
  • step-over che riprende l'esecuzione del programma una riga alla volta senza arrestarsi all'interno di eventuali metodi invocati




Sei strategie per il debug

  1. Scoprite come riprodurre l'errore (individuate i dati in input che lo causano)

  2. Semplificate l'errore (individuate i dati più semplici possibili che lo causano)

  3. Divide et impera (eseguite il main procedendo in modalità step-over sino a quando l'anomalia si verifica: il metodo che l'ha causata è l'ultimo eseguito prima che fosse possibile riscontrare il problema, quindi possiamo ri-eseguire il debug facendo step-into in quella particolare invocazione e procedendo di nuovo con step-over sulle istruzioni del metodo, e così via)

  4. Procedete consapevolmente (durante il debug confrontate costantemente i valori correnti delle variabili con quelli che vi aspettereste)

  5. Controllate tutti i dettagli (se mentre cercate di risolvere un'anomalia incappate in un'altra non ignoratela: è preferibile prendere un appunto per il problema originale, cercare di correggere la nuova anomalia e poi tornare al problema originario)

  6. Correggete gli errori solo dopo averli compresi (soluzioni tampone possono essere rapide ma spesso hanno il grosso svantaggio di causare problemi ancora più gravi in altri punti del codice)




Esempi

Per fare pratica con l'uso del debugger consideriamo due semplici esempi riuniti nella classe TestDebug:

Calcolo del fattoriale ricorsivo: scopo della demo è (1) mostrare come si imposta un breakpoint con guardia, (2) mostrare come la pila di esecuzione possa contenere diverse istanze dello stesso metodo, (3) mostrare modalità step-over e step-into, (4) mostrare come sia possibile modificare il contenuto di variabili locali e il codice durante il debugging, (5) modificare il codice per consentire l'uso di pacchetti di prova.

Ordinamento di un array: scopo della demo è (1) mostrare tecniche di stack-tracing e di logging, (2) mostrare condivisione di oggetti tra metodi diversi e effetti collaterali, (3) mostrare allocazione dinamica di variabili locali, (4) modificare il codice per consentire l'uso di pacchetti di prova.