Le eccezioni
Durante l'esecuzione di un applicativo possono verificarsi
delle situazioni di errore non verificabili a compile-time, che in qualche
modo vanno gestiti.
Le possibili tipologie di errori sono diverse ed in generale non tutte
trattabili allo stesso modo. In particolare possiamo distinguere tra
errori che non compromettono il funzionamento del programma ed errori che
invece costituiscono una grave impedimento al normale svolgimento delle
operazioni.
Tipico della prima categoria sono ad esempio gli errori dovuti a errato
input dell'utente, facili da gestire senza grossi problemi. Meno facili
da catturare e gestire e` invece la seconda categoria cui possiamo
inserire ad esempio i fallimenti relativi all'acquisizione di risorse come
la memoria dinamica; questo genere di errori viene solitamente indicato
con il termine di eccezioni per sottolineare la loro caratteristica
di essere eventi particolarmente rari e di comportare il fallimento di
tutta una sequenza di operazioni.
La principale difficolta` connessa al trattamento delle eccezioni e` quella
di riportare lo stato dell'applicazione ad un valore consistente. Il verificarsi di un tale vento comporta infatti (in linea di principio) l'interruzione di tutta una sequenza di operazioni rivolta ad assolvere
ad una certa funzionalita`, allo svuotamento dello stack ed alla
deallocazione di eventuali risorse allocate fino a quel punto relativamente
alla richiesta in esecuzione. Le informazioni necessarie allo svolgimento
di queste operazioni sono in generale dipendenti anche dal momento e dal
punto in cui si verifica l'eccezione e non e` quindi immaginabile (o comunque
facile) che la gestione dell'errore possa essere incapsulata in un unico
blocco di codice richiamabile indipendentemente dal contesto in cui si
verifica il problema.
In linguaggi che non offrono alcun supporto, catturare e gestire questi
errori puo` essere particolarmente costoso e difficile, al punto che
spesso si rinuncia lasciando sostanzialmente al caso le conseguenze.
Il C++ comunque non rientra tra questi linguaggi e offre alcuni strumenti
che saranno oggetto dei successivi paragrafi di questo capitolo.
Segnalare le eccezioni
Il primo problema che bisogna affrontare quando si verifica un errore e` capire dove e quando bisogna gestire l'anomalia.
Poniamo il caso che si stia sviluppando una serie di funzioni matematiche,
in particolare una che esegue la radice quadrata. Come comportarsi se
l'argomento della funzione e` un numero negativo? Le possibilita` sono due:
- Terminare il processo;
- Segnalare l'errore al chiamante.
Probabilmente la prima possibilita` e` eccessivamente drastica, tanto piu`
che non sappiamo a priori se e` il caso di terminare oppure se il
chiamante possa prevedere azioni alternative da compiere in caso di errore
(ad esempio ignorare l'operazione e passare alla successiva).
D'altronde neanche la seconda possibilita` sarebbe di per se` una buona
soluzione, cosa succede se il chiamante ignora l'errore proseguendo come
se nulla fosse?
E` chiaramente necessario un meccanismo che garantisca che nel caso il
chiamante non catturi l'anomalia qualcuno intervenga in qualche modo.
Ma andiamo con ordine, e supponiamo che il chiamante preveda del
codice per gestire l'anomalia.
Se al verificarsi di un errore grave non si dispone delle informazioni
necessarie per decidere cosa fare, la cosa migliore da farsi e` segnalare
la condizione di errore a colui che ha invocato l'operazione. Questo
obiettivo viene raggiunto con la keyword throw:
|
int Divide(int a, int b) {
if (b) return a/b;
throw "Divisione per zero";
}
|
L'esecuzione di throw provoca l'uscita dal blocco in cui essa si
trova (si noti che in questo caso la funzione non e` obbligata a restituire
alcun valore tramite return) e in queste situazioni si dice che
la funzione ha sollevato (o lanciato) una eccezione.
La throw accetta un argomento come parametro che viene utilizzato per
creare un oggetto che non ubbidisce alle normali regole di scope e che viene
restituito a chi ha tentato l'esecuzione dell'operazione (nel nostro caso
al blocco in cui Divide e` stata chiamata). Il compito
di questo oggetto e` trasportare tutte le informazioni utili sull'evento.
L'argomento di throw puo` essere sia un tipo predefinito che
un tipo definito dal programmatore.
Per compatibilita` con il vecchio codice, una funzione non e` tenuta a
segnalare la possibilita` che possa lanciare una eccezione, ma e`
buona norma avvisare dell'eventualita` segnalando quali tipologie di
eccezioni sono possibili. Allo scopo si usa ancora throw
seguita da una coppia di parentesi tonde contenente la lista dei tipi
di eccezione che possono essere sollevate:
|
int Divide(int a, int b) throw(char*) {
if (b) return a/b;
throw "Errore";
}
void MoreExceptionTypes() throw(int, float, MyClass&) {
/* ... */
}
|
Nel caso della Divide si segnala la possibilita` che venga
sollevata una eccezione di tipo char*; nel caso della seconda
funzione invece a seconda dei casi puo` essere lanciata una eccezione
di tipo int, oppure di tipo float, oppure ancora una
di tipo MyClass& (supponendo che MyClass
sia un tipo precedentemente definito).
Gestire le eccezioni
Quanto abbiamo visto chiaramente non e`
sufficiente, non basta poter sollevare (segnalare) una eccezione ma e`
necessario poterla anche catturare e gestire.
L'intenzione di catturare e gestire l'eventuale eccezione deve essere
segnalata al compilatore utilizzando un blocco try:
|
#include < iostream >
using namespace std;
int Divide(int a, int b) throw(char*) {
if (b) return a/b;
throw "Errore";
}
int main() {
cout << "Immettere il dividendo: ";
int a;
cin >> a;
cout << endl << "Immettere il divisore: ";
int b;
cin >> b;
try {
cout << Divide(a, b);
}
/* ... */
}
|
Utilizzando try e racchidendo tra parentesi graffe (le parentesi si
devono utilizzate sempre) il codice che puo` generare una eccezione si
segnala al compilatore che siamo pronti a gestire l'eventuale eccezione.
Ci si potra` chiedere per quale motivo sia necessario informare il compilatore dell'intenzione di catturare e gestire l'eccezione, il motivo
sara` chiaro in seguito, al momento e` sufficiente sapere che cio` ha il
compito di indicare quando certi automatismi dovranno arrestarsi e
lasciare il controllo a codice ad hoc preposto alle azioni del caso.
Il codice in questione dovra` essere racchiuso all'interno di un
blocco catch che deve seguire il blocco try:
|
#include < iostream >
using namespace std;
int Divide(int a, int b) throw(char*) {
if (b) return a/b;
throw "Errore, divisione per 0";
}
int main() {
cout << "Immettere il dividendo: ";
int a;
cin >> a;
cout << endl << "Immettere il divisore: ";
int b;
cin >> b;
cout << endl;
try {
cout << "Il risultato e` " << Divide(a, b);
}
catch(char* String) {
cout << String << endl;
return -1;
}
return 0;
}
|
Il generico blocco catch potra` gestire in generale solo una
categoria di eccezioni o una eccezione generica. Per fornire codice
diverso per diverse tipologie di errori bisognera` utilizzare piu`
blocchi catch:
|
try {
/* ... */
}
catch(Type1 Id1) {
/* ... */
}
catch(Type2 Id2) {
/* ... */
}
/* Altre catch */
catch(TypeN IdN) {
/* ... */
}
/* Altro */
|
Ciascuna catch e` detta exception handler e riceve un parametro che e` il tipo di eccezione che viene gestito in quel blocco.
Nel caso generale un blocco try sara` seguito da
piu` blocchi catch, uno per ogni tipo di eccezione possibile
all'interno di quel try. Si noti che le catch devono
seguire immediatamente il blocco try.
Quando viene generata una eccezione (throw) il controllo risale
indietro fino al primo blocco try. Gli oggetti staticamente
allocati (che cioe` sono memorizzati sullo stack) fino a quel momento nei blocchi da cui si esce vengono distrutti invocando il loro distruttore
(se esiste).
Nel momento in cui si giunge ad un blocco try anche gli oggetti
staticamente allocati fino a quel momento dentro il blocco try
vengono distrutti ed il controllo passa immediatamente dopo la fine
del blocco.
Il tipo dell'oggetto creato con throw viene quindi confrontato con i
parametri delle catch che seguono la try.
Se viene trovata una catch del tipo corretto, si passa ad
eseguire le istruzioni contenute in quel blocco, dopo aver
inizializzato il parametro della catch con l'oggetto restituito con
throw.
Nel momento in cui si entra in un blocco catch, l'eccezione viene
considerata gestita ed alla fine del blocco catch il controllo
passa alla prima istruzione che segue la lista di catch (sopra
indicato con "/* Altro */").
Vediamo un esempio:
|
#include < iostream >
#include < string.h >
using namespace std;
class Test {
char Name[20];
public:
Test(char* name){
Name[0] = '\0';
strcpy(Name, name);
cout << "Test constructor inside "
<< Name << endl;
}
~Test() {
cout << "Test distructor inside "
<< Name << endl;
}
};
int Sub(int b) throw(int) {
cout << "Sub called" << endl;
Test k("Sub");
Test* K2 = new Test("Sub2");
if (b > 2) return b-2;
cout << "exception inside Sub..." << endl;
throw 1;
}
int Div(int a, int b) throw(int) {
cout << "Div called" << endl;
Test h("Div");
b = Sub(b);
Test h2("Div 2");
if (b) return a/b;
cout << "exception inside Div..." << endl;
throw 0;
}
int main() {
try {
Test g("try");
int c = Div(10, 2);
cout << "c = " << c << endl;
} // Il controllo ritorna qua
catch(int exc) {
cout << "exception catched" << endl;
cout << "exception value is " << exc << endl;
}
return 0;
}
|
La chiamata a Div all'interno della main
provoca una eccezione nella Sub, viene quindi distrutto
l'oggetto k ed il puntatore k2, ma non
l'oggetto puntato (allocato dinamicamente). La deallocazione di oggetti
allocati nello heap e` a carico del programmatore.
In seguito alla eccezione, il controllo risale a Div,
ma la chiamata a Sub non era racchiusa dentro un blocco
try e quindi anche Div viene terminata distruggendo
l'oggetto h. L'oggetto h2 non e` stato
ancora creato e quindi nessun distruttore per esso viene invocato.
Il controllo e` ora giunto al blocco che ha chiamato la Div,
essendo questo un blocco try, vengono distrutti gli oggetti
g e c ed il controllo passa nel punto in
cui si trova il commento.
A questo punto viene eseguita la catch poiche` il tipo dell'eccezione
e` lo stesso del suo argomento e quindi il controllo passa alla
return della main.
Ecco l'output del programma:
|
Test constructor inside try
Div called
Test constructor inside Div
Sub called
Test constructor inside Sub
Test constructor inside Sub 2
exception inside Sub...
Test distructor inside Sub
Test distructor inside Div
Test distructor inside try
exception catched
exception value is 0
|
Si provi a tracciare l'esecuzione del programma e a ricostruirne la
storia, il meccanismo diverra` abbastanza chiaro.
Il compito delle istruzioni contenute nel blocco catch costituiscono
quella parte di azioni di recupero che il programma deve svolgere in
caso di errore, cosa esattamente mettere in questo blocco e` ovviamente
legato alla natura del programma e a cio` che si desidera fare; ad esempio
ci potrebbero essere le operazioni per eseguire dell'output su un file di
log. E` buona norma studiare gli exception handler in modo che al loro
interno non possano verificarsi eccezioni.
Nei casi in cui non interessa distinguere tra piu` tipologie di eccezioni,
e` possibile utilizzare un unico blocco catch utilizzando le
ellissi:
|
try {
/* ... */
}
catch(...) {
/* ... */
}
|
In altri casi invece potrebbe essere necessari passare l'eccezione ad un
blocco try ancora piu` esterno, ad esempio perche` a quel livello e`
sufficiente (o possibile) fare solo certe operazioni, in questo caso basta
utilizzare throw all'interno del blocco catch per reinnescare
il meccanismo delle eccezioni a partire da quel punto:
|
try {
/* ... */
}
catch(Type Id) {
/* ... */
throw; // Bisogna scrivere solo throw
}
|
In questo modo si puo` portare a conoscenza dei blocchi piu` esterni
della condizione di errore.
Casi particolari
Esistono ancora due problemi da affrontare
- Cosa fare se una funzione solleva una eccezione non specificata tra
quelle possibili;
- Cosa fare se non si trova un blocco try seguito da una
catch compatibile con quel tipo di eccezione;
Esaminiamo il primo punto.
Per default una funzione che non specifica una lista di possibili tipi
di eccezione puo` sollevare una eccezione di qualsiasi tipo. Una funzione
che specifica una lista dei possibili tipi di eccezione e` teoricamente
tenuta a rispettare tale lista, ma nel caso non lo facesse, in seguito ad
una throw di tipo non previsto, verrebbe eseguita immediatamente
la funzione predefinita unexpected(). Per default
unexpected() chiama terminate() provocando
la terminazione del programma. Tuttavia e` possibile alterare tale
comportamento definendo una funzione che non riceve alcun parametro
e restituisce void ed utilizzando set_unexpected()
come mostrato nel seguente esempio:
|
#include < exception >
using namespace std;
void MyUnexpected() {
/* ... */
}
typedef void (* OldUnexpectedPtr) ();
int main() {
OldUnexpectedPtr = set_unexpected(MyUnexpected);
/* ... */
return 0;
}
|
unexpected() e set_unexpected() sono
dichiarate nell'header < exception >.
E` importante ricordare che la vostra unexpected non deve
ritornare, in altre parole deve terminare l'esecuzione del programma:
|
#include < exception >
#include < stdlib.h >
using namespace std;
void MyUnexpected() {
/* ... */
abort(); // Termina il programma
}
typedef void (* OldHandlerPtr) ();
int main() {
OldhandlerPtr = set_unexpected(MyUnexpected);
/* ... */
return 0;
}
|
Il modo in cui terminate l'esecuzione non e` importante, quello che conta
e` che la funzione non ritorni.
set_unexpected() infine restituisce l'indirizzo della
unexpected precedentemente installata e che in talune
occasioni potrebbe servire.
Rimane da trattare il caso in cui in seguito ad una eccezione, risalendo
i blocchi applicativi, non si riesca a trovare un blocco try oppure
una catch compatibile con il tipo di eccezione sollevata.
Nel caso si trovi un blocco try ma nessuna catch idonea,
il processo viene iterato fino a quando una catch adatta viene
trovata, oppure non si riesce a trovare alcun altro blocco try.
Se nessun blocco try viene trovato, viene chiamata la
funzione terminate().
Anche in questo caso, come per unexpected(),
terminate() e` implementata tramite puntatore ed e` possibile
alterarne il funzionamento utilizzando set_terminate() in modo
analogo a quanto visto per unexpected() e
set_unexpected() (ovviamente la nuova terminate
non deve ritornare).
set_terminate() restituisce l'indirizzo della
terminate() precedentemente installata.
Eccezioni e costruttori
Il meccanismo di stack unwinding (srotolamento dello stack) che si innesca quando viene sollevata una
eccezione garantisce che gli oggetti allocati sullo stack vengano
distrutti via via che il controllo esce dai vari blocchi applicativi.
Ma cosa succede se l'eccezione viene sollevata nel corso dell'esecuzione
di un costruttore? In tal caso l'oggetto non puo` essere considerato completamente costruito ed il compilatore non esegue la chiamata al
suo distruttore, viene comunque eseguita la chiamata dei distruttori per le
componenti dell'oggetto che sono state create:
|
#include < iostream >
using namespace std;
class Component {
public:
Component() {
cout << "Component constructor called..." << endl;
}
~Component() {
cout << "Component distructor called..." << endl;
}
};
class Composed {
private:
Component A;
public:
Composed() {
cout << "Composed constructor called..." << endl;
cout << "Throwing an exception..." << endl;
throw 10;
}
~Composed() {
cout << "Composed distructor called..." << endl;
}
};
int main() {
try {
Composed B;
}
catch (int) {
cout << "Exception handled!" << endl;
};
return 0;
}
|
Dall'output di questo programma:
|
Component constructor called...
Composed constructor called...
Throwing an exception...
Component distructor called...
Exception handled!
|
e` possibile osservare che il distruttore per l'oggetto B
istanza di Composed non viene eseguito perche` solo
al termine del costruttore tale oggetto puo` essere considerato totalmente
realizzato.
Le conseguenze di questo comportamento possono passare inosservate, ma e`
importante tenere presente che eventuali risorse allocate nel corpo del
costruttore non possono essere deallocate dal distruttore. Bisogna
realizzare con cura il costruttore assicurandosi che risorse allocate
prima dell'eccezione vengano opportunamente deallocate:
|
#include < iostream >
using namespace std;
int Divide(int a, int b) throw(int) {
if (b) return a/b;
cout << endl;
cout << "Divide: throwing an exception..." << endl;
cout << endl;
throw 10;
}
class Component {
public:
Component() {
cout << "Component constructor called..." << endl;
}
~Component() {
cout << "Component distructor called..." << endl;
}
};
class Composed {
private:
Component A;
float* FloatArray;
int AnInt;
public:
Composed() {
cout << "Composed constructor called..." << endl;
FloatArray = new float[10];
try {
AnInt = Divide(10,0);
}
catch(int) {
cout << "Exception in Composed constructor...";
cout << endl << "Cleaning up..." << endl;
delete[] FloatArray;
cout << "Rethrowing exception..." << endl;
cout << endl;
throw;
}
}
~Composed() {
cout << "Composed distructor called..." << endl;
delete[] FloatArray;
}
};
int main() {
try {
Composed B;
}
catch (int) {
cout << "main: exception handled!" << endl;
};
return 0;
}
|
All'interno del costruttore di Composed viene sollevata una
eccezione. Quando questo evento si verifica, il costruttore ha gia` allocato
delle risorse (nel nostro caso della memoria); poiche` il distruttore non
verrebbe eseguito e` necessario provvedere alla deallocazione di tale
risorsa. Per raggiungere tale scopo, le operazioni soggette a potenziale
fallimento vengono racchiuse in una try seguita dall'opportuna
catch. Nel exception handler tale risorsa viene deallocata e
l'eccezione viene nuovamente propagata per consentire alla main
di intraprendere ulteriori azioni.
Ecco l'output del programma:
|
Component constructor called...
Composed constructor called...
Divide: throwing an exception...
Exception in Composed constructor...
Cleaning up...
Rethrowing exception...
Component distructor called...
main: exception handled!
|
Si noti che se la catch del costruttore della classe
Composed non avesse rilanciato l'eccezione, il compilatore considerando gestita l'eccezione, avrebbe terminato l'esecuzione del
costruttore considerando B completamente costruito.
Cio` avrebbe comportato la chiamata del distruttore al termine dell'esecuzione della main con il conseguente errore
dovuto al tentativo di rilasciare nuovamente la memoria allocata per
FloatArray.
Per verificare cio` si modifichi il programma nel seguente modo:
|
#include < iostream >
using namespace std;
int Divide(int a, int b) throw(int) {
if (b) return a/b;
cout << endl;
cout << "Divide: throwing an exception..." << endl;
cout << endl;
throw 10;
}
class Component {
public:
Component() {
cout << "Component constructor called..." << endl;
}
~Component() {
cout << "Component distructor called..." << endl;
}
};
class Composed {
private:
Component A;
float* FloatArray;
int AnInt;
public:
Composed() {
cout << "Composed constructor called..." << endl;
FloatArray = new float[10];
try {
AnInt = Divide(10,0);
}
catch(int) {
cout << "Exception in Composed constructor...";
cout << endl << "Cleaning up..." << endl;
delete[] FloatArray;
}
}
~Composed() {
cout << "Composed distructor called..." << endl;
}
};
int main() {
try {
Composed B;
cout << endl << "main: no exception here!" << endl;
}
catch (int) {
cout << endl << "main: Exception handled!" << endl;
};
}
|
eseguendolo otterrete il seguente output:
|
Component constructor called...
Composed constructor called...
Divide: throwing an exception...
Exception in Composed constructor...
Cleaning up...
main: no exception here!
Composed distructor called...
Component distructor called...
|
Come si potra` osservare, il blocco try della main
viene eseguito normalmente e l'oggetto B viene distrutto
non in seguito all'eccezione, ma solo perche` si esce dallo scope
del blocco try cui appartiene.
La realizzazione di un costruttore nella cui esecuzione puo` verificarsi
una eccezione, e` dunque un compito non banale e in generale sono
richieste due operazioni:
- Eseguire eventuali pulizie all'interno del costruttore se non si
e` in grado di terminare correttamente la costruzione dell'oggetto;
- Se il distruttore non termina correttamente (ovvero l'oggetto non viene
totalmente costruito), propagare una eccezione anche al codice che ha
invocato il costruttore e che altrimenti rischierebbe di utilizzare un
oggetto non correttamente creato.
La gerarchia exception
Lo standard prevede tutta una serie di eccezioni, ad esempio l'operatore ::new puo` sollevare una
eccezione di tipo bad_alloc, alcune classi standard (ad esempio
la gerarchia degli iostream) ne prevedono altre.
E` stata prevista anche una serie di classi da utilizzare all'interno dei propri programmi, in particolare in fase di debugging.
Alla base della gerarchia si trova la classe exception da
cui derivano logic_error e runtime_error.
La classe base attraverso il metodo virtuale what() (che
restituisce un char*) e` in grado di fornire una descrizione
dell'evento (cosa esattamente c'e` scritto nell'area puntata dal puntatore
restituito dipende dall'implementazione).
Le differenze tra logic_error e runtime_error
sono sostanzialmente astratte, la prima classe e` stata pensata per segnalare
errori logici rilevabili attraverso le precondizioni o le invarianti, la
classe runtime_error ha invece lo scopo di riportare errori
rilevabili solo a tempo di esecuzione.
Da logic_error e runtime_error derivano poi altre
classi secondo il seguente schema:
|

