Blocchi try catch e Gestione delle Eccezioni in C++

Concetti Chiave
  • I blocchi try e catch supportano la gestione delle eccezioni in C++.
  • Un'espressione throw solleva un'eccezione.
  • Le clausole catch gestiscono le eccezioni sollevate nei blocchi try.
  • La libreria standard definisce diverse classi di eccezioni per segnalare vari tipi di errori.
  • Le eccezioni possono essere propagate attraverso più livelli di chiamate di funzione.
  • Se nessun gestore di eccezioni viene trovato, il programma termina.

Blocchi try catch e Gestione delle Eccezioni

Le eccezioni sono anomalie in fase di esecuzione che esistono al di fuori del normale funzionamento di un programma. Ad esempio, la perdita di una connessione ad un database o l'incontro di input inaspettati. Gestire un comportamento anomalo può essere una delle parti più difficili della progettazione di qualsiasi sistema.

La gestione delle eccezioni è generalmente usata quando una parte di un programma rileva un problema che non può risolvere e il problema è tale che la parte principale del programma non può continuare, ossia la parte che si occupa della logica principale. In tali casi, questa parte ha bisogno di un modo per segnalare che qualcosa è accaduto e che non può continuare. Inoltre, essa ha bisogno di un modo per segnalare il problema senza sapere quale parte del programma si occuperà della condizione eccezionale. Dopo aver segnalato ciò che è accaduto, la parte principale smette di elaborare.

Un programma che contiene codice che potrebbe sollevare un'eccezione ha (solitamente) un'altra parte per gestire qualunque cosa sia accaduta. Ad esempio, se il problema è un input non valido, la parte di gestione potrebbe chiedere all'utente di fornire un input corretto. Se la connessione con un database è caduta, la parte di gestione potrebbe avvisare un operatore.

La gestione delle eccezioni supporta questa cooperazione tra le parti principali e di gestione di un programma. In C++, la gestione delle eccezioni coinvolge tre gruppi di elementi:

  • espressioni throw, che la parte principale usa per indicare che ha incontrato qualcosa che non può gestire. Diciamo che un throw solleva un'eccezione.
  • blocchi try, che la parte di gestione usa per trattare un'eccezione. Un blocco try inizia con la parola chiave try e termina con una o più clausole catch. Le eccezioni sollevate dal codice eseguito all'interno di un blocco try sono solitamente gestite da una delle clausole catch. Poiché "gestiscono" l'eccezione, le clausole catch sono anche conosciute come gestori di eccezioni.
  • Un insieme di classi di eccezioni che vengono usate per passare informazioni su ciò che è accaduto tra un throw e un catch associato.

Nel resto di questa lezione, introdurremo questi tre componenti della gestione delle eccezioni. Tuttavia, ritorneremo sulle eccezioni in lezioni successive quando tratteremo argomenti più avanzati.

Espressioni throw

La parte principale di un programma usa un'espressione throw per sollevare un'eccezione. Un throw consiste nella parola chiave throw seguita da un'espressione. Il tipo dell'espressione determina quale tipo di eccezione viene sollevata. Un'espressione throw è solitamente seguita da un punto e virgola, rendendola un'istruzione di espressione.

Come semplice esempio, supponiamo di voler realizzare un programma che calcola la media di un vector di numeri in virgola mobile. Se il vector è vuoto, non ha senso calcolare la media. Un primo approccio semplice per la gestione di questa condizione potrebbe essere quella di usare un'istruzione if per discriminare i due casi:

std::vector<double> v = /* ... */;

/* ... */

if (v.empty())
    std::cerr << "Impossibile calcolare la media di un vettore vuoto\n";
else {
    double sum = 0;
    for (auto val : v)
        sum += val;
    std::cout << "La media è " << sum/v.size() << '\n';
}

In un programma più realistico, la parte che effettua il calcolo vero e proprio potrebbe essere separata dalla parte che gestisce l'interazione con un utente. In questo caso, potremmo riscrivere il test per sollevare un'eccezione piuttosto che restituire un indicatore di errore:

// prima controlla che il vettore non sia vuoto
if (v.empty())
    throw runtime_error("Impossibile calcolare la media di un vettore vuoto.");

// se siamo ancora qui, il vettore non è vuoto
double sum = 0;
for (auto val : v)
    sum += val;
