Tecniche di debugging del software - Corso di Laurea in Ingegneria

Transcript

Tecniche di debugging del software - Corso di Laurea in Ingegneria
Scuola Politecnica e delle Scienze di Base
Corso di Laurea in Ingegneria Informatica
Elaborato finale in Ingegneria del software
Tecniche di debugging del software
Anno Accademico 2015/2016
Candidato:
Giuseppe Viviani
matr. N46/000038
A mia madre, mio
fratello e tutti
coloro che mi
hanno dato la
forza di non
mollare.
Indice
Introduzione………………………………..……………………………………………………………….4
1 Il debugging……………....………………………………………………………………………...…7
1.1 Brute Force…………….………………………………………………………..………………….10
1.2 Backtracking…………….………………………………………………………………………….11
1.3 Ragionamento induttivo e deduttivo…………………………….……………………..13
1.4 Strumenti di debugging……………………………………………………………………....14
2
Esempio esplicativo………………………………………………………………………......15
2.1 Debugging tramite backtracking………………………………………………………….20
2.2 Debugging tramite brute force………………………………………………………….…24
2.3 Debugging tramite analisi dinamica dei dati…………….…………………………27
3 Confronto e conclusioni………………………………………..……………………………29
4 Bibliografia…………………………………………………………………………………………..31
1. INTRODUZIONE
Quando si comincia a programmare, il primo obiettivo è subito rivolto alla
corretta compilazione del programma, che viene visto come il più grande e
imminente problema da risolvere; sicuramente la corretta compilazione del
programma è fondamentale per la buona riuscita di esso, ma non è condizione
sufficiente affinché il programma funzioni ottimamente: scrivere un programma
che compili esattamente, ergo privo di errori di sintassi, non implica il corretto
funzionamento di esso! Questo perché spesso gli errori sono nascosti e quindi
difficili da rintracciare e correggere. Nel “gergo” informatico tali errori vengono
definiti bug: questo termine (dall’inglese piccolo insetto) identifica un errore
all'interno di un programma software: sono quei piccoli errori che si commettono
nella scrittura del codice di un software, ma che possono avere effetti disastrosi
per il software stesso. Quindi sono per lo più degli errori di sintassi che il
programmatore commette nella scrittura del software e la cui causa può essere
varia: può essere dovuto a un errore di distrazione, ad esempio ponendo un valore
reale all'interno di una variabile string, o a un errore di valutazione, dando per
scontato ad esempio che una variabile sia in un determinato formato
sottovalutando il possibile sviluppo di uno scenario che può verificarsi in
un'ambiente reale. La maggior parte degli errori sono dovuti, di solito, ad uno
sbaglio commesso dal programmatore, ma può anche capitare che il problema
venga prodotto dal compilatore. Spesso accade che questi difetti all’interno del
programma non si manifestano, ma vanno ad intaccare il risultato finale del
programma: ad esempio, se stiamo scrivendo un programma che faccia particolari
funzioni matematiche ma commettiamo qualche errore nel trascrivere una
formula, questo errore non si manifesterà nella compilazione, ma in esecuzione
avremmo un risultato sballato.
L'utilizzo del termine bug risale a un aneddoto molto curioso risalente al 1947,
precisamente il 9 settembre: il tenente Grace Murray Hopper era definita una
pioniera della programmazione informatica ed è famosa per il suo lavoro sul
primo computer digitale della marina, Harvard Mark I. Si unì al gruppo del
~4~
Computation Laboratory di Harvard e stava lavorando con l’Harvard Mark II
quando venne segnalato da un operatore un malfunzionamento interno: dopo aver
smontato il settore interessato, si accorse che tra gli ingranaggi all'interno di un
circuito vi era rimasta incastrata una falena. Dopo aver rimosso l'insetto dal
circuito, il tenente incollò la falena sul registro del computer e annotò:
"1545. Relay #70 Panel F (moth) in relay.
First actual case of bug being found."
Figura 1: Diario di Grace Murray Hopper
In realtà, il termine bug, inteso come problema tecnico, fu utilizzato almeno dal
1870: infatti in una lettera di Thomas Edison, in cui spiegava ad un amico che la
~5~
presenza di piccoli errori rallentava lo sviluppo delle sue invenzioni, possiamo
leggere:
" È stato così per tutte le mie invenzioni. Il primo passo è un'intuizione, e viene
come una fiammata, poi le difficoltà crescono. Questa cosa passa ed è allora che
quei "bachi" si manifestano e sono richiesti mesi di intensa osservazione, studio e
lavoro prima del successo commerciale oppure il fallimento è sicuramente
raggiunto."
Un bug può avere una grande varietà di effetti, di cui alcuni incidono poco sulla
funzionalità del programma e quindi possono rimanere sconosciuti per lungo
tempo. Al contrario, se il bug fosse abbastanza grave, può causare il crash o un
blocco del programma che porta ad una negazione del servizio richiesto. Altri,
qualificati come bug di sicurezza, potrebbero consentire ad un utente
malintenzionato di aggirare i controlli di accesso, al fine di ottenere privilegi non
autorizzati.
Nel dettaglio, gli errori più comuni che possiamo riscontrare all’interno di un
software sono:

Errori di runtime, se un insieme di dati dà luogo a delle operazioni non
lecite come ad esempio, per alcuni linguaggi, la divisione per zero;

Errori di sintassi, causato solitamente dal ricorso di una sintassi errata o
comunque non contemplata dal linguaggio di programmazione in uso;

