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 ~