|
Le varie classi sostanzialmente differiscono solo concettualmente, non
ci sono differenze nel loro codice, in questo caso la derivazione e`
stata utilizzata al solo scopo di sottolineare delle differenze di ruolo
forse non immediate da capire.
Dallo standard:
- logic_error ha lo scopo di segnalare un
errore che presumibilmente potrebbe essere rilevato prima di eseguire
il programma stesso, come la violazione di una precondizione;
- domain_error va lanciata per segnalare
errori relativi alla violazione del dominio;
- invalid_argument va utilizzato per segnalare il
passaggio di un argomento non valido;
- length_error segnala il tentativo di costruire un
oggetto di dimensioni superiori a quelle permesse;
- out_of_range riporta un errore di argomento con valore
non appartenente all'intervallo di definizione;
- runtime_error rappresenta un errore che puo` essere
rilevato solo a runtime;
- range_error segnala un errore di intervallo durante
una computazione interna;
- overflow_error riporta un errore di overflow;
- underflow_error segnala una condizione di underflow;
Eccetto che per la classe base che non e` stata pensata per essere impiegata direttamente, il costruttore di tutte le altre classi riceve come unico
argomento un const string& il tipo string e`
definito nella libreria standard del linguaggio.
Conclusioni
I meccanismi che sono stati descritti nei
paragrafi precedenti costituiscono un potente mezzo per affrontare e
risolvere situazioni altrimenti difficilmente trattabili. Non si tratta
comunque di uno strumento facile da capire e utilizzare ed e` raccomandabile
fare diverse prove ed esercizi per comprendere cio` che sta dietro le quinte.
La principale difficolta` e` quella di riconoscere i contesti in cui bisogna
utilizzare le eccezioni ed ovviamente la strategia da seguire per gestire
tali eventi. Cio` che bisogna tener presente e` che il meccanismo delle
eccezioni e` sostanzialmente non locale poiche` il controllo ritorna
indietro risalendo i vari blocchi applicativi. Cio` significa che bisogna
pensare ad una strategia globale, ma che non tenti di raggruppare tutte le
situazioni in un unico errore generico altrimenti si verrebbe schiacciati
dalla complessita` del compito.
In generale non e` concepibile occuparsi di una possibile eccezione al
livello di ogni singola funzione, a questo livello cio` che e` pensabile
fare e` solo lanciare una eccezione; e` invece bene cercare di rendere i
propri applicativi molto modulari e isolare e risolvere all'interno di
ciascun blocco quante piu` situazioni di errore possibile, lasciando filtrare
una eccezione ai livelli superiori solo se le conseguenze possono avere
ripercussioni a quei livelli.
Ricordate infine di catturare e trattare le eccezioni standard che si celano dietro ai costrutti predefiniti quali l'operatore globale ::new.
Pagina precedente - Pagina successiva
C++, una panoramica sul linguaggio - seconda edizione © Copyright 1996-1999, Paolo Marotta
|