Errori logici, causati da scelte erronee fatte nell’algoritmo: sono i più
difficili da individuare perché non restituiscono messaggi di errore e
vengono riconosciuti soltanto quando in fase di esecuzione dell’algoritmo
otteniamo risultati diversi da quelli previsti.
~6~
2. IL DEBUGGING
L’implementazione di un software comprende diverse fasi:

Analisi: indagine preliminare su caratteristiche e requisiti che il software
dovrà rispettare;

Progettazione: definizione della struttura del prodotto software in
funzione dei requisiti. Definisce la soluzione del problema;

Implementazione: sviluppo vero e proprio del codice sorgente del/i
programmi costituenti il software;

Collaudo (o Testing): verifica e validazione che quanto implementato
soddisfi i requisiti. Valuta dunque la correttezza rispetto alle specifiche;

Pubblicazione: una volta superata la fase di collaudo, il software viene
pubblicato agli utenti finali. A seconda della complessità del software,
essa può consistere nella copia di un singolo file o di una “copia
opportuna” di molti file organizzati in una complessa gerarchia di
componenti, eventualmente distribuiti su hardware differenti.

Manutenzione: insieme di sotto attività necessarie a modifiche del
prodotto post rilascio, al fine di correggere ulteriori errori, adattarlo a
nuove versioni degli ambienti operativi, estenderne le funzionalità, etc.
Noi ci soffermeremo sulla fase di Collaudo. O meglio, di ciò che avviene a valle
del testing quando questo ha evidenziato errori o il mancato rispetto di
determinate specifiche. Qui tornano in gioco gli sviluppatori che hanno il compito
di risolvere i problemi riscontrati anche, o soprattutto, attraverso il debugging.
Tipicamente il primo passo nell’individuazione di un bug è trovare un modo per
riprodurlo facilmente. Una volta che l’errore viene riprodotto, il programmatore
può usare un debugger per seguire l’esecuzione nella regione difettosa e trovare il
punto in cui il programma dà problemi. Il debugging è l’attività di ricerca e
correzione dei difetti che sono causa di malfunzionamenti all’interno di un
programma, quindi sequenzialmente è l’attività che segue quella di un testing che
~7~
ha avuto successo, dove è stato scoperto il difetto. Essa è una delle operazioni più
importanti per la messa a punto di un software: è un processo estremamente
difficile poiché può causare comportamenti ancora diversi da quelli desiderati nel
tentativo di correggere gli errori per cui si è svolta tale attività, ma è un processo
necessario al fine di garantire affidabilità e consistenza al sistema. Comprende due
fasi: ricerca e correzione del difetto. Logicamente, nonostante il debugging
semplifica di molto la ricerca di eventuali errori all’interno di un programma,
possiamo incontrare difficoltà anche in questa fase dello sviluppo di un processo
software: possiamo ad esempio avere a che fare con un sintomo intermittente,
oppure
correggendo
un
difetto
potremmo
non
avere
eliminato
in
malfunzionamento all’interno del programma poiché causato dall’azione
combinata di più difetti. L’errore può essere rilevato sia in fase di testing, come
detto precedentemente, sia in fase di utilizzo del programma: un primo principio
molto semplice che aiuta alla localizzazione dei bug è tentare di ridurre la distanza
tra malfunzionamento e difetto; noi consideriamo malfunzionamento uno stato
scorretto che si verifica nell’esecuzione di un programma: il problema principale
sta nel fatto che non sempre i malfunzionamenti portano immediatamente a un
fallimento e quindi non possono essere facilmente individuati. È possibile rendere
visibili i malfunzionamenti attraverso opportune tecniche. Per esempio, una
tecnica che veniva largamente usata era quella di produrre un’immagine dello
stato di memoria, ovvero stampare i contenuti delle variabili in un certo punto
dell’esecuzione di un programma: in linea di principio, in questo modo ogni
malfunzionamento dà luogo a un fallimento, poiché, rendendo visibile lo stato
della memoria, si rende lo stato di esecuzione un output del programma, per cui
qualunque valore scorretto della memoria produce un fallimento. L’ovvio
inconveniente di questo approccio è che, in generale, sono necessari troppi
dettagli per analizzare il risultato dell’immagine di memoria: la specifica
dell’intero stato di memoria è troppo complessa da definire e verificare. Ma il
principio di avvicinare i malfunzionamenti ai fallimenti può essere applicato in
modi estremamente più efficaci. Infatti, il debugging consente di eseguire un
programma in modo interattivo, mentre si guarda il codice sorgente e le variabili
durante l’esecuzione: con l’inserimento di punti di interruzione (breakpoints)
~8~
all’interno del codice, si può specificare il punto esatto in cui l’esecuzione del
programma deve smettere. I breakpoint possono essere condizionali, cioè che si
attivano solo quando si passa su una certa riga e si verifica una certa condizione,
oppure dipendenti dallo Hit Count, ovvero che si attivano solo dopo un certo
numero di passaggi su quella riga di codice. Il watchpoint, invece, permette
l’interruzione dell’esecuzione del programma solo nel caso in cui un campo deve
essere letto o modificato; rispetto al breakpoint, non si riferisce ad una linea di
codice eseguibile, ma ad un attributo di una classe. Un watch, generalmente, è una
semplice istruzione che inoltra il contenuto di una variabile verso un canale di
output; l’inserimento di una watch è un’operazione invasiva poiché, innanzitutto,
anche all’interno di esso potrebbe annidarsi un difetto, e in secondo luogo, ma non
meno importante, l’inserimento e l’utilizzo di queste sonde potrebbe modificare
sensibilmente il comportamento di un software: ad esempio, in un programma in
cui sono presenti più thread paralleli, con velocità di esecuzione diverse, l’utilizzo
su un thread di un watchpoint potrebbe portare all’interruzione del thread stesso
mentre i restanti continuano la loro esecuzione, quindi è stata causata una
modifica al programma che porterà a un risultato sballato. Nei linguaggi
interpretati, è anche possibile fornire la possibilità di modificare il valore delle
variabili in fase di esecuzione: in questo modo possiamo anche testare cammini
infeasible, ovvero cammini impercorribili, per cui non esiste alcun valore di
ingresso che soddisfi il predicato del cammino stesso.
Quindi possiamo dire che breakpoints e watchpoints possono essere riassunti
come punti di arresto, e una volta che il programma viene arrestato è possibile
esaminare le variabili, cambiarne il contenuto ecc.
~9~
2.1 Brute force
Questo è il metodo più inefficace di fare debugging. È un algoritmo di risoluzione
di un problema dato che consiste nel verificare tutte le soluzioni teoricamente
possibili fino a giungere a quella effettivamente corretta. Quello del brute force è
un metodo lento e dispendioso, viene utilizzato soltanto nel caso in cui le altre
tecniche di debugging abbiano fallito e quindi non abbiano prodotto alcun
risultato, oppure quando questo è l’unico metodo conosciuto, per cui non abbiamo
alcun altro strumento a disposizione. Fattore vantaggioso per il brute force è che,
nonostante la scarsa velocità di esecuzione, ci permette comunque di raggiungere
e correggere i difetti all’interno del nostro programma.
Vi sono diversi approcci a disposizione:

