Capitolo 1 - Gestione delle eccezioni

Migliorare il recupero da una situazione di errore è uno dei modi più potenti in cui si può incrementare la robustezza del codice. Sfortunatamente, è pratica quasi comune ignorare le condizione di errore, come se fossimo in un stato di negazione degli errori. Una ragione, senza dubbio, è la tediosità di controllare molti errori. Per esempio, printf() restituisce il numero di caratteri che sono stati stampati con successo, ma virtualmente nessuno controlla questo valore. La proliferazione di codice solo sarebbe disgustoso, senza contare la difficoltà di lettura del codice.

Il problema dell'approccio del C con la gestione degli errori potrebbe essere pensato come accoppiamento, l'utente di una funzione deve mantenere il codice per la gestione dell'errore così vicino a quella funzione che essa diventa scomoda da usare.

Una delle caratteristiche più importanti del C++ è la gestione delle eccezioni, che è un modo migliore di preoccuparsi e di gestire gli errori. Con la gestione delle eccezioni si può:

  1. Il codice per la gestione degli errori non è poi così tedioso da scrivere, non si mischia al codice "normale". Si scrive il codice normalmente e poi in una sezione separata si scrive il codice che gestisce i problemi. Se si fanno chiamate multiple ad un funzione, si gestiscono gli errori da quella funzione una sola volta in un solo posto.
  2. Gli errori non possono essere ignorati. Se una funzione necessita di inviare un messaggio di errore al chiamante di quella funzione, essa "lancia"(throw) un oggetto che rappresenta quell'errore fuori dalla funzione. Se il chiamante non "afferra" (catch) l'errore e lo gestisce, esso va nel prossimo scope dinamico di enclosing, e così via fino a che l'errore o viene catchato o il programma termina perché non c'è nessuna gestione per quel tipo di errore.

Questo capitolo esamina l'approccio del C alla gestione degli errori (così com'è), discute sul perché non funziona bene con per il C, e spiega perché non può funzionare per niente con il C++. Questo capitolo affronta anche try,throw e catch, le parole chiave del C++ che supportano la gestione delle eccezioni.

Gestione degli errori tradizionale

Nella maggior parte degli esempi di questo volume, useremo assert() così come si conosceva: per debuggare durante lo sviluppo con codice che può essere disabilitato con #define NDEBUG per la consegna del prodotto. Il controllo di errori a runtime usa le funzione di require.h (assure() e require() sviluppate nel capitolo 9 del primo volume e riportate in questo libro nell'appendice B). Queste funzioni sono un modo comodo di dire "C'è un problema qui probabilmente vorrai gestirlo con del codice più complicato, ma non c'è bisogno di farsi distrarre da esso in quest'esempio." Le funzioni di require.h potrebbero bastare per piccoli programmi, ma per programmi complessi si vorrà scrivere codice più sofisticato.

La gestione degli errori è abbastanza semplice quando si sa esattamente cosa fare, perchè si hanno tutte le informazioni necessarie in quel contesto. Si gestisce l'errore in quel punto.

C'è un problema quando non si hanno abbastanza informazioni in quel contesto e necessita di passare informazioni di errore in diverso contesto dove quella informazioni esiste. In C si può gestire questa situazione usando tre approcci:

