Introduzione alle Eccezioni in C#

Quando scriviamo un programma, descriviamo passo dopo passo che cosa il computer deve fare (almeno nella programmazione imperativa; nella programmazione funzionale le cose sono un po' diverse) e, nella maggior parte dei casi, ci affidiamo al fatto che il programma verrà eseguito normalmente.

In effetti, la maggior parte delle volte i programmi seguono questo schema normale, ma esistono alcune eccezioni. Supponiamo di voler leggere un file e visualizzarne il contenuto sullo schermo. Supponiamo che il file si trovi su un server remoto e che, durante la lettura, la connessione venga interrotta. Il file verrà quindi caricato solo parzialmente.

Il programma non sarà in grado di eseguire normalmente e mostrare il contenuto del file sullo schermo. In questo caso abbiamo un'eccezione rispetto all'esecuzione normale (e corretta) del programma e tale eccezione deve essere riportata all'utente e/o all'amministratore.

Eccezioni

Eccezione è una notifica che qualcosa interrompe l'esecuzione normale del programma. Le eccezioni forniscono un paradigma di programmazione per rilevare e reagire a eventi imprevisti. Quando sorge un'eccezione, lo stato del programma viene salvato, il flusso normale viene interrotto e il controllo viene passato a un gestore di eccezioni (se esiste nel contesto corrente).

Le eccezioni vengono sollevate o lanciate dal codice di programmazione che deve inviare un segnale al programma in esecuzione riguardo a un errore o a una situazione anomala. Per esempio, se proviamo ad aprire un file che non esiste, il codice responsabile dell'apertura del file rileverà la situazione e lancerà un'eccezione con un opportuno messaggio di errore.

Le eccezioni sono uno dei principali paradigmi della programmazione orientata agli oggetti (OOP). Approfondiremo questo concetto più avanti nelle prossime lezioni.

Cattura e gestione delle eccezioni

La gestione delle eccezioni è un meccanismo che permette alle eccezioni di essere lanciate e catturate. Questo meccanismo è fornito internamente dal CLR (Common Language Runtime). Parti dell'infrastruttura di gestione delle eccezioni sono i costrutti del linguaggio in C# per lanciare e catturare eccezioni. Il CLR si occupa di propagare ogni eccezione al codice che può gestirla.

Nella Programmazione Orientata agli Oggetti (OOP), le eccezioni sono un potente meccanismo per la gestione centralizzata degli errori e delle situazioni eccezionali. Questo meccanismo sostituisce il metodo procedurale di gestione degli errori in cui ogni funzione restituisce un codice che indica un errore o un'esecuzione riuscita.

Di solito, in OOP, un codice che esegue un'operazione genererà un'eccezione se c'è un problema e l'operazione non può essere completata con successo. Il metodo che causa l'operazione può catturare l'eccezione (e gestire l'errore) o propagare l'eccezione al metodo chiamante. Ciò consente di delegare la gestione degli errori a un livello superiore nello stack delle chiamate e, in generale, permette una gestione flessibile di errori e situazioni impreviste.

Un altro concetto fondamentale è la gerarchia delle eccezioni. In OOP le eccezioni sono classi e possono essere ereditate per costruire gerarchie. Quando un'eccezione viene gestita (catturata), il meccanismo di gestione può intercettare un'intera classe di eccezioni e non solo un particolare errore (come nella programmazione procedurale tradizionale).

In OOP è consigliato usare le eccezioni per gestire situazioni di errore o eventi imprevisti che possono sorgere durante l'esecuzione di un programma. Ciò sostituisce l'approccio procedurale di gestione degli errori e offre importanti vantaggi come la gestione centralizzata degli errori, la gestione di errori multipli in un solo punto e la possibilità di passare gli errori a un gestore di livello superiore. Un altro vantaggio importante è che le eccezioni si auto-descrivano e possono creare gerarchie.

Talvolta le eccezioni vengono usate non tanto per segnalare un problema quanto per gestire un evento previsto. Questo non è considerato una buona pratica, poiché le eccezioni non dovrebbero controllare il flusso normale del programma. Nelle prossime lezioni analizzeremo più nel dettaglio questo aspetto.

Eccezioni in .NET

Un'eccezione in .NET è un oggetto che segnala un errore o un evento che non rientra nel flusso normale del programma. Quando si verifica un evento insolito, il metodo in esecuzione lancia un oggetto speciale contenente informazioni sul tipo di errore, sul punto del programma in cui l'errore si è verificato e sullo stato del programma al momento dell'errore.

Ogni eccezione in .NET contiene il cosiddetto stack trace, che fornisce informazioni su dove esattamente l'errore si è verificato. Questo verrà discusso in maggior dettaglio più avanti nelle prossime lezioni.

Un esempio di codice che lancia un'eccezione

Ecco un esempio di codice che lancerà un'eccezione:

class ExceptionsDemo
{
    static void Main()
    {
        string fileName = "WrongTextFile.txt";
        ReadFile(fileName);
    }

    static void ReadFile(string fileName)
    {
        TextReader reader = new StreamReader(fileName);
        string line = reader.ReadLine();
        Console.WriteLine(line);
        reader.Close();
    }
}

Questo programma verrà compilato con successo ma, se lo si esegue, il risultato sarà simile al seguente (FileNotFoundException stampata sulla console):

Unhandled Exception: System.IO.FileNotFoundException: Could not find file 'WrongTextFile.txt'.
   at System.IO.__Error.WinIOError(Int32 errorCode, String maybeFullPath)
   at System.IO.FileStream..ctor(String path, FileMode mode, FileAccess access, FileShare share, Int32 bufferSize, FileOptions options)
   at System.IO.StreamReader..ctor(String path)
   at ExceptionsDemo.ReadFile(String fileName) in c:\Projects\OutOfRange\ExceptionsDemo.cs:line 13
   at ExceptionsDemo.Main() in c:\Projects\OutOfRange\ExceptionsDemo.cs:line 6
Press any key to continue . . .

In questo esempio abbiamo un codice che tenta di aprire un file di testo in lettura e poi visualizzare la prima riga del file sullo schermo. Studieremo in dettaglio i file e i flussi di input-output in C# nelle prossime lezioni. Per ora, è sufficiente sapere che il metodo ReadFile(string fileName) accetta il nome di un file come parametro e tenta di aprirlo in lettura.

Le prime due righe di ReadFile() contengono codice che lancia un'eccezione. In questo esempio, se il file WrongTextFile.txt non esiste, il costruttore StreamReader(string fileName) lancerà una FileNotFoundException. Se durante le operazioni di input-output si verifica un problema imprevisto, i metodi del flusso, come ReadLine(), lanceranno una IOException.

Il codice sopra si compila con successo ma a run-time lancerà un'eccezione se il file WrongTextFile.txt non esiste. Il risultato finale in questo caso è un messaggio di errore visualizzato sulla console. L'output della console contiene anche informazioni su dove e come l'errore si è verificato.

Come funzionano le eccezioni?

Se durante l'esecuzione normale del programma uno dei metodi lancia un'eccezione, il flusso normale del programma viene interrotto. Nell'esempio sopra ciò avviene quando viene inizializzato StreamReader. Osserviamo la seguente riga:

TextReader reader = new StreamReader("WrongTextFile.txt");

Se questa riga genera un errore, la variabile locale reader non verrà inizializzata e avrà il suo valore predefinito di null. Nessuna delle righe che seguono nel metodo verrà eseguita. Il programma verrà interrotto finché il CLR non troverà un gestore in grado di processare l'eccezione.

Cattura delle eccezioni in C#

Dopo che un metodo lancia un'eccezione, il CLR cerca un gestore di eccezioni che possa elaborare l'errore. Per capire come funziona, esamineremo più da vicino il concetto di call-stack.

Lo stack delle chiamate del programma è una struttura a pila che contiene informazioni sulle chiamate di metodo, sulle loro variabili locali, sui parametri dei metodi e sulla memoria per i tipi valore.

I programmi .NET iniziano dal metodo Main(...), che è il punto di ingresso del programma. Un altro metodo, chiamiamolo Metodo_1, può essere chiamato da Main. Metodo_1 può chiamare Metodo_2 e così via finché viene chiamato Metodo_N.

Quando Metodo_N termina, il flusso del programma ritorna al metodo chiamante (nel nostro esempio “Method N-1”), quindi al suo chiamante e così via, finché non si raggiunge il metodo Main(...). Una volta che Main(...) termina, l'intero programma si chiude.

Il principio generale è che, quando viene chiamato un nuovo metodo, esso viene spinto in cima allo stack. Quando il metodo termina, viene rimosso dallo stack. In qualsiasi momento, lo stack delle chiamate contiene tutti i metodi chiamati durante l'esecuzione – dal metodo iniziale Main(...) all'ultimo metodo chiamato, che è attualmente in esecuzione, insieme alle loro variabili locali e agli argomenti passati in input.

Il meccanismo di gestione delle eccezioni segue un processo inverso. Quando viene lanciata un'eccezione, il CLR inizia a cercare un gestore di eccezioni nello stack delle chiamate a partire dal metodo che ha lanciato l'eccezione. Questa ricerca si ripete per ciascuno dei metodi nello stack finché non viene trovato un gestore che intercetta l'eccezione. Se si raggiunge Main(...) e non si trova alcun gestore, il CLR intercetta l'eccezione e di solito visualizza un messaggio di errore (nella console o in una finestra di dialogo).

Il processo di chiamata dei metodi e di gestione delle eccezioni descritto può essere visualizzato nel seguente diagramma (passi da 1 a 5):

Meccanismo di Lancio di un'eccezione
Figura 1: Meccanismo di Lancio di un'eccezione

Il costrutto di programmazione try-catch

Per gestire un'eccezione, dobbiamo racchiudere il codice che potrebbe generare un'eccezione in un blocco try-catch:

try
{
    // Codice che potrebbe lanciare un'eccezione
}
catch (ExceptionType objectName)
{
    // Codice che gestisce un'eccezione
}

Il costrutto try-catch è composto da un blocco try e da uno o più blocchi catch. All'interno del blocco try inseriamo il codice che potrebbe lanciare eccezioni. ExceptionType nel blocco catch deve essere un tipo derivato da System.Exception, altrimenti il codice non verrà compilato. L'espressione tra parentesi dopo catch è anche la dichiarazione di una variabile; quindi, all'interno del blocco catch possiamo usare objectName per accedere alle proprietà dell'eccezione o chiamarne i metodi.

Esempio di gestione delle eccezioni

Modifichiamo ora il codice del nostro esempio precedente affinché gestisca le sue eccezioni. Per farlo, racchiudiamo il codice che potrebbe creare problemi in try-catch e aggiungiamo blocchi catch per gestire i due tipi di eccezioni che sappiamo potrebbero verificarsi.

static void ReadFile(string fileName)
{
    // Un'eccezione potrebbe essere lanciata nel codice seguente
    try
    {
        TextReader reader = new StreamReader(fileName);
        string line  = reader.ReadLine();
        Console.WriteLine(line);
        reader.Close();
    }
    catch (FileNotFoundException fnfe)
    {
        // Gestore per FileNotFoundException
        // Informiamo semplicemente l'utente che non esiste tale file
        Console.WriteLine(
            "Il file '{0}' non è stato trovato.", fileName);
    }
    catch (IOException ioe)
    {
        // Gestore per altre eccezioni di input/output
        // Stampiamo semplicemente lo stack trace sulla console
        Console.WriteLine(ioe.StackTrace);
    }
}

Ora il nostro metodo funziona in modo diverso. Quando viene lanciata una FileNotFoundException durante l'inizializzazione di StreamReader (eseguendo il costruttore new StreamReader(filename)), il CLR non esegue le righe successive ma salta direttamente al blocco catch (FileNotFoundException fnfe):

catch (FileNotFoundException fnfe)
{
    // Gestore per FileNotFoundException
    // Informiamo semplicemente l'utente che non esiste tale file
    Console.WriteLine("Il file '{0}' non è stato trovato.", fileName);
}

Nel nostro esempio, l'utente viene semplicemente informato che il file non esiste tramite un messaggio sull'output standard:

Il file 'WrongTextFile.txt' non è stato trovato.

Analogamente, se viene lanciata un'IOException durante reader.ReadLine(), essa viene gestita dal blocco seguente:

catch (IOException ioe)
{
    // Gestore per IOException
    // Stampiamo semplicemente lo stack trace sullo schermo
    Console.WriteLine(ioe.StackTrace);
}

In questo caso, lo stack trace dell'eccezione viene visualizzato sull'output standard.

Le righe comprese tra il punto in cui l'eccezione viene lanciata e il blocco catch che la elabora non vengono eseguite.

Nota

Attenzione alle informazioni esposte

Mostrare all'utente finale tutte le informazioni sull'eccezione non è sempre una buona pratica!

Discuteremo le migliori pratiche di gestione delle eccezioni più avanti nelle prossime lezioni.

Stack Trace

Lo stack trace contiene informazioni dettagliate sull'eccezione, incluso il punto esatto in cui si è verificata nel programma. Lo stack trace è molto utile ai programmatori quando cercano di capire il problema che ha causato l'eccezione. Le informazioni presenti nello stack trace sono molto tecniche e sono pensate per essere usate da programmatori e amministratori di sistema, non dagli utenti finali. Durante il debug, lo stack trace è uno strumento di valore inestimabile.

Ecco lo stack trace tratto dal nostro primo esempio:

Unhandled Exception: System.IO.FileNotFoundException: Could not
find file '…\WrongTextFile.txt'.
   at System.IO.__Error.WinIOError(Int32 errorCode, String maybeFullPath)
   at System.IO.FileStream.Init(String path, FileMode mode,
       FileAccess access, Int32 rights, Boolean useRights,
       FileShare share, Int32 bufferSize, FileOptions options,
       SECURITY_ATTRIBUTES secAttrs, String msgPath,
       Boolean bFromProxy, Boolean useLongPath)
   at System.IO.FileStream..ctor(String path, FileMode mode,
       FileAccess access, FileShare share, Int32 bufferSize,
       FileOptions options)
   at System.IO.StreamReader..ctor(String path, Encoding encoding,
       Boolean detectEncodingFromByteOrderMarks, Int32 bufferSize)
   at System.IO.StreamReader..ctor(String path)
   at Exceptions.Demo1.ReadFile(String fileName) in Program.cs:line 17
   at Exceptions.Demo1.Main() in Program.cs:line 11

Il sistema non riesce a trovare il file denominato WrongTextFile.txt e viene lanciata una FileNotFoundException.

Lettura dello Stack Trace

Per poter utilizzare lo stack trace dobbiamo conoscerne la struttura.

Lo stack trace contiene le seguenti informazioni:

  • Il nome completo della classe dell'eccezione;
  • Un messaggio con informazioni aggiuntive sull'errore;
  • Informazioni sul call-stack;

Nel nostro esempio sopra, il nome completo dell'eccezione è System.IO.FileNotFoundException. Il messaggio di errore segue: Could not find file '…\WrongTextFile.txt'. Ciò che segue è il dump completo del call-stack, che di solito è la parte più lunga dello stack trace. Ogni riga del dump del call-stack contiene qualcosa di simile al seguente:

at <namespace>.<class>.<method> in <source file>.cs:line <line>

Ogni metodo è mostrato su una riga separata. Sulla prima riga c'è il metodo che ha lanciato l'eccezione e sull'ultima riga il metodo Main() (nota che Main() potrebbe non essere presente se l'eccezione è lanciata da un thread che non è il thread principale del programma). Per ogni metodo vengono fornite informazioni complete sulla classe che lo contiene e (se possibile) perfino la riga nel codice sorgente:

at Exceptions.Demo1.ReadFile(String fileName) in …\Program.cs:line 17

I numeri di riga sono inclusi solo se la rispettiva classe è compilata con informazioni di debug (queste informazioni contengono numeri di riga, nomi di variabili e altre informazioni tecniche). Le informazioni di debug non sono incluse negli assembly .NET ma in file separati chiamati “debug symbols” (*.pdb). Come puoi vedere nell'esempio di stack trace, le informazioni di debug sono disponibili per alcuni assembly, mentre per altri (come gli assembly .NET) non lo sono. Ecco perché alcune voci nello stack trace hanno numeri di riga e altre no.

Se il metodo che lancia l'eccezione è un costruttore, al posto del nome del metodo lo stack trace contiene la parola .ctor, come in:

System.IO.StreamReader..ctor(String path)

Queste informazioni dettagliate nello stack trace permettono di trovare rapidamente e facilmente la classe, il metodo e perfino la riga sorgente in cui si è verificato l'errore. A quel punto, di solito, è abbastanza semplice analizzare il problema che ha causato l'errore e correggerlo. Questo non vale per linguaggi primitivi come C e Pascal nei quali il concetto di stack trace non è supportato.

Lancio delle eccezioni tramite throw

In C# le eccezioni vengono lanciate usando la parola chiave throw. Dobbiamo fornire un'istanza dell'eccezione che contenga tutte le informazioni necessarie sull'errore. Le eccezioni sono classi normali e l'unico requisito è che ereditino, direttamente o indirettamente, dalla classe System.Exception.

Ecco un esempio:

static void Main()
{
    Exception e = new Exception("There was a problem");
    throw e;
}

Il risultato dell'esecuzione di questo programma è:

Unhandled Exception: System.Exception: There was a problem
   at Exceptions.Demo1.Main() in Program.cs:line 11