Utilizzo dello storage dump, cioè stampe dello stato della memoria in
codice esadecimale o ottale;

Disseminazione nel codice di sonde, per catturare quante più
informazioni possibili e poi valutarle in cerca di indizi;

Utilizzo di strumenti di debugging automatico, che permette di
analizzare l’esecuzione del programma con l’inserimento di
breakpoint o con l’osservazione di variabili (inefficace perché
potrebbe produrre troppe informazioni da comprendere)
~ 10 ~
2.2 Backtracking
Il metodo di debugging per backtracking è un procedimento ricorsivo che
prevede di ripercorrere il codice “all’indietro” a partire dal punto in cui si è
verificato il malfunzionamento. Un tipico scenario in cui risulta efficace si ha
nel caso in cui vi sono una moltitudine di opzioni e bisogna sceglierne una: a
valle di una decisione ci si trova davanti a un nuovo set di opzioni, e si sceglie
un nuovo percorso da seguire; se si scopre di aver seguito il percorso errato, si
torna indietro e si sceglie una nuova opzione. Questo procedimento va seguito
finché non si giunge alla causa dell’anomalia. Di seguito viene illustrato un
esempio pratico che aiuta a capire con facilità come funziona tale metodo:
Figura 2: Funzionamento del backtracking
~ 11 ~
1. Partendo dal metodo in cui è stata riscontrata l’anomalia, si hanno come
opzioni i due metodi che lo richiamano, Opzione A e Opzione B:
ammettiamo che la nostra scelta inizialmente ricada sull’opzione A;
2. Dal canto suo, A viene richiamato da due metodi, Opzione C e Opzione
D: scegliamo Opzione C;
3. Dato che Opzione C non è la causa del problema, ritorniamo su Opzione
A;
4. In Opzione A, non avendo avuto successo l’Opzione C, non abbiamo
altra scelta che provare l’Opzione D;
5. Poiché nemmeno l’Opzione D è la soluzione del problema, ritorniamo
in
Opzione A;
6. In Opzione A abbiamo terminato le soluzioni, quindi torniamo al
metodo di partenza;
7. Adesso, avendo provato l’Opzione A, passiamo a provare l’Opzione B;
8. L’Opzione B, dal canto suo, richiama due metodi: Opzione E ed
Opzione F;
9. Proviamo l’Opzione E, e siccome risulta essere la soluzione del
problema, abbiamo risolto il bug!
Vedendo questo semplice esempio, vediamo chiaramente che più è complessa
l’architettura del software e più, utilizzando questo approccio, il processo di
risoluzione del bug può risultare lento e dispendioso.
~ 12 ~
2.3 Ragionamento induttivo e deduttivo
Prima di tutto, si cerca di individuare la tipologia dei dati che fa fallire il
programma, poi si prova a formulare un’ipotesi sulla possibile causa del difetto,
proponendo dati in ingresso in grado di far avvenire il malfunzionamento; infine
si cerca di controllare la validità di tale ipotesi. Possiamo utilizzare due possibili
approcci, definiti come metodo induttivo e metodo deduttivo:

il metodo induttivo
consiste nel derivare ipotesi sulla base dei dati
disponibili e delle loro relazioni provando quella più accreditante, fino a
trovare quella giusta;
Figura 3: Metodo induttivo