Restituire un informazione di errore dalla funzione oppure, se il valore di ritorno non può essere usato in questo modo, settare un flag di condizione di errore globale(il C Standard fornisce errno e perror( ) ). Come menzionato prima, il programmatore tende a ignorare l'informazione di errore perchè è noiosa e tralascia il controllo dell'errore ad ogni chiamata di funzione. In aggiunta, ritornare da una funzione che provoca un condizione di eccezione potrebbe non avere senso.

  1. Usare la poco conosciuta libreria del C Standard di signal-handling, implementata con la funzione signal( ) ( per determinare cosa succede quando l'evento accade) e raise( )(per generare l'evento). Ancora, quest'approccio implica un alto accoppiamento perchè richiede al'utente di qualsiasi libreria di generare segnali per capire e installare l'appropriato meccanismo di signal-handling. In progetti grossi questi signal provenienti da più librerie possono andare in collisione.
  2. Usare le funzioni goto non locali del C Standard: setjmp( ) e longjmp( ).
    Con setjmp( ) si salva uno stato buono conosciuto del programma e se ci son problemi longjmp( ) ripristinerà quello stato. Di nuovo, c'è un grosso accoppiamento tra il posto dove lo stato è memorizzato ed il posto dove accade l'errore.
  3. Quando si considerano schemi di gestione degli errori con il C++, c'è un problema critico addizionale: le tecniche di segnalazione C e setjmp( )/longjmp( ) non chiamano i distruttori, quindi gli oggetti non vengono distrutti correttamente( infatti se longjmp( ) salta il contesto dove i distruttori dovrebbero essere chiamati, il comportamento del programma è indefinito). Questo fa sì che sia virtualmente impossibile recuperare una situazione di eccezione perchè gli oggetti non verranno rimossi e non potranno più essere utilizzati. L'esempio seguente è una dimostrazione di setjmp/longjmp:
//: C01:Nonlocal.cpp
// setjmp() & longjmp().
#include <iostream>
#include <csetjmp>
using namespace std;
 
class Rainbow {
public:
 Rainbow() { cout << "Rainbow()" << endl; }
 ~Rainbow() { cout << "~Rainbow()" << endl; }
};
 
jmp_buf kansas;
 
void oz() {
 Rainbow rb;
 for(int i = 0; i < 3; i++)
   cout << "there's no place like home" << endl;
 longjmp(kansas, 47);
}
 
int main() {
 if(setjmp(kansas) == 0) {
   cout << "tornado, witch, munchkins..." << endl;
   oz();
 } else {
   cout << "Auntie Em! "
        << "I had the strangest dream..."
        << endl;
 }
} ///:~
 
 

Se si chiama setjmp( ) direttamente, essa memorizza tutte le informazioni rilevanti sullo stato corrente del processore(il contenuto del program counter e il puntatore dello stack) nel jmp_buf e ritorna zero. In questo caso si comporta come una funzione normale. Tuttavia, se si chiama longjmp( ) usando lo stesso jmp_buf,è come se si ritornasse ancora da longjmp( ), quindi si può capire se si sta ritornando veramente da un longjmp( ). Si può immaginare che con molti jmp_buf , si può ritornare in diversi punti del programma. La differenza tra un goto locale (con una label) e questo goto non locale è che si può ritornare in qualsiasi locazione predeterminata nello stack con setjmp( )/longjmp( )

Nel C++ longjmp( ) non rispetta gli oggetti ; in particolare non chiama i distruttori quando salta uno scope[1].I distruttori sono essenziali, quindi quest'approccio non funziona con il C++. Infatti, il C++ Standard stabilisce che saltare in un scope con goto o uscire da uno scope con longjmp( ) quando un oggetto nello stack ha un distruttore, costituisce un comportamento indefinito.

Indice

[nascondi]

Lanciare un'eccezione

Se si incontra una situazione eccezionale nel codice -- cioè se non si hanno abbastanza informazioni nel contesto corrente per decidere che cosa fare -- si possono trasmettere informazioni circa l'errore ad un contesto più grande con un oggetto che contiene quell'informazione, "lanciandolo" dal corrente contesto. Tutto questo viene denominato con lanciare ("throw") un'eccezione. La cosa assomiglia a:

//: C01:MyError.cpp {RunByHand}
 
class MyError {
 const char* const data;
public:
 MyError(const char* const msg = 0) : data(msg) {}
};
 
void f() {
  // Here we "throw" an exception object:
 throw MyError("something bad happened");
}
 
int main() {
 // As you'll see shortly, we'll want a "try block"   here:
 f();
} ///:~
 
 