double media = sum / v.size();

In questo codice, se il vettore è vuoto, solleviamo un'espressione che è un oggetto di tipo runtime_error. Sollevare un'eccezione termina la funzione corrente e trasferisce il controllo a un gestore che saprà come gestire questo errore.

Il tipo runtime_error è uno dei tipi di eccezione della libreria standard ed è definito nell'header stdexcept. Dobbiamo inizializzare un runtime_error dandogli una stringa o una stringa di caratteri in stile C. Quella stringa fornisce informazioni aggiuntive sul problema.

Il Blocco try

La forma generale di un blocco try è la seguente:

try {
    istruzioni-programma
} catch (dichiarazione-eccezione) {
    istruzioni-gestore
} catch (dichiarazione-eccezione) {
    istruzioni-gestore
} // ...

Un blocco try inizia con la parola chiave try seguita da un blocco, che, come al solito, è una sequenza di istruzioni racchiuse tra parentesi graffe.

Dopo il blocco try c'è una lista di una o più clausole catch. Un catch consiste di tre parti: la parola chiave catch, la dichiarazione di un oggetto (possibilmente senza nome) all'interno di parentesi (chiamata dichiarazione di eccezione), e un blocco. Quando un catch viene selezionato per gestire un'eccezione, il blocco associato viene eseguito. Una volta che il catch termina, l'esecuzione continua con l'istruzione immediatamente successiva all'ultima clausola catch del blocco try.

Le istruzioni-programma all'interno del try costituiscono la logica normale del programma. Come qualsiasi altro blocco, possono contenere qualsiasi istruzione C++, incluse le dichiarazioni. Come con qualsiasi blocco, le variabili dichiarate all'interno di un blocco try sono inaccessibili al di fuori del blocco e, in particolare, non sono accessibili alle clausole catch.

Realizzare un Gestore delle Eccezioni

Nell'esempio precedente, abbiamo usato un throw per evitare di dover calcolare la media di un vettore vuoto. Abbiamo immaginato che la parte del programma che calcolava la media fosse separata dalla parte che comunicava con l'utente. La parte che interagisce con l'utente potrebbe contenere codice simile al seguente per gestire l'eccezione che è stata sollevata:

std::vector<double> v;
while (leggi_dati(v)) {
    try {
        // esegui codice che calcola la media
        // se la media viene calcolata, stampala
        // se il vettore è vuoto, viene sollevata un'eccezione
    } catch (runtime_error err) {
        // ricorda all'utente che il vettore era vuoto
        cout << err.what()
             << "\nRiprovare? Inserisci s o n" << endl;
        char c;
        cin >> c;
        if (!cin || c == 'n')
            break; // esce dal ciclo while
    }
}

In questo esempio, abbiamo supposto che leggi_dati sia una funzione che legge vettori di dati di cui calcolare la media. Tale funzione restituisce true se sono stati letti dati validi e false altrimenti e, in quest'ultimo caso, il ciclo while termina.

La logica ordinaria del programma che gestisce l'interazione con l'utente appare all'interno del blocco try. Questa parte del programma è racchiusa in un try perché potrebbe sollevare un'eccezione di tipo runtime_error.

Questo blocco try ha una singola clausola catch, che gestisce eccezioni di tipo runtime_error. Le istruzioni nel blocco che segue il catch vengono eseguite se il codice all'interno del blocco try solleva un runtime_error. Il nostro catch gestisce l'errore stampando un messaggio e chiedendo all'utente di indicare se vuole continuare. Se l'utente inserisce 'n', allora il break viene eseguito e usciamo dal while. Altrimenti, l'esecuzione passa attraverso la parentesi graffa di chiusura del while, che trasferisce il controllo alla condizione del while per l'iterazione successiva.

Il prompt all'utente stampa il ritorno da err.what(). Sappiamo che err ha tipo runtime_error, quindi possiamo dedurre che what è una funzione membro della classe runtime_error. Ciascuna delle classi di eccezione della libreria definisce una funzione membro chiamata what. Queste funzioni non prendono argomenti e restituiscono una stringa di caratteri in stile C (cioè, un const char *). Il membro what di runtime_error restituisce una copia della stringa usata per inizializzare il particolare oggetto. Se il codice descritto nella sezione precedente sollevasse un'eccezione, allora questo catch stamperebbe