il metodo deduttivo, invece, permette di elencare tutte le possibili cause,
eliminando quelle non attinenti e provando le rimanenti fino a quando non
si individua la causa del bug in questione.
Figura 4: Metodo deduttivo
~ 13 ~
2.4 Strumenti di debugging
Il debugging è un’attività estremamente intuitiva, che però deve essenzialmente
essere sviluppata all’interno dell’ambiente di sviluppo ed esecuzione del codice.
Strumenti a supporto del debugging vengono integrati in piattaforme di sviluppo
(IDE) cosicché sia possibile accedere ai dati del programma, anche durante
l’esecuzione. Un ambiente di sviluppo integrato, o IDE, è un ambiente di
programmazione che è stato confezionato come un programma applicativo,
costituito in genere da un editor di codice, un compilatore, un debugger e
un’interfaccia utente grafica. Può essere un’applicazione stand-alone oppure può
essere incluso come parte di una o più applicazioni esistenti e compatibili.
Sebbene siano in uso alcuni IDE multi-linguaggio, come Eclipse, NetBeans e
Visual Studio, generalmente sono rivolti ad uno specifico linguaggio di
programmazione.
Noi concentreremo la nostra attenzione su Eclipse, in cui implementeremo
un’applicazione C++ in cui avremo iniettato un difetto e su cui applicheremo le
varie tecniche di debugging studiate precedentemente. Eclipse è un ambiente di
sviluppo integrato (IDE - Integrated Development Enviroment) open-source,
ovvero una piattaforma integrata che consente di gestire l'intero processo di
sviluppo di applicazioni Java, ma può essere utilizzato per la produzione di
software di vario genere. Il programma è scritto in linguaggio Java; la piattaforma
di sviluppo è incentrata sui plug-in, componenti software ideate per un generico
scopo: tutta la piattaforma è un insieme di plug-in, e chiunque può svilupparli e/o
modificarli. Per comprendere meglio il funzonamento delle varie tecniche di
debugging sopra definite, effettueremo un esempio esplicativo di progetto
all’interno del quale è stato iniettato un difetto che va ad inficiare la corretta
esecuzione di esso. Vedremo l’analisi e la correzione di tale difetto con le varie
tecniche di debugging studiate, ovvero Backtracking, Brute Force e analisi
dinamica dei dati (con l’utilizzo dei watchpoint).
~ 14 ~
3 ESEMPIO ESPLICATIVO
Come precedentemente accennato, facendo uso delle funzionalità del debugger di
Eclipse e delle varie tecniche di debugging considerate, studieremo un esempio di
debugging su un progetto scritto in C++, il quale è stato infetto in modo tale da
avere un’esecuzione sbagliata e per il quale effettuare l’analisi e la correzione; una
volta terminato, effettueremo un confronto tra le varie tecniche, indicandone i vari
pregi e difetti, esprimendo un giudizio personale in base all’esperienza effettuata.
Le tecniche utilizzate in questo esercizio sono:

Backtracking, ossia, partendo dalla riga di codice che presumeremo
essere causa del cattivo risultato, andremo a ripercorrere a ritroso il
programma cercando di risalire manualmente all’errore commesso in fase
di scrittura e correggerlo;

Brute Force, ovvero, dopo aver posto un breakpoint all’inizio del nostro
programma, andremo a ripercorrere l’intero codice, riga per riga,
osservando lo stato di ciascuna variabile definita all’interno di esso,
finché non giungeremo alla “modifica” anomala causata dal nostro errore
e quindi alla correzione;