MyError è una classe ordinaria, che in questo caso prende un char* come argomento di un costruttore. Puoi usare qualsiasi tipo quando lanci un'eccezione (inclusi tipi built-in), ma generalmente creerai classi speciali per lanciare le eccezioni.

La parola chiave "throw" fa accadere una serie di cose magiche. Primo, crea una copia dell'oggetto che stai lanciando e, in effetti, la rimanda dalla funzione che contiene l'epressione "throw", anche se questo oggetto non è quello che normalmente la funzione ritorna. Un modo semplice di pensare alla gestione di un'eccezione è vederla come un meccanismo alternativo di ritornare un valore (anche se vederla sempre in questo modo può portare a dei problemi). E' anche possibile uscire dalle ordinarie aree di scope tramite un'eccezione. In ogni caso, un valore è restituito, e si esce dalla funzione o area di scope.

Ogni similitudine con lo stato di ritorno di una funzione finisce qui, perchè il codice ritorna in qualche punto che è completamente differente da dove una normale funzione ritornerebbe (finisci in una perte appropriata di codice - chiamata "gestore degli eventi" - che può essere differente da dove l'eccezione è stata lanciata). In oltre, ogni oggetto creato localmente durante l'eccezione viene distrutto. Questa pulizia degli oggetti locali è spesso chiamata ?stack unwinding?.

In oltre, si possono lanciare tutti i differenti tipi di oggetti che si vuole. Generalmente, si lancia un tipo differente per ogni categoria di errore. L'idea è di immagazzinare le informazioni nell'oggetto e nel nome della sua classe, così che nel contesto della chiamata si possa capire cosa fare con l'eccezione.

Catching an exception (Catturare un'eccezione)

Come menzionato precedentemente, uno dei vantaggi della gestione delle eccezioni in C++ è che ti puoi concentrare sulla soluzione del problema in un posto e gestire gli errori in un'altro.

Il blocco "try"

Se sei all'interno di una funzione e lanci un'eccezione (oppure una funzione chiamata lancia un'eccezione), la funzione esce a causa dell'eccezione lanciata. Se non vuoi che generando l'eccezione si lasci la funzione, puoi creare un blocco di codice dedicato e al suo interno provare a gestirla (e potenzialmente generarne un'altra). Questo blocco di codice è chiamato il "try block" perchè è dove si prova a gestire le eccezioni. Il "try block" è un'area di scope ordinaria, preceduta dalla parola chiave "try":

try {
  // Codice che potrebbe generare un'eccezione
}

Se fai il controllo degli errori esaminando i codici ritornati dalle funzioni che usi, hai bisogno di circondare ogni chiamata a funzione con codice di setup e test, anche se chiami la stessa funzione più volte. Con la gestione delle eccezioni, invece, metti tutto in un blocco try e gestisci le eccezioni dopo il blocco try. Perciò, il codice è più semplice da scrivere e leggere perchè lo scopo del codice non è confuso con la gestione degli errori.

Gestore delle eccezioni

Certamente, il lancio delle eccezioni deve terminare da qualche parte. Questo posto è il gestore delle eccezioni, e ne serve uno per ogni tipo di eccezione che si vuole catturare. Comunque, il polimorfismo lavora anche per le eccezioni, così, un gestore delle eccezioni può lavorare con un tipo di eccezione e classi derivate da quel tipo.

I gestori delle eccezioni sono posizionati subito dopo il blocco try e sono caratterizzati dalla parola chiave catch:

try {
  // Codice che pu generare un'eccezione
} catch(type1 id1) {
  // Gestore delle eccezioni di tipo1
} catch(type2 id2) {
  // Gestore delle eccezioni di tipo2
} catch(type3 id3)
  // Etc...
} catch(typeN idN)
  // Gestore delle eccezioni di tipoN
}
// L'esecuzione normale riprende qui...
 

