![]() ![]() |
|
![]() ![]() |
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).
![]() 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.
|
![]() ![]() |
|
![]() ![]() |
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.
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à.
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.
|
![]() ![]() |
|
![]() ![]() |
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:
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.
I valori utilizzati per il collaudo dovrebbero coprire le seguenti categorie:
|
![]() ![]() |
|
![]() ![]() |
Quando eseguiamo il collaudo sui casi di prova dobbiamo anche poter
confrontare i valori prodotti in output con i risultati attesi.
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:
È 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).
|
![]() ![]() |
|
![]() ![]() |
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.
|
![]() ![]() |
|
![]() ![]() |
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. |
![]() ![]() |
|
![]() ![]() |
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.
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:
Un problema secondario è che i messaggi inseriti in punti diversi del programma dovrebbero essere differenti, altrimenti potrebbero essere confusi.
|
![]() ![]() |
|
![]() ![]() |
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.
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.
|
![]() ![]() |
|
![]() ![]() |
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.
L'uso della classe Logger prevede quindi che i messaggi di tracciamento possano eventualmente restare nel codice rilasciato, purché disattivati.
|
![]() ![]() |
|
![]() ![]() |
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.
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:
|
![]() ![]() |
|
![]() ![]() |
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:
|
![]() ![]() |
|
![]() ![]() |
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
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.
|
![]() ![]() |
|
![]() ![]() |
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):
|
![]() ![]() |
|
![]() ![]() |
|
![]() ![]() |
|
![]() ![]() |
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. |