Analisi dinamica dei dati, ovvero, tramite l’utilizzo dei watchpoint,
partendo dalla linea di codice in cui presumiamo sia stato generato
l’errore nell’esecuzione, andremo a porre le cosiddette “sonde” sulle
variabili che ci interessa osservare, e ogniqualvolta esse verranno
modificate, con l’interruzione del programma andremo a controllare se si
siano verificate eventuali anomalie fino a scovare il difetto.
Queste tecniche le abbiamo già definite in maniera ampia nel capitolo precedente,
adesso ci siamo soltanto limitati a definire il metodo pratico con cui vengono
applicate all’interno dell’ambiente di lavoro!
~ 15 ~
Come esempio a cui applicare i metodi di debugging, prendiamo un programma
C++ che implementa una coda: questa struttura dati è una struttura FIFO (First In
– First Out) nel quale il primo elemento che viene introdotto è il primo elemento
ad essere eliminato. Quindi nel programma è stata implementata una classe Coda,
in cui è stata costruita un’altra classe che definisce un singolo nodo, formato da un
elemento di tipo T (definito tramite l’uso dei template), che è il valore del nostro
nodo che andiamo a inserire nella struttura dati, e da un puntatore Nodo* next che
punta all’elemento successivo della coda. All’interno di questa classe Coda sono
stati definiti metodi per l’inserimento e rimozione di elementi, per la stampa a
video dell’intera coda inserita e dell’eventuale e attuale testa della coda, se essa
non risulti vuota. All’interno del programma, per poter poi mostrare le
funzionalità delle varie tecniche di debugging, andiamo ad iniettare alcuni difetti,
in particolare apportiamo delle modifiche su alcuni dei metodi definiti che portano
a risultati sballati: in primis, effettuiamo una modifica sul metodo Push(), rivolto
all’inserimento di un nodo all’interno della coda. La funzione interessata
originale, senza errori, riguardante l’inserimento di un elemento, è questa:
void Inserisce(Coda<int>&c){
int e;
cout<<"\n Digita l'elemento da inserire:";
cin>>e;
if(!c.Full())c.Push(e);
else cout<<"\n Coda Piena";
}
template<class T>
void Coda<T>::Push(const T e) {
Nodo*p = new Nodo(e, testa)
p->elem = e;
if (testa == 0)
testa = p;
else coda->next = p;
p->next = 0;
coda = p;
}
Come possiamo vedere, questo metodo chiede innanzitutto che in ingresso sia
dato un valore, del tipo che ho definito all’interno del main, che va poi inserito
all’interno della coda; come prima cosa viene definito un nuovo Nodo con
puntatore: dopo aver posto e come elemento del nostro Nodo appena definito,
~ 16 ~
effettuiamo un controllo sulla nostra coda: se la coda fosse vuota (testa==0) il
nostro Nodo è il primo elemento della coda e quindi sarà la testa di essa;
altrimenti, se la coda non fosse vuota, andiamo a porre il Nodo p come ultimo
elemento inserito, quindi il puntatore Coda dovrà essere collegato proprio a p; a
sua volta il puntatore di p sarà momentaneamente collegato a 0, poiché è l’ultimo
elemento, in attesa di un nuovo inserimento; infine il puntatore p non è nient’altro
che il nuovo puntatore Coda essendo l’ultimo elemento. Come primo difetto
inserito all’interno del programma, modifichiamo l’istruzione condizionale
if(testa==0), sostituendo all’uguaglianza la disuguaglianza, ovvero if(testa!=0),
quindi come se avessi commesso nella scrittura del programma un errore
concettuale o di distrazione: così facendo il programma non arriva a generare
risultati sballati, ma addirittura va in crash e si chiude.
Figura 5: Errore in esecuzione a causa del primo difetto
~ 17 ~
Il secondo errore inserito, che temporalmente è successivo al primo, riguarda la
funzione di stampa in output della testa della coda, ovvero del primo elemento
presente all’interno della struttura. Il metodo che rappresenta questa funzione del
programma, è il seguente:
void Esamina(Coda<int>&c){
int e;
if(!c.Empty()){
c.Top(e);
cout<<"\n La testa e':"<<e;
}
else cout<<"\n Coda vuota";
}
template<class T>
void Coda<T>::Top(T&e){
if(testa!=0){
e=testa->elem;
}
}
In questo caso, è un metodo molto breve e semplice: praticamente, verifica se la
coda contenga elementi o se essa sia vuota. Se la coda fosse vuota, come
possiamo vedere il programma in output ci segnalerà che non ci sono elementi
all’interno della struttura, altrimenti ci stamperà l’elemento appartenente al nodo a
cui punta il puntatore Testa. La modifica fatta in questo frangente è la stessa
effettuata nella prima funzione, ovvero all’interno dell’operatore condizionale
andiamo ad invertire il simbolo di uguaglianza: in questo caso, invece di
if(testa!=0), scriviamo if(testa==0).
~ 18 ~
Per mostrare l’errore causato da questa espressione, andiamo ad effettuare un
confronto tra l’output privo di errori e l’output del programma in cui è presente
questo difetto nella funzione Top():
Figura 6: Esecuzione corretta del programma
Figura 7: Esecuzione errata del programma a causa del secondo difetto
~ 19 ~
Il programma effettua 5 inserimenti nella coda e 3 estrazioni: nella prima
immagine, ovvero nell’esecuzione del programma corretto, gli elementi inseriti in
sequenza sono 1,2,3,4,5; avendo estratto 1,2,3, il primo elemento rimasto è la testa
della coda, ovvero 4; nella seconda esecuzione, in cui ho iniettato il piccolo
difetto, gli elementi inseriti ed estratti sono i medesimi, la differenza è che in
questo caso, siccome la funzione Top(), che ci mostra la testa della coda, è stata
implementata in maniera errata e quindi, in questo caso, il puntatore testa punta
erroneamente all’ultimo elemento estratto, ovvero 3, invece che al primo elemento
rimasto nella Coda.
Dopo aver iniettato i vari difetti, e constatato che il programma produce risultati
completamente errati, passiamo alla fase di debugging vera e propria e quindi
all’utilizzo delle varie tecniche sopra citate.
3.1 Debugging tramite backtracking
Ovviamente, considerando l’ordine delle istruzioni, il primo errore che si
manifesta all’interno del programma è l’errore sull’inserimento, che interrompe
bruscamente l’esecuzione del programma. Quindi, seguendo la logica del
backtracking, anche se l’inserimento sia la prima azione che il programma
effettua, dobbiamo andare all’indietro nel programma fino all’inizio del main:
int main(){
Coda<int>c;
Inserisce(c);
Inserisce(c);
Inserisce(c);
Inserisce(c);
Inserisce(c);
Visualizza(c);
Estrae(c);
Estrae(c);
Estrae(c);
Esamina(c);
return 0;
}
~ 20 ~
Partendo dal termine del file eseguibile, osserviamo molto velocemente che il
problema potrebbe risiedere nella chiamata del metodo Inserisce(), quindi
seguendo la logica del backtracking andiamo ad esaminare la suddetta funzione:
void Inserisce(Coda<int>&c){
int e;
cout<<"\n Digita l'elemento da inserire:";
cin>>e;
if(!c.Full())c.Push(e);
else cout<<"\n Coda Piena";
}
Ricordiamo l’esempio dell’albero fatto nel paragrafo precedente, quindi, partendo
dalla riga di codice in cui si è presumibilmente verificato l’errore in esecuzione,
andiamo a ritroso nel programma ricercando quelle istruzioni che potrebbero
essere difettose e quindi restituito valori errati: analizzando la funzione, notiamo
come il difetto che interrompe il programma potrebbe risiedere nell’immissione
dell’elemento e, quindi dovremo andare ad osservare eventuali errori nella
definizione della variabile di Template T all’interno del main, oppure dovremo
esaminare le funzioni Full() e Push(). Ritornando nel main, l’istruzione che
definisce il tipo della variabile e, ovvero Coda<int> C, definisce una coda di
interi, che è anche la stessa che viene implementata all’interno della definizione
del metodo richiamato, quindi avendo verificato che il tipo dell’elemento è
coerente con la definizione del tipo della struttura dati, dobbiamo cambiare strada.
Ritorniamo al punto di partenza ed esaminiamo l’opzione seguente: passiamo alla
funzione Full():
bool Full() const {
return false;
}
~ 21 ~
È evidente che non possiamo avere alcun errore nella suddetta funzione, poiché
all’interno dell’if la condizione restituisce sempre il valore booleano true, quindi
passiamo all’analisi della funzione Push():
template<class T>
void Coda<T>::Push(const T e) {
Nodo*p = new Nodo(e, testa);
p->elem = e;
if (testa != 0)
testa = p;
else coda->next = p;
p->next = 0;
coda = p;
}
Analizziamo la funzione: dopo aver definito il nodo, e aver inserito
opportunamente l’elemento in p→elem, andiamo ad analizzare l’istruzione
presente all’interno dell’if: l’istruzione letteralmente dice che, se il puntatore testa
non punti a 0, ergo la coda non è vuota, il puntatore del nuovo nodo p è il nuovo
nodo a cui punta il puntatore: questo è sicuramente un errore, poiché ovviamente
sappiamo che la coda è una struttura dati la cui testa è il primo elemento inserito
in essa, e se il primo elemento venisse rimosso il puntatore punta all’elemento
successivo. In questo caso, quindi, il puntatore testa punta al nuovo nodo solo nel
caso in cui la coda fosse vuota, quindi l’istruzione deve chiaramente essere
corretta in if(testa==0). Dopo aver modificato, compilando ed eseguendo
osserviamo di aver risolto questo problema, ma vediamo che il programma esegue
ma in maniera sbagliata. L’errore, come abbiamo visto precedentemente, risiede
nella stampa a video della testa della coda, quindi andando a fare lo stesso
ragionamento ripercorriamo il programma partendo dall’ultima istruzione del
main: partendo dalla fine, l’istruzione che ci interessa è quella che richiama la
funzione Esamina(), metodo in cui viene esaminata la coda e, se presenti elementi
all’interno di essa, stampa a video l’elemento a cui punta il puntatore testa.
~ 22 ~
void Esamina(Coda<int>&c){
int e;
if(!c.Empty()){
c.Top(e);
cout<<"\n La testa e':"<<e;
}
else cout<<"\n Coda vuota";
}
Avendo già verificato in precedenza che effettivamente si tratta di una coda di
interi, le due istruzioni che dovremmo esaminare sono Empty() e Top(). Partiamo
dalla funzione Empty():
bool Empty() const {
return testa == 0;
}
Tale funzione restituisce valore booleano true se il puntatore testa non punta a
nessun elemento, cioè la coda non ha nessun elemento all’interno, e valore
booleano false se il puntatore punta a un elemento della Coda, sicché la Coda non
è vuota: vuol dire che in tale funzione non possiamo avere alcun errore che possa
compromettere il risultato, poiché sintatticamente corretta. Passiamo quindi ad
analizzare la funzione Top():
template<class T>
void Coda<T>::Top(T&e){
if(testa==0){
e=testa->elem;
}
}
Tale funzione è composta da una sola istruzione all’interno di un blocco if, e
andandola ad analizzare, letteralmente funziona in questo modo: se il puntatore
testa non punta a nessun elemento, allora l’elemento che la funzione Esamina()
va a mostrare a video è quello il quale questa funzione Top() assegna alla
variabile elem del puntatore testa. Chiaramente c’è un errore concettuale in questa
espressione, poiché il puntatore andrebbe a restituire un elemento che non è
~ 23 ~
all’interno della Coda, nonostante questa assegnazione venga effettuata soltanto se
testa==0, che ci indica una coda vuota; invece sarebbe stato corretto scrivere
testa!=0, dato che il puntatore testa, il cui valore viene inizialmente assegnato e in
seguito aggiornato con inserimento e rimozione di elementi, tramite le funzioni
Inserisce() ed Estrai() (che non abbiamo visto in questo esempio), deve restituire
l’elemento a cui punta solo nel caso in cui la coda non sia vuota, ma contenga uno
o più elementi. Corretta questa istruzione, andiamo nuovamente a compilare ed
eseguire il programma, e vediamo che questa volta il puntatore testa restituisce il
valore esatto, e quindi abbiamo risolto il problema ed il programma funziona in
maniera corretta.
3.2 Debugging tramite brute force
Abbiamo già visto in precedenza che, con l’utilizzo di questa tecnica, sicuramente
raggiungeremo la causa del nostro difetto; il problema è vedere quanto tempo
impiegheremo per arrivare ad un risultato. In questo caso, noi scorreremo
interamente il nostro programma con l’utilizzo di Step Into, a partire dalla prima
linea di codice, e ogni volta che andremo a cambiare istruzione dovremo
controllare lo stato di tutte le variabili che abbiamo definito all’interno del nostro
programma e che compaiono nella finestra Variables all’interno dell’ambiente di
Debug in Eclipse: infatti, quando andremo a cominciare l’operazione di Debug,
Eclipse ci trasporta all’interno di un nuovo ambiente in cui sono presenti diverse
finestre che ci riportano lo stato delle variabili, i breakpoint e i watchpoint che
abbiamo settato e altre informazioni utili al nostro scopo. Quando andremo ad
avviare il nostro debugger, ci ritroveremo in questa situazione:
~ 24 ~
Figura 7: Avvio del debugging in Eclipse
Ci troviamo nella prima istruzione del main, quindi quando andremo a cliccare
Step Into, la prima operazione che effettuerà il programma sarà quella di
inserimento: ciò vuol dire che tramite il comando utilizzato ci addentreremo nei
metodi richiamati fino al punto in cui ci chiede di inserire l’elemento. In quel
punto il debugger si bloccherà, e non sarà possibile continuare finchè non avremo
inserito l’elemento nella finestra di esecuzione. Infatti, a quel punto ecco cosa ci
apparirà all’interno della Console:
Figura 8: Fase di inserimento da tastiera durante il debugging
~ 25 ~
Una volta digitato l’elemento che vorremmo inserire nella coda, possiamo
continuare a scorrere il programma, e iniziare a controllare le nostre variabili:
infatti, vediamo che nel campo Variables la variabile e è stata modificata ed ha
assunto il valore che noi stessi abbiamo digitato:
Figura 9: Il valore di e cambia durante l’inserimento
Continuiamo con il debugging: vediamo nel campo variables che vengono
aggiornati il puntatore p e il puntatore this, fino a che arriviamo alla condizione
if(testa!=0) testa=p; cosa succede in questo caso? Vediamo che siccome la coda è
vuota, l’esecuzione del programma ci porta a saltare il collegamento del puntatore
testa all’elemento p ed esegue l’istruzione coda→next=p; ma sappiamo che la
coda è vuota e quindi il tentare di accedere a un campo interno di un puntatore che
punta a NULL è una violazione: abbiamo un segmentation fault. Quindi grazie
alla brute force abbiamo riscontrato l’errore nella funzione Push() e possiamo
andare a correggerlo. Eseguendo il programma, ci renderemo conto del secondo
errore presente all’interno di esso, quindi dovremo ricominciare con il brute force
dalla prima linea di codice. Ricordando che abbiamo 5 inserimenti e 3 estrazioni
dalla coda, tramite l’opzione Step Into dobbiamo addentrarci per 5 volte
all’interno dei metodi richiamati per l’inserimento e 3 volte per quelli richiamati
dall’estrazione, il che vuol dire che abbiamo speso un bel po’ di tempo per
avanzare con il debugging e soprattutto controllare tutte le variabili passo dopo
passo. In questo caso, seguendo il ragionamento fatto per il primo errore, avremo
5 inserimenti e 5 estrazioni senza alcun tipo di problema; il problema lo
incontreremo quando verrà richiamata la funzione Esamina(), che a sua volta,
~ 26 ~
come già detto all’interno del paragrafo riguardante il backtracking, richiama la
funzione Top(): cosa vedremo? Vedremo che all’interno della finestra Variables il
valore elem a cui punta il puntatore testa non è il valore che indica l’elemento in
testa alla coda dopo le estrazioni dei primi elementi, quindi siamo riusciti a
raggiungere anche il secondo errore, possiamo effettuare la correzione e verificare
che adesso il programma funzioni in maniera esatta e ci dia i risultati che ci
aspettiamo.
2.3 Debugging tramite analisi dinamica dei dati
Questa tecnica di debugging si basa sull’utilizzo dei watchpoint che, come detto
in precedenza, sono punti di interruzione, come i breakpoint, con la differenza che
essi vengono posti su una specifica variabile, un attributo, che vogliamo porre
sotto osservazione e non su una linea di codice; in più si bloccano ogniqualvolta la
variabile su cui vengono posizionati subisce una modifica, in modo che possiamo
controllare con gli strumenti che abbiamo a disposizione il loro aggiornamento,
per cercare di captare la sorgente di un difetto all’interno del codice, ammesso che
ce ne sia uno. Nel nostro caso, come già sappiamo, abbiamo un primo difetto che
si verifica sull’inserimento di un elemento nella coda e un secondo sulla stampa a
video della testa della coda. Quindi, dovremo innanzitutto posizionare i nostri
watchpoint sulle variabili che ci interessa osservare: poiché il primo difetto si
verifica sull’inserimento delle variabili, andremo a porre i nostri punti di
osservazione sul puntatore testa, sul puntatore coda e su p→elem e p→next, che
sono le variabili del nuovo nodo che andremo ad inserire all’interno della coda.
Una volta definito quali elementi osservare, e collegato i watchpoint a tali
variabili, possiamo cominciare la fase di debugging. Ciò che riscontriamo è che
per il primo difetto avremo lo stesso risultato che abbiamo avuto per l’algoritmo
Brute Force: praticamente, al momento dell’inserimento del primo elemento,
~ 27 ~
poiché tenteremo di accedere alle variabili interne di un puntatore che punta a
NULL, il programma smetterà di funzionare, quindi è come se avessimo risolto il
primo problema con lo stesso metodo utilizzato precedentemente, con la
differenza che in questo caso avremo avviato il debugger con il comando Resume
che teoricamente avrebbe dovuto bloccarsi nel momento in cui una delle variabili
osservate sarebbe stata modificata. Anche in questo caso abbiamo risolto il
problema, con una velocità maggiore rispetto ai casi precedenti. Passiamo
all’analisi del secondo errore che, come già sappiamo, si mostra solo dopo aver
risolto il primo. Torniamo nel nostro ambiente di debugging, e questa volta
poniamo la nostra attenzione sulle variabili interessate da questo nuovo problema:
dobbiamo trovare il difetto di programmazione all’interno di quella variabile che
viene portata in output per la stampa a video della testa della coda, quindi,
siccome ciò che va in output è l’elemento e in cui poniamo l’informazione
contenuta in testa→elem ciò che dobbiamo osservare è semplicemente il
puntatore testa, e a quale variabile essa punta. Il rallentamento che subiamo in
questo caso è superiore rispetto al primo errore che praticamente fa chiudere il
programma in maniera repentina, poiché la funzione top viene richiamata solo alla
fine del programma e noi non siamo certi che il problema risieda lì, quindi
ogniqualvolta che il programma si arresta per una modifica del puntatore
osservato, dobbiamo osservare il valore di esso per verificare che non ci siano
errori. Nel nostro caso, l’errore si mostrerà nel momento in cui richiamiamo la
funzione top(): succede che il puntatore testa, a causa dell’istruzione errata, punti
a un elemento diverso da quello che si trova realmente in testa alla coda, quindi
ciò che vedremo, quando il programma si arresta, è che il valore di elem, variabile
interna del puntatore, non sarà il valore che ci aspetteremo che fosse, ma un altro
valore, indicandoci quindi il difetto nell’istruzione precedente: anche in questo
caso abbiamo risolto il nostro problema, poiché una volta ottenuto questo valore
sballato è molto semplice risalire all’errore.
~ 28 ~
3 CONFRONTO E CONCLUSIONI
Abbiamo terminato l’analisi sulle tecniche di debugging studiate e la prima cosa
da mettere in evidenza è che il nostro è un esercizio molto semplice, e che di
solito i programmatori hanno a che fare con programmi molto più lunghi, il che
vuol dire molto più tempo da utilizzare per la ricerca e la correzione di eventuali
errori che potrebbero verificarsi, sia in fase di compilazione che in fase di
esecuzione: confrontandole, possiamo affermare che, nonostante si trattasse di un
esercizio molto semplice, si nota già la differenza tra i vari metodi e che la pratica
segue fedelmente la teoria: il metodo più veloce da utilizzare è stato quello basato
sull’utilizzo dei watchpoint, poiché scorriamo il programma in maniera molto
veloce e ci dobbiamo preoccupare soltanto di osservare le variabili che abbiamo
deciso di mettere in evidenza per verificare eventuali valori inattesi in esse.
L’unica accortezza che dobbiamo avere è quella di scegliere in maniera esatta le
variabili da porre sotto osservazione, poiché potremmo osservare variabili futili
per cercare di correggere errori che si sono verificati nel programma e quindi
potrebbe capitare di ripetere il procedimento più di una volta: ciò potrebbe
capitare maggiormente in programmi molto lunghi, con ad esempio migliaia di
linee di codice e un numero elevato di variabili definite in essi. La tecnica del
backtracking, invece, rispetto all’analisi statica, è un metodo molto più teorico e
ragionato, che ci porta via un po’ più di tempo per la ricerca di errori: dal punto di
vista personale, è il metodo che più mi ha attratto poiché bisogna ragionare sul da
farsi piuttosto che limitarsi ad osservare dei valori sullo schermo di un computer,
ma sono anche piuttosto certo del fatto che, lavorando con programmi molto più
complessi, è preferibile utilizzare i punti di osservazione, che ci permette di
ottimizzare i tempi che per il backtracking potrebbero protrarsi e diventare
abbastanza lunghi. Ripercorrere programmi all’indietro, partendo dalla linea di
codice che presumibilmente ci mostra i sintomi causati da un errore, potrebbe
risultare un po’ più caotico se dobbiamo addentrarci in metodi complessi, che
contengono ad esempio cicli for da percorrere un determinato numero di volte, il
che alla lunga toglie sicuramente molto più tempo di quello che necessario con
~ 29 ~
altri metodi. Sicuramente sconsigliabile, da utilizzare soltanto come ultima
spiaggia, il Brute Force, che ha allungato i tempi già con un programma semplice
e con poche funzioni come quello che abbiamo considerato. Sappiamo che,
utilizzandolo, sicuramente porterà al risultato che vogliamo e ai problemi che
cerchiamo, ma percorrere passo per passo, linea di codice per linea di codice, un
programma, controllando tutte le variabili ogniqualvolta facciamo uno “step” in
avanti, può risultare molto noioso e dispendioso.
In definitiva, l’introduzione del debugging ha sicuramente facilitato l’attività dei
programmatori, difatti è una delle operazioni più importanti nella messa a punto di
un software, estremamente difficile per la complessità dei programmi oggi in uso
ed estremamente delicata per la possibilità di introdurre nuovi errori o
comportamenti difformi da quelli desiderati. Tutti noi cerchiamo di scrivere un
programma nella maniera più corretta possibile, eppure 9 volte su 10 capita che
l’errore venga fuori durante la fase di test; è questo che rende fondamentali,
nell’ambito di uno sviluppo software, le tecniche di debugging che abbiamo
affrontato.
~ 30 ~
Bibliografia
[1] "Grace Hopper." Bio. A&E Television Networks, 2015. 16/01/2016
[2] What is debugging, http://searchsoftwarequality.techtarget.com/definition/debugging,
19/1/2016
[3] http://www.vogella.com/articles/EclipseDebugging/article.html, 22/01/2016
[4] C.Ghezzi, M.Jazayeri, D.Mandrioli, Ingegneria del software: fondamenti e principi, 2° edizione,
Pearson
[5] H.Agrawal, R.DeMillo, E.Spafford, An Execution Backtracking Approach to Program Debugging,
Department of Computer Sciences, Purdue University, West Lafayette
~ 31 ~