Capitolo 2 - Programmare in difesa

Scrivere un software perfetto può essere un obiettivo inafferrabile per gli sviluppatori, ma alcune tecniche difensive, applicate ordinariamente, possono migliorare la qualità del vostro codice.

Sebbene la complessità di una tipica produzione software garantisce che i tester avranno sempre un lavoro, si spera di produrre software esenti da difetti. Le tecniche di disegno orientate agli oggetti aiutano a controllare le difficoltà dei grossi progetti, ma alla fine si devono scrivere i cicli e le funzion. Questi dettagli di programmazione diventano i mattoni dei componenti più grandi necessari ai progetti. Se i cicli non sono corretti e le funzioni calcolano i valori corretti solo la maggior parte delle volte, si avranno problemi non importa il tipo di metodologia usato. In questo capitolo, si imparerà ad usare codice robusto a prescindere dalle dimensioni del progetto.

Il codice è, tra le altre cose, un espressione del proprio tentativo di risolvere un problema. Dovrebbe essere chiaro al lettore esattamente cosa si sta pensando quando si progetta un ciclo. In certi punti del programma, si dovrebbe poter dichiarare che qualche condizione o altre sono valide ( altrimenti il problema non è veramente risolto). Tali dichiarazioni sono dette invarianti, poiché esse dovrebbero essere invariabilmente vere nel punto dove appaiono nel codice; se no, o il design è sbagliato o il codice non riflette il design.

Si consideri un programma del gioco del Hi-Lo. Una persona pensa un numero compreso tra 1 e 100 e l'altra persona indovina il numero. La persona che pensa il numero può dire ad ogni tentativo che il numero è alto, basso oppure esatto. La migliore strategia per chi deve indovinare è un ricerca binaria, che sceglie il punto a metà nell'intervallo nel quale il numero da indovinare si trova. La risposta alto-basso dice a chi deve indovinare in quale metà della lista si trova il numero ed il processo si ripete, dimezzando la dimensione degli intervalli attivi di ricerca ad ogni iterazione. Quindi come si scrive un ciclo per eseguire una corretta ripetizione? Non è sufficiente dire

bool guessed = false;

while(!guessed) {

  ...

}

perchè un utente "cattivo" potrebbe rispondere intenzionalmente in maniera sbagliato e si potrebbe passare tutto il giorno a fare tentativi. Quale assunto si fa ogni volta che si indovina? In altre parole, quale condizione dovrebbe valere per design ad ogni iterazione del ciclo?

Il semplice assunto è che il numero segreto si trova nell'intervallo attivo dei numeri non indovinati : [1, 100]. Si supponga di etichettare i punti finali dell'intervallo con le variabili low e high. Ogni volta che si passa attraverso il ciclo si deve essere sicuri che se quel numero era nell'intervallo [low, high] all'inizio del ciclo, si calcola il nuovo ciclo in modo che esso contenga ancora il numero alla fine dell'iterazione del ciclo corrente.


Lo scopo è esprimere il ciclo invariante nel codice in modo che una violazione può essere rilevato a runtime. Sfortunatamente, poiché il computer non conosce il numero segreto, non si può esprimere questa condizione direttamente nel codice, ma si può almeno scrivere un commento:

while(!guessed) {

  // INVARIANT: the number is in the range [low, high]

  ...

}

Cosa accade quando l'utente di che un numero è troppo alto o troppo basso quando non lo è ? L'inganno escluderà il numero segreto dal nuovo sottointervallo. Poiché una bugia porta sempre ad un'altra, l'intervallo diminuirà a zero ( poiché si rimpicciolisce dimezzandosi ogni volta ed il numero segreto non è in esso). Possiamo esprimere questa condizione del seguente programma:

//: C02:HiLo.cpp {RunByHand}

// Plays the game of Hi-Lo to illustrate a loop invariant.

#include <cstdlib>

#include <iostream>

#include <string>

using namespace std;

 

int main() {

  cout << "Think of a number between 1 and 100" << endl

     << "I will make a guess; "

     << "tell me if I'm (H)igh or (L)ow" << endl;

  int low = 1, high = 100;

  bool guessed = false;

  while(!guessed) {

    // Invariant: the number is in the range [low, high]

    if(low > high) {  // Invariant violation

      cout << "You cheated! I quit" << endl;

      return EXIT_FAILURE;

    }

    int guess = (low + high) / 2;

    cout << "My guess is " << guess << ". ";

    cout << "(H)igh, (L)ow, or (E)qual? ";

    string response;

    cin >> response;

    switch(toupper(response[0])) {

      case 'H':

        high = guess - 1;

        break;

      case 'L':

        low = guess + 1;

        break;

      case 'E':

        guessed = true;

        break;

      default:

        cout << "Invalid response" << endl;

        continue;

    }

  }

  cout << "I got it!" << endl;

  return EXIT_SUCCESS;

} ///:~

 

La violazione dell'invariante è rilevato dalla condizione: if(low > high), perchè se l'utente dice sempre la verità, si troverà sempre il numero segreto prima di terminare i tentativi.

Usiamo anche la tecnica del C standard per riportare lo stato del programma al contesto chiamante restituendo valori diversi da main( ). E' portabile usare return 0; per indicare successo, ma non esiste valore portabile per indicare errore. Per questo motivo usiamo la macro dichiarato per questo scopo in <cstdlib>: EXIT_FAILURE. Per coerenza, ogni volta che si usa EXIT_FAILURE si usa anche EXIT_SUCCESS, perfino se l'ultima è definita come zero.

Assertions

Assertions

La condizione nel programma Hi-Lo dipende dall'input dell'utente, quindi non si può prevenire una violazione dell'invariante. Tuttavia, gli invarianti di solito dipendono solo dal codice che si scrive, quindi essi varranno sempre se si è implementato il design correttamente. In questo caso, è più chiaro fare un asserzione (assert), che è un statement positivo che rivela le sclete di design.

Si supponga di implementare un vettore di interi: un array espandibile che cresce a richiesta. La funzione che aggiunge un elemento al vettore deve prima verifica che c'è un posto libero nell'array che gestisce gli elementi; altrimenti, bisogna richiedere maggiore spazio nella heal e copiare gli elementi esistenti al nuovo spazio prima di aggiungere un nuovo elemento ( e cancellare il vecchio array). Una tale funzione assomiglia alla seguente:

void MyVector::push_back(int x) {

  if(nextSlot == capacity)

    grow();

  assert(nextSlot < capacity);

  data[nextSlot++] = x;

}

 

In questo esempio, data è un array dinamico di int con numero di elementi disponibile pari a capacity ed nextSlot elementi in uso. Lo scopo di grow() è di espandere la dimensione di data in modo che il nuovo valore di capacity è maggiore di nextSlot. Il comportamento corretto di MyVector depende da questa decisione di design e non fallirà mai se il resto del codice è corretto. Asseriamo la condizione con la macro assert(), che è definita nel file header <cassert>.

la macro assert()' della libreria del C standard è concisa e portabule. Se la condizione nei suoi parametri è diversa da zero, l'esecuzione conutina ininterrottamente; altrimenti un messaggio contenente il testo dell'errore insieme al nome del file sorgente ed il numero di lina viene stampato sul canale dello standard error ed il programa termina. Troppo drastico? In pratica, è molto pù drastico permettere la continuazione quando un assunto di progettazione fallisce. Il programma necessità di essere aggiustato.