La sintassi di una clausola catch ricorda quella di una funzione che prende un singolo parametro. L'identificatore (id1, id2, ecc...) può essere usato all'interno del gestore, come l'argomento di una funzione, altrimenti può essere omesso se non è usato nel gestore. Il tipo dell'eccezione generalemente da abbastanza informazioni sulla sua gestione.

Il gestore deve trovarsi subito dopo il blocco try. Se viene lanciata un'eccezione, il meccanismo di gestione delle eccezioni cerca il primo gestore con un'argomento che corrisponde al tipo dell'eccezione. Quindi, entra nella clausola catch, e l'eccezione è considerata gestita (la ricerca del gestore si ferma quando la clausola catch corretta viene trovata). Solo la clausola catch corrispondente viene eseguita; quindi, l'esecuzione riprende dopo l'ultimo gestore associato con il blocco try.

Nota che, all'interno del blocco try, diverse chiamate a funzioni possono generare lo stesso tipo di eccezione, ma è necessario solo un gestore.

Per illustare il blocco try e catch, la seguente variazione di Nonlocal.cpp sostituisce le chiamate a setjmp( ) con un blocco try a le chiamate a longjmp( ) con una dichiarazione throw:

//: C01:Nonlocal2.cpp
// Illustrates exceptions.
#include <iostream>
using namespace std;
 
class Rainbow {
public:
  Rainbow() { cout << "Rainbow()" << endl; }
  ~Rainbow() { cout << "~Rainbow()" << endl; }
};
 
void oz() {
 Rainbow rb;
 for(int i = 0; i < 3; i++)
   cout << "there's no place like home" << endl;
 throw 47;
}
 
int main() {
  try {
    cout << "tornado, witch, munchkins..." << endl;
    oz();
  } catch(int) {
    cout << "Auntie Em! I had the strangest dream..." 
         << endl;
  }
} ///:~
 

Quando viene eseguita la dichiarazione throw in oz(), il controllo del programma va indietro finchè non trova la clausola catch che prende come parametro un intero. L'esecuzione riprende nel corpo di quel blocco catch. La diferenza più importante tra questo programma e Nonlocal.cpp è che il distruttore dell'oggetto rb viene chiamato quando la dichiarazione throw fa si che l'esecuzione lasci la funzione oz().

Terminazione e ripresa

Ci sono due teorie per la gestione delle eccezioni: la terminazione e la ripresa. Nella terminazione (che è quello che il C++ supporta), si assume che l'errore è talmente critico che non c'è modo di riprendere automaticamente l'esecuzione normale dal punto in cui è avvenuta l'eccezione. In altre parole, chiunque abbia lanciato l'eccezione ha deciso che non c'era modo di salvare la situazione, e non si vuole tornare indietro.

Il modo alternativo per gestire gli errori è chiamato ripresa, inizialmente introdotto con il linguaggio PL/I nel 1960 [2]. Usando la semantica della ripresa ci si aspetta che il gestore delle eccezioni faccia qualcosa per rettificare la situazione, e quindi il codice che ha generato l'errore viene rieseguito, presumendo che funzioni la seconda volta. Se si vuole usare questo sistema in C++, si deve esplicitamente ritrasferire l'esecuzione al codice che ha generato l'errore, di solito ripetendo la chiamata a funzione che lo ha generato la prima volta. Non è inusuale mettere il blocco try all'interno di un ciclo while che rientra nel blocco try finchè il risultato non è soddisfacente.

Storicamente, i programmatori che usavano i sistemi operativi che supportavano la gestione delle eccezioni tramite la ripresa finirono con l'usare il metodo della terminazione evitando quello della ripresa. Anche se la ripresa all'inizio suona come attrattiva, sembra non essere utile in pratica. Una delle ragioni potrebbe essere la distanza tra l'eccezione e il suo gestore. Una cosa è terminare a un gestore che è lontano, ma saltare al gestore e poi tornare indietro (al punto che ha generato l'eccezione) potrebbe essere concettualmente troppo difficile in grandi sistemi dove l'eccezione è generata da molti punti.