Impossibile calcolare la media di un vettore vuoto.
Riprovare? Inserisci s o n

Le Funzioni Vengono Terminate Durante la Ricerca di un Gestore delle Eccezioni

In sistemi complicati, il percorso di esecuzione di un programma può passare attraverso più blocchi try prima di incontrare codice che solleva un'eccezione. Ad esempio, un blocco try potrebbe chiamare una funzione che contiene un try, che chiama un'altra funzione con il proprio try, e così via.

La ricerca di un gestore inverte la catena di chiamate. Quando viene sollevata un'eccezione, la funzione che ha sollevato l'eccezione viene cercata per prima. Se non viene trovato nessun catch corrispondente, quella funzione termina. La funzione che ha chiamato quella che ha sollevato l'eccezione viene cercata successivamente. Se non viene trovato nessun gestore, anche quella funzione esce. Il chiamante di quella funzione viene cercato successivamente, e così via risalendo il percorso di esecuzione fino a quando viene trovato un catch di tipo appropriato.

Se non viene trovato nessun catch appropriato, l'esecuzione viene trasferita a una funzione di libreria chiamata terminate. Il comportamento di quella funzione è dipendente dal sistema ma comunque essa deve garantire di interrompere l'ulteriore esecuzione del programma.

Le eccezioni che si verificano in programmi che non definiscono alcun blocco try vengono gestite nello stesso modo: dopo tutto, se non ci sono blocchi try, non ci possono essere gestori. Se un programma non ha blocchi try e si verifica un'eccezione, allora viene chiamata terminate e il programma viene terminato.

Nota

Scrivere Codice Sicuro Rispetto alle Eccezioni è Difficile

È importante rendersi conto che le eccezioni interrompono il flusso normale di un programma.

Nel punto in cui si verifica l'eccezione, alcuni dei calcoli che il chiamante ha richiesto potrebbero essere stati fatti, mentre altri rimangono non fatti. In generale, bypassare parte del programma potrebbe significare che un oggetto è lasciato in uno stato non valido o incompleto, o che una risorsa non viene liberata, e così via. I programmi che "puliscono" correttamente durante la gestione delle eccezioni sono detti sicuri rispetto alle eccezioni o exception safe. Scrivere codice sicuro rispetto alle eccezioni è sorprendentemente difficile, e (in gran parte) oltre lo scopo di questo corso.

Alcuni programmi usano le eccezioni semplicemente per terminare il programma quando si verifica una condizione eccezionale. Tali programmi generalmente non si preoccupano della sicurezza rispetto alle eccezioni.

I programmi che gestiscono le eccezioni e continuano l'elaborazione generalmente devono essere costantemente consapevoli se un'eccezione potrebbe verificarsi e cosa il programma deve fare per assicurarsi che gli oggetti siano validi, che le risorse non perdano, e che il programma sia ripristinato a uno stato appropriato.

Occasionalmente segnaleremo tecniche particolarmente comuni usate per promuovere la sicurezza rispetto alle eccezioni. Tuttavia, i lettori i cui programmi richiedono una robusta gestione delle eccezioni dovrebbero essere consapevoli che le tecniche che copriamo sono di per sé insufficienti per ottenere la sicurezza rispetto alle eccezioni.

Eccezioni Standard

La libreria C++ definisce diverse classi che usa per segnalare problemi incontrati nelle funzioni della libreria standard. Queste classi di eccezioni sono anche destinate ad essere usate nei programmi che scriviamo. Queste classi sono definite in quattro header:

  • L'header exception definisce il tipo più generale di classe di eccezione chiamata exception. Comunica solo che si è verificata un'eccezione ma non fornisce informazioni aggiuntive.
  • L'header stdexcept definisce diverse classi di eccezione di uso generale, che sono elencate nella tabella sottostante.
  • L'header new definisce il tipo di eccezione bad_alloc, che copriamo in seguito.
  • L'header type_info definisce il tipo di eccezione bad_cast, che copriamo in seguito.