Se tutto va bene, si testerà accuratamente il codice con tutte le asserzioni intatte per il tempo in cui il prodotto finale è distribuito ( diremo qualcosa in più sul test più in avanti). In questo caso, si possono rimuovere tutte le asserzioni automaticamente definendo la macro NDEBUG e ricompilando l'applicazione.

Per vedere come funzione, si noti che una tipica implementazione di assert() assomiglia a questa:

#ifdef NDEBUG

   #define assert(cond) ((void)0)

#else

  void assertImpl(const char*, const char*, long);

#define assert(cond) \

 ((cond) ? (void)0 : assertImpl(???))

#endif

 

Quando la macro NDEBUG viene definita, il codice si ridice all'espressione (void) 0, quindi tutto ciò che è lasciato nella compilazione è essenzialmente una dichiarazione vuota come risultato del punto e virgola alla fine di ogni assert(). Se NDEBUG non è definito,assert(cond) si espande ad una dichirazione condizionale che, quando cond vale zero, chiama una funzione dipendente dal compilatore ( di nome assertImpl()) con un argomento stringa che rappresenta il testo di cond, insieme al nome del file e il numero di linea dove è apparsa l'asserzione( abbiamo usato ??? come segnaposto nell'esempio, ma la stringa menzionata è computata la, insieme al nome del file ed il numero di linea dove avviene la macro in quel file. Come questi valori sono ottenuti non è materiale della nostra discussione). Se si vogliono abilitare o meno le asserzioni in punti diversi del programma, si deve non solo #define oppure #undef NDEBUG, ma si deve anche re-'includere <cassert>. Le macro sono valutate quando il preprocessore le incontra ed le usa se NDEBUG si applica al punto di inclusione. Il modo più comune i definire NDEBUG un volta per l'intero programma è come opzione del compilatore, tramite le opzione di progetto nel proprio ambiente visuale o a linea di comando come in:

mycc -DNDEBUG myfile.cpp

 

La maggior parte dei compilatori usa il flag -D per definire i nomi delle macro ( sostituire i nome del proprio compilatore al posto di mycc). Il vantaggio di questo approccio è che si possono lasciare le asserzioni nel codic sorgente come documentazione senza problemi a runtime. Il codice delle asserzioni scompare quando NDEBUG viene definito, è importante che non si lavori mai in una asserzione. Occorre testatre solo le condizioni che non cambiano lo stato del proprio programma.

Se sia una buona idea usare NDEBUG per codice rilasciato rimane argomento di dibattito. Tony Hoare, uno dei più influenti informatici di tutti i tempi[15] ha suggerito che disabilitare i controlli a runtime è simile ad un velista che indossa un giubbotto salvagente mentre si allena sulla terra ferma e se lo leva quando va in mare.[16] Se un asserzione fallisce in produzione, si ha un problema peggiore della degradazione delle performance, quindi è sono preferibili.

Non tutte le condizioni dovrebbero essere rafforzate dalle asserzioni. Gli errori dell'utente e i fallimenti di risorse a runtime dovrebbero essere segnalati sollevando eccezioni, come spiegato del capitolo 1. E' attraente usare le asserzioni per la maggior parte delle condizioni di errore quando si abbozza il codice, con l'intento di rimpiazzarlo in seguito con una robusta gestione delle eccezioni. Come qualsiasi altra tentazione, si faccia attenzione, poichè si potrebbe dimenticare di effettuare le modifiche necessarie in seguito. Ricordate: le asserzioni sono intese per verificare le decisioni di design che falliscon solo a causa delle scelte sbagliate del programmatore. L'idela è risolvere tutte le violazioni delle asserzioni durante lo sviluppo. Non usare le asserzioni per condizioni che non sono totalmente sotto il proprio controllo ( per esempio, condizioni che dipendono dagli input dell'utente). In particolare, non usare le asserioni per validare gli argomenti di funzione; sollevare invece un logic_error.


L'uso delle asserzioni come strumento per assicurare la correttezza di un programma è stata fomralizzata da Bertrand Meyer nel suo "Design by Contract methodology" [17] Ogni funzione ha un contratto implitcito con i clienti che, date certe precondizioni, garantisce certe postcondizioni. In altre parole, le precondizioni sono i requisiti per usare la funzione, com fornire argomenti con certi interalli e le postcondizioni sono il risultato fornito dalla funzione, sia dal valore di ritorno o per effetto collaterale.


Quando i programmi client non danno un valido input, bisogna rispondere che hanno rotto ol contratto. Non è il massimo terminare il programma ( sebbene si è giustificati poiché il contratto è violato=, ma un eccezione è certamente appropriata. Questo è il motivo per cui la libreria del C standard lancia un eccezione derivata da logic_error, come out_of_range.[18] Se ci sono funzioni che solo noi chiamiamo, tuttavia, tipo funzioni private in una classe di nostro design, la macro assert( ) è appropriata, poiché si ha il controllo totale della situazione e si vorrà prima fare il debug prima del rilascio.


Un fallimento di una postcondizione indica un errore nel programma ed è appropriato l'uso delle asserzione per qualsiasi invariante in qualsiasi momento, includendo il test delle postcondizioni alla fine di una funzione. Ciò si applica in particolare alle funzioni membro delle classi che mantengono lo stato di un oggetto. Nel esempio precedente MyVector un invariante ragionevole per tutte le funzioni membro pubbliche sarebbe:

assert(0 <= nextSlot && nextSlot <= capacity);

 

oppure,se nextSlot è un unsigned integer, semplicemente

assert(nextSlot <= capacity);

 

Un tale invariante è detto un invariante classe e può essere ragionevolmente rafforzato da un'asserzione. Le sottoclassi giocano un ruolo di subcontractor verso le loro classi base perchè devono preservare il contratto originale tra le classi base ed i loro clienti. Per questo motivo, le precondizioni nelle classi derivate non devono imporre ulteriori requisiti oltre quelli presenti nei contratti base e le postcondizioni devono fornire almeno tanto.[19]

Validare i risultati restituiti al client, tuttavia, non è altro che testare, quindi usare le asserzioni delle post-condzioni in questo caso duplicherebbe il lavoro. Si, è una buona documentazione, ma più di uno sviluppatore ci è cascato nell'imbroglio di usare le asserzioni delle post-condizioni con un sostituto dei test unitari.

Un semplice framework di test unitario

La scrittura del software ruota tutta intorno al soddisfacimento dei requisiti[20]. Creare i requisiti è difficile ed essi possono cambiare da giorno a giorno; si può scoprire in una riunione settimanale di progetto che si è spesa la settimana a fare cose che l'utente non vuole.

Le persone non riescono a fornire requisiti software senza provare un sistema funzionante ed in evoluzione. E' molto meglio un pò di specifica, un pò di disegno, un pò di codice ed un pò di test. In seguito, dopo una valutazione dei risultati, ripetere tutto di nuovo. La capacità di sviluppare in questa maniera iterativa è uno dei grandi punti di forza dell'approccio orientato agli oggetti, ma richiede programmatori svelti che possano scrivere codice elastico. Cambiare è difficile.

Un altro impeto al cambiamento viene da te, il programmatore. L'artigiano in te vuole migliorare continuamente il design del proprio codice. Quale programmatore non ha imprecato durante la manutenzione di prodotti vecchi perchè risultato di patch non modificabili? La riluttanza del management a permettere di interferire con un sistema funzionante duruba il codice dell'elasticità che necessità per durare. "Se funziona, non bisogna fissarlo" alla fine porta a "Non possiamo fissarlo, riscrivilo". Il cambiamento è necessario.

Gli sviluppatori scrivono test unitari' per guadagnare la confidenza per dire le due cose più importanti che uno sviluppatore può dire:

1.Capisco i requisiti

2.Il mio codice soddisfa questi requisiti.(al meglio delle mie conoscenze)

Non c'è modo migliore di assicurarsi di sapere cosa faccia il codice che si sta per scrivere che scrivere prima i test unitari. Questo semplice esercizio aiuta a concentrarsi sul compito ed aiuterà a scrivere il codice più velocemente. In termini XP :

Test + programmazione è più veloce della sola programmazione .

Scrivere prima i test ci difende anche dalle condizioni limite che potrebbero rompere il codice, quindi il codice è più robusto.

Quando il codice passa tutti i test, si sa che se il sistema non funziona, il problema non sta nel proprio codice. La frase "I miei test sono passati tutti" è un potente argomento.

Fortunatamente, l'industria si sta abituando alla disciplina del refactoring, l'arte di ristrutturare internamente il codice per migliorare il suo disegno, senza cambiare il suo comportamento[21]. Questi miglioramenti includono l'estrazione di una nuova funzione da un'altra, o inversamente, l'unione di funzioni membro e la sostituzione di condizioni con il polimorfismo. Il refactoring aiuta il codice ad evolversi.

Se la forza del cambiamento viene dagli utenti o programmatori, i cambiamenti oggi possono interrompere ciò che funzionava ieri. C'è bisogno di un modo di costruire codice che resista al cambiamento e migliori col tempo.

Whether the force for change comes from users or programmers, changes today may break what worked yesterday. We need a way to build code that withstands change and improves over time.

Extreme Programming (XP) [22] è solo uno delle tante pratiche che favorisce un quick-on-your-feet motivo. In questa sezione esplorermo quello che pensiamo sia la chiave per avere successo con un sviluppo incrementale e flessibile: un framework facile da usare di test unitario. (Nota che tester, professionisti che testano il codice di altri per vivere, sono ancora indispensabili. Qui descriviamo solo un modo per aiutare gli sviluppatori a scrivere un codice migliore.)

Il test automatizzato

Quindi come è fatto un test unitario ? Troppo spesso gli sviluppatori utilizzano ben noti input per produrre i risultati attesi, che essi si aspettano visualmente. Esistono due pericoli con questo approccio. Il primo, i programmi non sempre ricevono input corretti. Sappiamo tutti che dovremmo testare le condizioni limite degli input di un programma, ma è difficile pensare a ciò quando si sta cercando di far funzionare le cose. Se si scrive un test per una funzione prima di iniziare a programmare, si può indossare il "cappello del test" e chiedersi : "Cosa potrebbe interrompere ciò?". Codificate un test che testerà una funzione e poi indossate il cappello dello sviluppatore. Si scriverà un codice migliore nel caso in cui il test non venga scritto prima.

Il secondo pericolo è che ispezionare l'output visivamente è noiso e fonte di errori. Molte delle cose può fare un essere umano possono essere fatte dal computer, ma senza errori. E' meglio formulare test come raccolta di espressioni booliane ed avere un rapporto degli eventuali fallimenti.

Per esempio si supponga di scrivere una classe Date che ha le seguenti proprietà:

La classe può memorizzare tre interi che rappresentano l'anno, il giorno ed il mese (assicursi solo che l'anno sia di dimensione almeno 16 bit per soddisfare l'ultimo requisito). L'interfaccia per la classe Date può assomigliare a:

//: C02:Date1.h
// A first pass at Date.h.
#ifndef DATE1_H
#define DATE1_H
#include <string>
 
class Date {
public:
  // A struct to hold elapsed time:
  struct Duration {
    int years;
    int months;
    int days;
    Duration(int y, int m, int d)
    : years(y), months(m), days(d) {}
  };
  Date();
  Date(int year, int month, int day);
  Date(const std::string&);
  int getYear() const;
  int getMonth() const;
  int getDay() const;
  std::string toString() const;
friend bool operator<(const Date&, const Date&);
friend bool operator>(const Date&, const Date&);
friend bool operator<=(const Date&, const Date&);
friend bool operator>=(const Date&, const Date&);
friend bool operator==(const Date&, const Date&);
friend bool operator!=(const Date&, const Date&);
  friend Duration duration(const Date&, const Date&);
};
#endif // DATE1_H ///:~

Prima di implementare questa classe, si possono solidificare i requisiti scrivendo l'inizio di un programma di test. Si può scrivere qualcosa del genere:

//: C02:SimpleDateTest.cpp
//{L} Date
#include <iostream>
#include "Date.h" // From Appendix B
using namespace std;
 
// Test machinery
int nPass = 0, nFail = 0;
void test(bool t) { if(t) nPass++; else nFail++; }
 
int main() {
  Date mybday(1951, 10, 1);
  test(mybday.getYear() == 1951);
  test(mybday.getMonth() == 10);
  test(mybday.getDay() == 1);
  cout << "Passed: " << nPass << ", Failed: "
       << nFail << endl;
}
/* Expected output:
Passed: 3, Failed: 0
*/ ///:~

In questo caso comune, la funzione test() gestisce le variabili globali nPass e nFail. L'unica ispezione visuale che si fa è leggere il punteggio finale. Se un test fallisce, una funzione test() più sofisticata mostra un appropriato messaggio. Il framework che verrà descritto più in avanti in questo capitolo possiede un tale funzione, tra le altre cose.

Si può ora implementare abbastanza della classe Date per far passare questi test e si può procedere iterativamente finchè tutti i requisiti sono soddisfatti. Scrivendo prima i test, si pensa prima ai casi limiti che possono interrompere il codice e si scrive codice corretto alla prima stesura. Tale esercizio può produrre la seguente versione di un test per la classe Date:

 

//: C02:SimpleDateTest2.cpp
//{L} Date
#include <iostream>
#include "Date.h"
using namespace std;
 
// Test machinery
int nPass = 0, nFail = 0;
void test(bool t) { if(t) ++nPass; else ++nFail; }
 
int main() {
  Date mybday(1951, 10, 1);
  Date today;
Date myevebday("19510930");
   
  // Test the operators
  test(mybday < today);
  test(mybday <= today);
  test(mybday != today);
 test(mybday == mybday);
 test(mybday >= mybday);
 test(mybday <= mybday);
 test(myevebday < mybday);
 test(mybday > myevebday);
 test(mybday >= myevebday);
 test(mybday != myevebday);
 
 // Test the functions
 test(mybday.getYear() == 1951);
 test(mybday.getMonth() == 10);
 test(mybday.getDay() == 1);
 test(myevebday.getYear() == 1951);
 test(myevebday.getMonth() == 9);
 test(myevebday.getDay() == 30);
 test(mybday.toString() == "19511001");
 test(myevebday.toString() == "19510930");
 
 // Test duration
 Date d2(2003, 7, 4);
 Date::Duration dur = duration(mybday, d2);
 test(dur.years == 51);
 test(dur.months == 9);
 test(dur.days == 3);
 
 // Report results:
 cout << "Passed: " << nPass << ", Failed: "
      << nFail << endl;
} ///:~

Questo test può essere sviluppato ulteriormente. Per esempio, non si è testato che lunghe durate siano gestite correttamente. Ci fermeremo qui, ma vi siete fatti un'idea. L'implementazione completa della classe Date è disponibile nei file Date.h e Date.cpp dell'appendice.[23]

The TestSuite Framework

Sono disponibili come download sul Web alcuni tool di test unitario per il C++, per esempio CppUnit[24]. Il nostro scopo è solo quello di presenterare un meccanismo di test facile da usare, ma anche semplice da capire internamente e perfino da modificare se necessario. Quindi, nello spirito di "Fai la cosa più semplice che potrebbe funzionare"[25] abbiamo sviluppato la TestSuite Framework, un namespace chiamato TestSuite che contiene due classi chiave: Test e Suite.

la classe Test è una classe base astratta da cui si deriva un oggetto test. Essa tiene traccia del numero di test passati e falliti e mostra il testo di ogni test che fallisce. Semplicemente si scrive la funzione membro run (), che dovrebbe a sua volta chimare la macro test_() per ogni condizione di test booliano che si definisce.

Per definire un test per la classe Date usando il framework, si può ereditare da Test come mostra il seguente programma:

//: C02:DateTest.h
#ifndef DATETEST_H
#define DATETEST_H
#include "Date.h"
#include "../TestSuite/Test.h"
 
class DateTest : public TestSuite::Test {
 Date mybday;
 Date today;
 Date myevebday;
public:
 DateTest(): mybday(1951, 10, 1), myevebday("19510930") {}
 void run() {
   testOps();
   testFunctions();
   testDuration();
 }
 void testOps() {
   test_(mybday < today);
   test_(mybday <= today);
   test_(mybday != today);
   test_(mybday == mybday);
   test_(mybday >= mybday);
   test_(mybday <= mybday);
   test_(myevebday < mybday);
   test_(mybday > myevebday);
   test_(mybday >= myevebday);
   test_(mybday != myevebday);
 } 
  void testFunctions() {
    test_(mybday.getYear() == 1951);
    test_(mybday.getMonth() == 10);
    test_(mybday.getDay() == 1);
    test_(myevebday.getYear() == 1951);
    test_(myevebday.getMonth() == 9);
    test_(myevebday.getDay() == 30);
    test_(mybday.toString() == "19511001");
    test_(myevebday.toString() == "19510930");
  }
  void testDuration() {
    Date d2(2003, 7, 4);
    Date::Duration dur = duration(mybday, d2);
    test_(dur.years == 51);
    test_(dur.months == 9);
    test_(dur.days == 3);
  }
 };
 #endif // DATETEST_H ///:~


Per eseguire il test basta instanziare un oggetto DateTest e chiamare il suo metodo run():

//: C02:DateTest.cpp
// Automated testing (with a framework).
//{L} Date ../TestSuite/Test
#include <iostream>
#include "DateTest.h"
using namespace std;
 
int main() {
  DateTest test;
  test.run();
  return test.report();
}
/* Output:
Test "DateTest":
        Passed: 21,      Failed: 0
*/ ///:~
 

La funzione Test::report( ) mostra l'output precedente ed il numero dei fallimenti, quindi è adatta per essere utilizzata come valore restituito da main().

La class Test utilizza RTTI[26] per ottenere il nome della class (per esempio, DateTest) per il report. C'è anche un meotdo setStream() se si vuole scrivere in un file i risultati del test. Vedremo in seguito l'implementazione della classe Test.

la macro test_() può estrarre il testo di una condizione booleana che fallisce, insieme al nome del file ed il numero di linea[27]. Per vedere cosa accade quando un test fallisce, si può introdurre intenzionalmente un errore nel codice, per esempio invertendo la condizione nella prima chiamata a test_() nella DateTest::testOps( ) del precedente esempio. L'output indica esattamente quale test era nell'errore e dove è accaduto:

 

DateTest failure: (mybday > today) , DateTest.h (line 31)
Test "DateTest":
        Passed: 20      Failed: 1
 

In aggiunta a test_(), il framework include le funzioni succeed_() e fail_(), per i casi dove un test booleano non sarebbe utilizzabile. Queste funzioni si applicano quando la classe che si sta testando protrebbe lanciare eccezioni. Durante il test, create un insimeme di input che causeranno un eccezione. Se non avviene, esso è un errore e si chiama fail_() esplicitamente per mostrare un messaggio e aggiornare il numero dei fallimenti. Se viene lanciata l'eccezione come ci si aspetta, si chiama la funzione succeed_() per aggiornare il numero dei successi.

A scopo illustrativo, si supponga di modificare la specifica dei costruttori non di defalut di Date per lanciare un eccezione DateError ( un tipo innestato in Date e derivato da std::logic_error) se i parametri di ingressono non rappresentano una data valida :

Date(const string& s) throw(DateError);
Date(int year, int month, int day) throw(DateError);
 

Il metodo DateTest::run( ) chiamerà ora la seguente funzione per testare la gestione dell'eccezione:

 void testExceptions() {
   try {
     Date d(0,0,0);  // Invalid
     fail_("Invalid date undetected in Date int ctor");
   } catch(Date::DateError&) {
     succeed_();
   }
   try {
     Date d("");  // Invalid
     fail_("Invalid date undetected in Date string ctor");
   } catch(Date::DateError&) {
     succeed_();
   }
 }
 

In entrambi i casi, se un eccezione non viene lanciata, c'è un errore. Si Noti che si deve manualmente passare un messaggio a fail_( ), poichè non c'è nessuna espressione booleana da valutare.

Suite di test

I progetti veri di solito contengono molte classe, c'è bisogno quindi di un modo per raggruppare i test in modo da premere un solo pulsante per testare tutto il progetto[28]. La classe Suite raccoglie i test in un'unità funzionale. Si aggiungono gli oggetti Test ad una Suite con il metodo addTest(), oppure si può includere un'intera suite esistente con addSuite(). A scopo illustrativo, il seguente esempio raccoglie i programmi del capitolo 3 che usano la classe Test in una singola suite. Si noti che questo file appare nella sotto directory del capitolo 3:

//: C03:StringSuite.cpp
//{L} ../TestSuite/Test ../TestSuite/Suite
//{L} TrimTest
// Illustrates a test suite for code from Chapter 3
#include <iostream>
#include "../TestSuite/Suite.h"
#include "StringStorage.h"
#include "Sieve.h"
#include "Find.h"
#include "Rparse.h"
#include "TrimTest.h"
#include "CompStr.h"
using namespace std;
using namespace TestSuite;
 
int main() {
   Suite suite("String Tests");
   suite.addTest(new StringStorageTest);
   suite.addTest(new SieveTest);
   suite.addTest(new FindTest);
   suite.addTest(new RparseTest);
   suite.addTest(new TrimTest);
   suite.addTest(new CompStrTest);
   suite.run();
   long nFail = suite.report();
   suite.free();
   return nFail;
}
/* Output:
s1 = 62345
s2 = 12345
Suite "String Tests"
====================
Test "StringStorageTest":
  Passed: 2   Failed: 0 
Test "SieveTest":
  Passed: 50  Failed: 0
Test "FindTest": 
  Passed: 9   Failed: 0
Test "RparseTest":
   Passed: 8   Failed: 0
Test "TrimTest":
   Passed: 11  Failed: 0
Test "CompStrTest":
   Passed: 8   Failed: 0
*/ ///:~
 

Cinque dei test precedenti sono contenuti completamente dei file header. TrimTest invece no, perchè contiene dati statici che devono essere definiti in un file di implementazione. Le prime due line di output sono line di trace dal test StringStorage. Si deve dare un nome alla suite com argomento del costruttore. Il metodo Suite::run( ) chiama Test::run( ) per ogni test che è contenuto. Lo stesso accade per Suite::report( ), tranne che si possono inviare i report dei test individuali ad una stream diversa di quella della suite. Se il test passato a addSuite( ) ha già n puntatore stream assegnato, lo mantiene. Altrimenti, prende la sa stream dall'oggetto Suite (come con Test, c'è un secondo argomento ozionale nel costruttore di suite che ha come default std::cout). Il distruttore di Suite non cancella automaticamente i puntatori a i Test contenuti perchè non ha bisogno di risiedere nel heap, questo è il lavoro di Suite::free( ).

Il codice del framework di test

Il codice del framework di test è in una sotto directory chiamata TestSuite nella distribuzione del codice disponibile su www.MindView.net. Per usarla, occorre includerla nel search path del proprio header, linkare i file oggetti ed includere la sottodirectory TestSuite nel search path di libreria. Ecco l'header di Test.h :

//: TestSuite:Test.h
#ifndef TEST_H
#define TEST_H
#include <string>
#include <iostream>
#include <cassert>
using std::string;
using std::ostream;
using std::cout;
 
// fail_() has an underscore to prevent collision with
// ios::fail(). For consistency, test_() and succeed_()
// also have underscores.
 
#define test_(cond) \
  do_test(cond, #cond, __FILE__, __LINE__)
#define fail_(str) \
  do_fail(str, __FILE__, __LINE__)
 
namespace TestSuite {
 
class Test {
  ostream* osptr;
  long nPass;
  long nFail;
  // Disallowed:
  Test(const Test&);
  Test& operator=(const Test&);
protected:
  void do_test(bool cond, const string& lbl,
    const char* fname, long lineno);
  void do_fail(const string& lbl,
    const char* fname, long lineno);
public:
 Test(ostream* osptr = &cout) {
   this->osptr = osptr;
   nPass = nFail = 0;
 }
 virtual ~Test() {}
 virtual void run() = 0;
 long getNumPassed() const { return nPass; }
 long getNumFailed() const { return nFail; }
 const ostream* getStream() const { return osptr; }
 void setStream(ostream* osptr) { this->osptr = osptr; }
 void succeed_() { ++nPass; }
 long report() const;
 virtual void reset() { nPass = nFail = 0; }
};
 
} // namespace TestSuite
#endif // TEST_H ///:~
 

Ci sono tre funzioni virtuali nella classe Test:

Come spiegato nel Volume 1, è sbagliato cancellare un oggetto della heap derivato attraverso un puntatore base a meno che la classe base non abbia un distruttore virtual. Qualsiasi classe intesa come classe base (di solito evidenziata dalla presenza di almeno una funzione virtual) dovrebbe avere un distruttore virtual. L'implementazione di default di Test::reset( ) resetta i contatori di success e failure a zero. Si potrebbe voler usare l'override di questa funzione per resettare lo stato dei dati nell'oggetto test derivato; assicurarsidi chiamare Test::reset( ) esplicitamente nell'override in modo da resettare i contatori. La funzione membro Test::run() è virtuale pura poichè è richiesto l'override nella classe derivata.


Le macro test_( ) e fail_( ) possono includere il nome del file e il numero di linea disponibili dal preprocessore. Originariamente avevamo omesso gli underscore nei nomi, ma la macro fail() collide con ios::fail(), provocando errori di compilazione.

Ecco l'implementazione :

 

//: TestSuite:Test.cpp {O}
#include "Test.h"
#include <iostream>
#include <typeinfo>
using namespace std;
using namespace TestSuite;
 
void Test::do_test(bool cond, const std::string& lbl,
  const char* fname, long lineno) {
  if(!cond)
    do_fail(lbl, fname, lineno);
  else
    succeed_();
}
 
void Test::do_fail(const std::string& lbl,
  const char* fname, long lineno) {
  ++nFail;
  if(osptr) {
    *osptr << typeid(*this).name()
           << "failure: (" << lbl << ") , " << fname
           << " (line " << lineno << ")" << endl;
  }
}
 
long Test::report() const {
  if(osptr) {
    *osptr << "Test \"" << typeid(*this).name()
          << "\":\n\tPassed: " << nPass
          << "\tFailed: " << nFail
          << endl;
 }
 return nFail;
} ///:~
 

La classe Test tiene traccia del numero di successi e fallimenti così come la stream dove su vuole che Test::report() mostri i risultati. Le macro test_( ) e fail_( ) estraggono il nome del file corrente ed il numero di linea dal preprocessore e passano il nome del file a do_test( ) ed il numero di linea a do_fail( ), che fa il vero lavoro di mostrare un messaggio ed aggiornare il giusto contatore. Non ci può essere una buona ragione per permettere di copiare e assegnare questi oggetti di test, quindi abbiamo impedito questo operazioni rendendo privati i loro prototipi ed omettendo i loro rispettivi corpi di funzione.

Ecco gli header file di Suite:

//: TestSuite:Suite.h
#ifndef SUITE_H
#define SUITE_H
#include <vector>
#include <stdexcept>
#include "../TestSuite/Test.h"
using std::vector;
using std::logic_error;
 
namespace TestSuite {
 
class TestSuiteError : public logic_error {
public:
  TestSuiteError(const string& s = "")
  : logic_error(s) {}
};
  
class Suite {
  string name;
  ostream* osptr;
  vector<Test*> tests;
  void reset();
  // Disallowed ops:
  Suite(const Suite&);
  Suite& operator=(const Suite&);
public:
  Suite(const string& name, ostream* osptr = &cout)
  : name(name) { this->osptr = osptr; }
  string getName() const { return name; }
  long getNumPassed() const;
  long getNumFailed() const;
  const ostream* getStream() const { return osptr; }
  void setStream(ostream* osptr) { this->osptr = osptr; }
  void addTest(Test* t) throw(TestSuiteError);
  void addSuite(const Suite&);
  void run();  // Calls Test::run() repeatedly
  long report() const;
  void free();  // Deletes tests
};
   
} // namespace TestSuite
#endif // SUITE_H ///:~
 

La classe Suite mantiene i puntatori ai suoi oggetti Test in un vector. Si noti exception sulla funzione membro addTest(). Quando si aggiunge un test ad una suite, Suite::addTest( ) verifica che il puntatore che si passa non sia null; se esso è null, viene lanciata l'eccezione TestSuiteError. Poichè questo rende impossibile l'aggiunta di un puntatore null alla suite, addSuite( ) esegue un assert di questa condizione ad ogni suo test, come fanno le altre funzioni che usano il vettore dei test ( vedere l'implementazione seguente). Copiare ed assegnare non è permesso così come nella classe Test.

 

//: TestSuite:Suite.cpp {O}
#include "Suite.h"
#include <iostream>
#include <cassert>
#include <cstddef>
using namespace std;
using namespace TestSuite;
 
void Suite::addTest(Test* t) throw(TestSuiteError) {
  // Verify test is valid and has a stream:
  if(t == 0)
    throw TestSuiteError("Null test in Suite::addTest");
  else if(osptr && !t->getStream())
    t->setStream(osptr);
  tests.push_back(t);
  t->reset();
}
 
void Suite::addSuite(const Suite& s) {
for(size_t i = 0; i < s.tests.size(); ++i) {
  assert(tests[i]);
addTest(s.tests[i]);
  }
}
 
void Suite::free() {
  for(size_t i = 0; i < tests.size(); ++i) {
    delete tests[i];
    tests[i] = 0;
  }
}
 
void Suite::run() {
 reset();
 for(size_t i = 0; i < tests.size(); ++i) {
   assert(tests[i]);
   tests[i]->run();
 }
}
 
long Suite::report() const {
  if(osptr) {
    long totFail = 0;
    *osptr << "Suite \"" << name
             << "\"\n=======";
    size_t i;
    for(i = 0; i < name.size(); ++i)
      *osptr << '=';
    *osptr << "=" << endl;
    for(i = 0; i < tests.size(); ++i) {
      assert(tests[i]);
      totFail += tests[i]->report();
    }
    *osptr << "=======";
    for(i = 0; i < name.size(); ++i)
      *osptr << '=';
    *osptr << "=" << endl;
    return totFail;
  }
  else
    return getNumFailed();
}
 
long Suite::getNumPassed() const {
  long totPass = 0;
  for(size_t i = 0; i < tests.size(); ++i) {
    assert(tests[i]);
    totPass += tests[i]->getNumPassed();
  }
  return totPass;
}
 
long Suite::getNumFailed() const {
  long totFail = 0;
  for(size_t i = 0; i < tests.size(); ++i) {
    assert(tests[i]);
    totFail += tests[i]->getNumFailed();
  }
  return totFail;
}
 
void Suite::reset() {
  for(size_t i = 0; i < tests.size(); ++i) {
    assert(tests[i]);
    tests[i]->reset();
  }
}  ///:~
 

Useremo il framework TestSuite dovunque sia applicabile nel resto di questo libro.

Tecniche di debug

La migliore abitudine per il debug è utilizzare gli assert come spiegato all'inizio di questo capitolo; facendo così si avrà un aiuto nel trovare errori logici prima che essi causino problemi reali. Questa sezione contiane altri trucchi e tecniche che potrebbero aiutare durante il debug.

 

Trace macros

A volte è utile stampare il codice di ogni riga come se esso fosse eseguito, sia su cout che su un file di trace. Ecco una macro del preprocessore per fare ciò:

#define TRACE(ARG) cout << #ARG << endl; ARG
 

Ora si può avanti e circondare le righe che si traccinao con questa macro. Tuttavia, si possono avere problmi. Per esempio, se si prendono le righe di comandi :

for(int i = 0; i < 100; i++)
  cout << i << endl;
 

e si mettono entrambe le line dentro TRACE( ), si ottiene:

TRACE(for(int i = 0; i < 100; i++))
TRACE(  cout << i << endl;)
 

che si espande a:

cout << "for(int i = 0; i < 100; i++)" << endl;
for(int i = 0; i < 100; i++)
  cout << "cout << i << endl;" << endl;
cout << i << endl;
 

che non è esattamente ciuò che si vuole. Bisogna utilizzare questa tecnica con attenzione.

Quella che segue è una variazione della macro TRACE( ) :

#define D(a) cout << #a "=[" << a << "]" << endl;
 

Se si vuole mostrare un'espressione, la si mette semplicemente dentro una chiamata a D( ). L'espressione viene mostrata, seguita dal suo valore( si assume che ci sia un operatore sovraccaricato << per lo stesso tipo restituito). Per esempio, si può scrivere D(a + b). Si può usare questa macro ogni volta che si controllo un valore intermedio.

Queste due macro rappresentano le due cose fondamentali che si fanno con un debugger: tracciare lungo l'esecuzione del codice e mostrare i valori. Un buon debugger è un eccellente strumento di produttività, ma a volte essi non sono disponibili o non è comodo usarli. Queste tecniche funzionano sempre in ogni situazione.

Trace file

DISCLAIMER: Questa sezione e le seguenti contengono codice che è ufficialmente non permesso dal C++ Standard. In particolare, ridefiniamo il cout e new con le macro, che possono causare risultati a sorpresa se non si fa attenzione. I nostri esempi lavorano con tutti i compilatori che usiamo, tuttavia, e forniscono informazioni utili. Questo è l'unico posto in questo libro in cui ci discosteremo dalla santità delle pratiche rispettosi degli standard di codificazione. Usate il codice a vostro rischio! Si noti che affinchè questo codice funzioni, deve essere usata una dichiarazione using , in modo che il cout non è premesso dal relativo namespace, cioè std::cout non funzionerà.

Il codice seguente genera facilmente una file di trace e manda tutto l'output che andrebbe normalmente a cout in quel file. Tutto ciò che dovete fare è #define TRACEON ed includere il file di intestazione (naturalmente, è abbastanza facile da scrivere le due linee chiave nel vostro file):

//: C03:Trace.h
// Creating a trace file.
#ifndef TRACE_H
#define TRACE_H
#include <fstream>
 
#ifdef TRACEON
std::ofstream TRACEFILE__("TRACE.OUT");
#define cout TRACEFILE__
#endif
 
#endif // TRACE_H ///:~
 
 

Ecco un semplice test del precedente file:

//: C03:Tracetst.cpp {-bor}
#include <iostream>
#include <fstream>
#include "../require.h"
using namespace std;
 
#define TRACEON
#include "Trace.h"
 
int main() {
  ifstream f("Tracetst.cpp");
  assure(f, "Tracetst.cpp");
  cout << f.rdbuf(); // Dumps file contents to file
} ///:~
 

Poichè cout stato testualmente trasformato in qualche altra cosa da Trace.h, tuti i cout nel vostro programma ora inviano informazioni al file di trace. Questo è un facile modi di catturare l'output in un file, nel caso in cui il vostro sistema operativa non rende semplice la redirezione.

Trovare i memory leak

Le seguenti tecniche di debug sono spiegate nel Volume 1:

1. Per controllare i limiti di un array, utilizzare il template Array che si trova in C16:Array3.cpp del Volume 1 per tutti gli array. Si può disabilitare il controllo ed incrementare l'efficenza quando si è pronti a consegnare ( sebbene non sia il caso di un puntatore ad un array).

2. Controllo per i distruttori non virtual nelle classi base.

Tracciare i new/delete ed i malloc/free Problemi comuni con l'allocazione della memoria includono chiamate effettuate erroneamente a delete per memoria che non è nel free store, cancellandola più di una volta e molto spesso dimenticando di cancellare un puntatore. Questa sezione descrive un sistema che può aiutare a tracciare questo tipo di problemi.

Un disclaimer addizionale a quello della sezione precedente: a causa del modo in cui abbiamo sopraccaricato new, la tecnica seguente può non funzionare su tutte le piattaforme e funzionerà solo con programmi che non chiamano la funzione new() esplicitamente. Siamo stati abbastanza attenti in questo libro nel presentare solo codice conforme al C++ Standard, ma in questo caso stiamo facendo un'eccezione per le seguenti ragioni:


1. Anche se è tecnicamente illegale, funziona con molti compilatori.[29]

2. lungo la via illustreremo utili considerazioni.

 

Per usare il sistema di controllo della memoria, si include semplicemente il file header MemCheck.h, si linka MemCheck.obj alla propria applicazione per intercettare tutte le chiamate a new e delete, e si chiama la macro MEM_ON( ) ( di seguito spiegata ) per inizializzare il tracciamento della memoria. Un trace di tutte le allocazioni e deallocazioni viene stampato sullo standard output ( via stdout ). Quando si usa questo sistema, tutte le chiamate ad new memorizzano informazioni circa il file e la linea dove sono state chiamate. Questo viene realizzato utilizzano la sintassi del piazzamento per l'operatore new.[30] Sebbene si usi tipicamente la sintassi del piazzamento quando si vuole piazzare oggetti ad uno specifico punto della memoria, esso può anche creare un operatore new() con qualsiasi numero di argomenti. Ciò è usato nel seguente esempio per memorizzare i risultati delle macro __FILE__ and __LINE__ ogni volta che new viene chiamato:

 

//: C02:MemCheck.h
#ifndef MEMCHECK_H
#define MEMCHECK_H
#include <cstddef>  // For size_t
 
// Usurp the new operator (both scalar and array versions)
void* operator new(std::size_t, const char*, long);
void* operator new[](std::size_t, const char*, long);
#define new new (__FILE__, __LINE__)
 
extern bool traceFlag;
#define TRACE_ON() traceFlag = true
#define TRACE_OFF() traceFlag = false
 
extern bool activeFlag;
#define MEM_ON() activeFlag = true
#define MEM_OFF() activeFlag = false
 
#endif // MEMCHECK_H ///:~
 

E' importante includere questo file in ogni file sorgente in cui si vuole tracciare l'attità del free storem ma lo si deve includere per ultimo. La maggior parte degli header della libreria standard sono template e poichè la maggior parte dei compilatori usa il modello di inclusione della compilazione del template ( cioè tutti il codice sorgente è negli header), la macro che rimpiazza new in MemCheck.h sostituirebbe tutte le istanze dell'operatore new nel codice del sorgente di libraria ( e si avrebbero errori di compilazione). Oltretutto siamo interessati a tracciare i nostri errori di memoria, non quelli della libreria.

Nel file seguente, che contiene l'implementazione del tracciamento della memoria, viene fatto tutto con l'I/O del C standard piuttosto che con le iostream del C++. Non ci dovrebbe essere differenza, poichè non stiamo interferendo con l'uso di iostream del free store, ma quando ci abbiamo provato, qualche compilatore ha dato errore. Tutti i compilatori sono contenti con <cstdio>.

 

//: C02:MemCheck.cpp {O}
#include <cstdio>
#include <cstdlib>
#include <cassert>
#include <cstddef>
using namespace std;
#undef new
 
// Global flags set by macros in MemCheck.h
bool traceFlag = true;
bool activeFlag = false;
 
namespace {
 
// Memory map entry type
struct Info {
  void* ptr;
  const char* file;
  long line;
};
 
// Memory map data
const size_t MAXPTRS = 10000u;
Info memMap[MAXPTRS];
size_t nptrs = 0;
 
// Searches the map for an address
int findPtr(void* p) {
 for(size_t i = 0; i < nptrs; ++i)
   if(memMap[i].ptr == p)
     return i;
 return -1;
}
 
void delPtr(void* p) {
 int pos = findPtr(p);
 assert(pos >= 0);
 // Remove pointer from map
 for(size_t i = pos; i < nptrs-1; ++i)
   memMap[i] = memMap[i+1];
 --nptrs;
}
 
// Dummy type for static destructor
struct Sentinel {
 ~Sentinel() {
   if(nptrs > 0) {
     printf("Leaked memory at:\n");
     for(size_t i = 0; i < nptrs; ++i)
       printf("\t%p (file: %s, line %ld)\n",
         memMap[i].ptr, memMap[i].file, memMap[i].line);
   }
   else
     printf("No user memory leaks!\n");
 }
};
 
// Static dummy object
Sentinel s;
 
} // End anonymous namespace
 
// Overload scalar new
void*
operator new(size_t siz, const char* file, long line) {
 void* p = malloc(siz);
 if(activeFlag) {
   if(nptrs == MAXPTRS) {
     printf("memory map too small (increase MAXPTRS)\n");
     exit(1);
   }
   memMap[nptrs].ptr = p;
   memMap[nptrs].file = file;
   memMap[nptrs].line = line;
   ++nptrs;
 }
 if(traceFlag) {
   printf("Allocated %u bytes at address %p ", siz, p);
   printf("(file: %s, line: %ld)\n", file, line);
 }
 return p;
}
 
// Overload array new
void*
operator new[](size_t siz, const char* file, long line) {
 return operator new(siz, file, line);
}
 
// Override scalar delete
void operator delete(void* p) {
 if(findPtr(p) >= 0) {
   free(p);
   assert(nptrs > 0);
   delPtr(p);
   if(traceFlag)
     printf("Deleted memory at address %p\n", p);
 }
 else if(!p && activeFlag)
   printf("Attempt to delete unknown pointer: %p\n", p);
}
 
// Override array delete
void operator delete[](void* p) {
 operator delete(p);
} ///:~
 

I flag booleani traceFlag e activeFlag sono globali, quindi possono essere modificati nel proprio codice dalle macro TRACE_ON( ), TRACE_OFF( ), MEM_ON( ) e MEM_OFF( ). In generale, occorre rinchiudere tutto il codice in main() all'interno di una coppia MEM_ON( )-MEM_OFF( ) in modo che quella memoria sia sempre tracciata. Il tracciamento, che stampa a ideo l'attività di rimpiazzamento delle funzioni per l'operatore new() e l'operatore delete(), è attivo per default, ma lo si può spegnere con TRACE_OFF( ). In ogni caso, il risultato finale viene sempre stampato ( si vedano i test più avanti nel capitolo).

MemCheck traccia la memoria mantenendo tutti gli indirizzi allocati dall'operatore new() in un array di strutture Info, che mantiene anche il nome del file e il numero di linea dove avviene una chiamata a new. Per prevenire collisioni con altri nome pizzati nel namespace globale, le informazioni sono tenute il più possibile dentro il namespace anonimo. La classe Sentiel esiste esclusivamente per chiamare un distruttore di oggetto statico quando il programma termina. Questo distruttore ispeziona meMap per vedere se c'è qualche puntatore che deve essere cancellato ( indicando un memory leak).

Il nostro operatore new() usa malloc() per ottenere memoria e poi aggiunge il puntatore ed le informazioni del file associato a memMap. L'operatore delete() fa il contrario chiamando free() e decrementando nptrs, ma prima controlla per vedere se il puntatore in questione è nella mappa al primo posto. Se non lo è sia perchè si sta cercando di cancellare un indirizzo che non è nel free store oppure si sta cercando di cancellare uno che è già stato cancellato e rimosso dalla mappa. La variabile activeFlag è importante qui perchè non si vuole processare ogni deallocazione da tutte le attività di shutdown del systema. Chiamando MEM_OFF() alla fine del codice, activeFlag è posta a false, e le successive chiamate a delete verrano ignorate ( questa è una cattiva cosa in un programma vero, ma il nostro scopo qui è cercare i leak; non stiamo debuggando la libreria). Per semplicità, continuiamo tutto il lavoro con una versione scalare al posto degli array.

 

//: C02:MemTest.cpp
//{L} MemCheck
// Test of MemCheck system.
#include <iostream>
#include <vector>
#include <cstring>
#include "MemCheck.h"   // Must appear last!
using namespace std;
 
class Foo {
 char* s;
public:
 Foo(const char*s ) {
   this->s = new char[strlen(s) + 1];
   strcpy(this->s, s);
 }
 ~Foo() { delete [] s; }
};
 
int main() {
 MEM_ON();
 cout << "hello" << endl;
 int* p = new int;
 delete p;
 int* q = new int[3];
 delete [] q;
 int* r;
 delete r;
 vector<int> v;
 v.push_back(1);
 Foo s("goodbye");
 MEM_OFF();
} ///:~
 

Questo esempio verifica che si possa usare MemCheck in presenza di stream, standard containers, e classi che allocano memoria nei costruttori. I puntatori p e q sono allocati e deallocati senza problemi, ma r non è un puntatore valido dell'heap, quindi nell'output c'è un errore perchè si cerca di cancellare un puntatore sconosciuto:

hello
Allocated 4 bytes at address 0xa010778 (file: memtest.cpp, line: 25)
Deleted memory at address 0xa010778
Allocated 12 bytes at address 0xa010778 (file: memtest.cpp, line: 27)
Deleted memory at address 0xa010778
Attempt to delete unknown pointer: 0x1
Allocated 8 bytes at address 0xa0108c0 (file: memtest.cpp, line: 14)
Deleted memory at address 0xa0108c0
No user memory leaks!
 

Dopo MEM_OFF(), nessuna successiva chiamata all'operatore delete() da vector o ostream è processato. Si possono ancora avere qualche chiamata a delt dalle reallocazioni eseguite da i container.


Se si chiama TRACE_OFF( ) all'inizio del programma l'output è :

hello
Attempt to delete unknown pointer: 0x1
No user memory leaks!

Sommario

Molte emicranie dovute al software engineering possono essere evitate essendo intenzionale circa che cosa state facendo. Probabilmente state usando le asserzioni mentali mentre avete data forma i vostri cicli e funzioni, anche se non avete usato la macro assert() . Se si usa assert(), si troveranno gli errori di logica più velocemente e si otterrà un codice anche più leggibile. Occorre ricordare di usare le asserzioni soltanto per gli invarianti e non per la gestione degli errori a runtime.

Niente vi darà maggior tranquillità di un codice completamente testato. Se è stato una seccatura nel passato, usare un framework automatizzato, come quello qui presentato, per integrare le routine di test nel proprio lavoro quotidiano. Voi (ed i vostri utenti!) sarete felici di averlo fatto.

Esercizi

Le soluzioni degli esercizi si trovano nel documento elettronico "The Thinking in C++ Volume 2 Annotated Solution Guide", disponibile con poca spesa su www.MindView.net.

1. Scrivere un programma di test utilizzando il framework TestSuite per la classe standard vector che testa le seguenti funzioni membro con un vettore di interi: push_back( ) ( inserisce un elemento alla fine del vettore), front() ( restituisce il primo elemento del vettore), back() ( restituisce l'ultimo elemento del vettore), pop_back() ( rimuove l'ultimo elemento senza restituirlo), at() ( restituisce l'elemento nella posizione specificata) e size() ( restituisce il numero di elementi). Assicurarsi di verificare che vector::at() lanci un eccezione std::out_of_range se l'indice fornito non è valido.

2. Si supponga di dover sviluppare una classe chiamata Rational che supporta i numeri razionali ( frazioni). La frazione in un oggetto Rational dovrebbe sempre essere memorizzata nei termini più piccoli e un denominatore che vale zero è un errore. Ecco un esempio di tale classe:

 

//: C02:Rational.h {-xo}
#ifndef RATIONAL_H
#define RATIONAL_H
#include <iosfwd>
 
class Rational {
public:
 Rational(int numerator = 0, int denominator = 1);
 Rational operator-() const;
 friend Rational operator+(const Rational&,
                           const Rational&);
 friend Rational operator-(const Rational&,
                           const Rational&);
 friend Rational operator*(const Rational&,
                           const Rational&);
 friend Rational operator/(const Rational&,
                           const Rational&);
 friend std::ostream&
 operator<<(std::ostream&, const Rational&);
 friend std::istream&
 operator>>(std::istream&, Rational&);
 Rational& operator+=(const Rational&);
 Rational& operator-=(const Rational&);
 Rational& operator*=(const Rational&);
 Rational& operator/=(const Rational&);
 friend bool operator<(const Rational&,
                       const Rational&);
 friend bool operator>(const Rational&,
                       const Rational&);
 friend bool operator<=(const Rational&,
                        const Rational&);
 friend bool operator>=(const Rational&,
                        const Rational&);
 friend bool operator==(const Rational&,
                        const Rational&);
 friend bool operator!=(const Rational&,
                        const Rational&);
};
#endif // RATIONAL_H ///:~
 

Scrivere una specifica completa per questa classe, incluso precondizioni, postcondizioni ed eccezioni.

3. Scrivere un test usando TestSuite che testa tutte le specifiche del precedente esercizio, incluso le eccezioni.

4. Implementare la classe Rational in modo che tutti i test del esercizio precedente passino. Usare le asserzioni solo per gli invarianti.

5. Il file BuggedSearch.cpp continene una funzione di ricerca binaria che cerca l'intervallo [beg, end[ per cosa. Ci sono dei bug nell'algoritmo. Utilizzare le tecniche di trace di questo capitolo per debuggare la funzione search.

//: C02:BuggedSearch.cpp {-xo}
//{L} ../TestSuite/Test
#include <cstdlib>
#include <ctime>
#include <cassert>
#include <fstream>
#include "../TestSuite/Test.h"
using namespace std;
 
// This function is only one with bugs
int* binarySearch(int* beg, int* end, int what) {
 while(end - beg != 1) {
   if(*beg == what) return beg;
   int mid = (end - beg) / 2;
   if(what <= beg[mid]) end = beg + mid;
   else beg = beg + mid;
 }
 return 0;
}
 
class BinarySearchTest : public TestSuite::Test {
 enum { SZ = 10 };
 int* data;
 int max; // Track largest number
 int current; // Current non-contained number
              // Used in notContained()
 // Find the next number not contained in the array
 int notContained() {
   while(data[current] + 1 == data[current + 1])
     ++current;
   if(current >= SZ) return max + 1;
   int retValue = data[current++] + 1;
   return retValue;
 }
 void setData() {
   data = new int[SZ];
   assert(!max);
   // Input values with increments of one.  Leave
   // out some values on both odd and even indexes.
   for(int i = 0; i < SZ;
       rand() % 2 == 0 ? max += 1 : max += 2)
     data[i++] = max;
 }
 void testInBound() {
   // Test locations both odd and even
   // not contained and contained
   for(int i = SZ; --i >=0;)
     test_(binarySearch(data, data + SZ, data[i]));
   for(int i = notContained(); i < max;
       i = notContained())
     test_(!binarySearch(data, data + SZ, i));
 }
 void testOutBounds() {
   // Test lower values
   for(int i = data[0]; --i > data[0] - 100;)
     test_(!binarySearch(data, data + SZ, i));
   // Test higher values
   for(int i = data[SZ - 1];
       ++i < data[SZ -1] + 100;)
     test_(!binarySearch(data, data + SZ, i));
 }
public:
 BinarySearchTest() { max = current = 0; }
 void run() {
   setData();
   testInBound();
   testOutBounds();
   delete [] data;
 }
};
 
int main() {
 srand(time(0));
 BinarySearchTest t;
 t.run();
 return t.report();
} ///:~