Classi di Eccezione Standard Definite in <stdexcept> Descrizione
exception Il tipo più generale di problema.
runtime_error Problema che può essere rilevato solo in fase di esecuzione.
range_error Errore in fase di esecuzione: risultato generato al di fuori dell'intervallo di valori significativi.
overflow_error Errore in fase di esecuzione: calcolo che ha causato overflow.
underflow_error Errore in fase di esecuzione: calcolo che ha causato underflow.
logic_error Errore nella logica del programma.
domain_error Errore logico: argomento per cui non esiste risultato.
invalid_argument Errore logico: argomento inappropriato.
length_error Errore logico: tentativo di creare un oggetto più grande della dimensione massima per quel tipo.
out_of_range Errore logico: usato un valore al di fuori dell'intervallo valido.
Tabella 1: Classi di Eccezione Standard Definite in

Le classi di eccezione della libreria hanno solo poche operazioni. Possiamo creare, copiare e assegnare oggetti di qualsiasi tipo di eccezione.

Possiamo solo inizializzare per default gli oggetti exception, bad_alloc e bad_cast; non è possibile fornire un inizializzatore per oggetti di questi tipi di eccezione.

Gli altri tipi di eccezione hanno il comportamento opposto: Possiamo inizializzare quegli oggetti da una stringa o una stringa in stile C, ma non possiamo inizializzarli per default. Quando creiamo oggetti di questi altri tipi di eccezione, dobbiamo fornire un inizializzatore. Quell'inizializzatore viene usato per fornire informazioni aggiuntive sull'errore che si è verificato.

I tipi di eccezione definiscono solo una singola operazione chiamata what. Quella funzione non prende argomenti e restituisce un const char* che punta a una stringa di caratteri in stile C. Lo scopo di questa stringa di caratteri in stile C è fornire qualche tipo di descrizione testuale dell'eccezione sollevata.

Il contenuto della stringa in stile C che what restituisce dipende dal tipo dell'oggetto eccezione. Per i tipi che prendono un inizializzatore stringa, la funzione what restituisce quella stringa. Per gli altri tipi, il valore della stringa che what restituisce varia a seconda del compilatore.

Esercizi

  • Scrivi un programma che legge due interi dall'input standard e stampa il risultato della divisione del primo numero per il secondo.

    Soluzione:

    #include <iostream>
    using namespace std;
    
    int main() {
        int num1, num2;
        cout << "Inserisci due numeri interi (dividendo e divisore): ";
        cin >> num1 >> num2;
    
        cout << "Risultato: " << num1 / num2 << endl;
    
        return 0;
    }
    
  • Rivedi il tuo programma per sollevare un'eccezione se il secondo numero è zero.

    Soluzione:

    #include <iostream>
    #include <stdexcept>
    using namespace std;
    
    int main() {
        int num1, num2;
        cout << "Inserisci due numeri interi (dividendo e divisore): ";
        cin >> num1 >> num2;
    
        if (num2 == 0)
            throw runtime_error("Errore: divisione per zero.");
    
        cout << "Risultato: " << num1 / num2 << endl;
    
        return 0;
    }
    
  • Rivedi il tuo programma dall'esercizio precedente per usare un blocco try per catturare l'eccezione. La clausola catch dovrebbe stampare un messaggio all'utente e chiedergli di fornire un nuovo numero e ripetere il codice all'interno del try.

    #include <iostream>
    #include <stdexcept>
    using namespace std;
    
    int main() {
        int num1, num2;
        bool completo = false;
    
        do {
            cout << "Inserisci due numeri interi (dividendo e divisore): ";
    
            cin >> num1 >> num2;
    
            try {
                if (num2 == 0)
                    throw runtime_error("Errore: divisione per zero.");
                cout << "Risultato: " << num1 / num2 << endl;
                completo = true; // Uscita dal ciclo se la divisione ha successo
            }
            catch (runtime_error& e) {
                cout << e.what() << " Riprova." << endl;
            }
        } while (!completo);
    
        return 0;
    }
    

    In questo programma, chiediamo all'utente di inserire due numeri interi. Se il secondo numero (il divisore) è zero, solleviamo un'eccezione runtime_error. Il blocco catch cattura questa eccezione e stampa un messaggio di errore, chiedendo all'utente di riprovare. Il ciclo continua fino a quando l'utente inserisce un divisore non zero, permettendo così la divisione e l'uscita dal ciclo.