Lanciare e Catturare Eccezioni in C#
- Nella maggior parte dei casi, le eccezioni da gestire sono eccezioni applicative specifiche che contengono eccezioni interne per preservare i dettagli tecnici.
- Le eccezioni annidate sono utili per mantenere informazioni tecniche dettagliate sulle eccezioni originali.
- È una buona pratica gestire solo le eccezioni che si aspettano e che sanno come elaborare, lasciando le altre al metodo chiamante.
- La gestione delle eccezioni a più livelli consente di gestire le condizioni di errore nel luogo più adatto, migliorando la chiarezza e la struttura del codice.
Gestire le Eccezioni Annidate
Abbiamo già visto che ogni eccezione può contenere un'eccezione annidata (interna). Spieghiamo in maggiore dettaglio perché è una pratica comune nella gestione degli errori in OOP annidare le eccezioni in questo modo.
Nell'ingegneria del software, è una buona pratica per ogni componente software definire un piccolo numero di eccezioni dell'applicazione specifiche. Il componente quindi lancerà solo queste eccezioni specifiche dell'applicazione e non le eccezioni standard .NET. In questo modo gli utenti del componente software sapranno quali eccezioni aspettarsi da esso.
Ad esempio, se abbiamo un software bancario e abbiamo un componente che gestisce gli interessi, questo componente definirebbe (e lancerebbe) eccezioni come EccezioneCalcoloInteresse
e EccezionePeriodoNonValido
.
Il componente degli interessi non dovrebbe lanciare eccezioni come FileNotFoundException
, DivideByZeroException
e NullReferenceException
. Quando si verifica un errore, che non è direttamente correlato al calcolo degli interessi, la rispettiva eccezione viene avvolta in EccezioneCalcoloInteresse
e il codice chiamante sarà informato che il calcolo degli interessi non è stato eseguito correttamente.
Tuttavia, queste eccezioni dell'applicazione utente di solito non hanno informazioni tecniche dettagliate sulla natura del problema. Ad esempio, una eccezione EccezioneCalcoloInteresse
potrebbe essere stata causata dal fatto che il file di configurazione del componente non esisteva, oppure che il database non era accessibile, o che il calcolo degli interessi ha incontrato un errore interno. Quindi, se l'utente del componente software vuole sapere cosa è andato storto, deve guardare la stack trace dell'eccezione originale.
Questo è il motivo per cui si considera una buona pratica includere dettagli tecnici sul problema e qui le eccezioni annidate tornano utili. Quando il componente lancia la sua eccezione dell'applicazione, dovrebbe mantenere l'eccezione originale come eccezione interna per preservare i dettagli tecnici sull'errore.
Un altro esempio è quando un componente software (chiamiamolo Componente A
) definisce le proprie eccezioni dell'applicazione (eccezioni A
). Questo componente internamente usa un altro componente (chiamato Componente B
). Se per qualche ragione B
lancia un'eccezione B
(un'eccezione definita in B
), forse A
dovrà propagare l'errore perché non sarà in grado di svolgere il suo compito. E poiché A
non può semplicemente lanciare un'eccezione B
, deve lanciare un'eccezione A
, contenente l'eccezione B
come eccezione annidata.
Potrebbero esserci varie ragioni per cui A
non può semplicemente lanciare un'eccezione B
:
- Gli utenti del Componente
A
non dovrebbero nemmeno sapere che il ComponenteB
esiste per motivi di astrazione e incapsulamento; - Il Componente
A
non aveva dichiarato che avrebbe lanciato eccezioni del ComponenteB
; - Gli utenti del Componente
A
non sono preparati a ricevere eccezioni del ComponenteB
. Si aspettano solo eccezioni del componenteA
.
Come Leggere lo Stack Trace delle Eccezioni Annidate
Di seguito abbiamo un esempio che crea una catena di eccezioni.
Dimostreremo come tale catena di eccezioni viene creata e come appare la stack trace nell'output:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
In questo esempio, chiamiamo il metodo LeggiFile()
(linea 9), che genererà un'eccezione (linea 19) perché il file "NomeFileSbagliato.txt"
non esiste.
Nel metodo Main()
catturiamo tutte le eccezioni (linea 11), le annidiamo in una nuova eccezione di tipo ApplicationException
e le lanciamo di nuovo (linea 13). Come vedremo nelle prossime lezioni, catturare un'Exception
cattura anche tutte le sue eccezioni discendenti nella sua gerarchia.
Infine l'eccezione lanciata (alla linea 13) viene catturata dal .NET Framework e la sua stack trace viene mostrata sulla console.
Il risultato dell'esecuzione dell'esempio sopra è mostrato di seguito:
Unhandled exception. System.ApplicationException: Qualcosa di brutto è successo
---> System.IO.FileNotFoundException: Could not find file '/home/distortionbyte/csharp/DemoEccezioni/NomeFileSbagliato.txt'.
File name: '/home/distortionbyte/csharp/DemoEccezioni/NomeFileSbagliato.txt'
at Interop.ThrowExceptionForIoErrno(ErrorInfo errorInfo, String path, Boolean isDirError)
at Microsoft.Win32.SafeHandles.SafeFileHandle.Open(String path, OpenFlags flags, Int32 mode, Boolean failForSymlink, Boolean& wasSymlink, Func`4 createOpenException)
at Microsoft.Win32.SafeHandles.SafeFileHandle.Open(String fullPath, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, UnixFileMode openPermissions, Int64& fileLength, UnixFileMode& filePermissions, Boolean failForSymlink, Boolean& wasSymlink, Func`4 createOpenException)
at System.IO.Strategies.OSFileStreamStrategy..ctor(String path, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode)
at System.IO.FileStream..ctor(String path, FileMode mode, FileAccess access, FileShare share, Int32 bufferSize, FileOptions options, Int64 preallocationSize)
at System.IO.StreamReader.ValidateArgsAndOpenPath(String path, Encoding encoding, Int32 bufferSize)
at System.IO.StreamReader..ctor(String path)
at DemoEccezioni.LeggiFile(String nomeFile) in /home/distortionbyte/csharp/DemoEccezioni/Program.cs:line 19
at DemoEccezioni.Main() in /home/distortionbyte/csharp/DemoEccezioni/Program.cs:line 9
--- End of inner exception stack trace ---
at DemoEccezioni.Main() in /home/distortionbyte/csharp/DemoEccezioni/Program.cs:line 13
Guardiamo più attentamente lo stack trace. Ora vediamo una sezione aggiuntiva che marca la fine dell'eccezione annidata:
--- End of inner exception stack trace ---
Questo fornisce informazioni utili su come l'eccezione è stata lanciata.
Se guardiamo più da vicino la prima linea, noterete che contiene informazioni nel seguente formato:
Unhandled exception. System.ApplicationException: Qualcosa di brutto è successo
Questo mostra che un'eccezione di tipo System.ApplicationException
è annidata attorno a un'eccezione di tipo System.IO.FileNotFoundException
. Dopo ogni tipo di eccezione, possiamo vedere il messaggio della rispettiva eccezione (come contenuto nella proprietà Message
). Utilizzando le informazioni nella stack-trace (il nome del file, il metodo e il numero di linea), possiamo scoprire come sono avvenute le eccezioni e dove.
Quali Eccezioni Gestire?
Esiste una regola universale riguardo alla gestione delle eccezioni:
Consiglio su quali eccezioni gestire
Un metodo dovrebbe gestire solo le eccezioni che si aspetta e che sa come elaborare.
Tutte le altre eccezioni devono essere lasciate al metodo chiamante.
Se seguiamo questa regola e ogni metodo lascia le eccezioni su cui non è competente per essere elaborate dal metodo chiamante, alla fine raggiungeremo il metodo Main()
(o il metodo di avvio del rispettivo thread di esecuzione) e se questo metodo non cattura l'eccezione, il CLR visualizzerà l'errore sulla console (o lo visualizzerà in qualche altro modo) e terminerà il programma.
Un metodo è competente a gestire un'eccezione se si aspetta questa eccezione, ha le informazioni sul perché l'eccezione è stata lanciata e cosa fare in questa situazione.
Se abbiamo un metodo che deve leggere un file di testo e restituire il suo contenuto come stringa, quel metodo potrebbe catturare FileNotFoundException
e restituire una stringa vuota in questo caso. Tuttavia, questo stesso metodo difficilmente sarà in grado di gestire correttamente OutOfMemoryException
.
Cosa dovrebbe fare il metodo in caso di memoria insufficiente? Restituire una stringa vuota? Lanciare qualche altra eccezione? Fare qualcosa di completamente diverso? Quindi apparentemente il metodo non è competente a gestire tale eccezione e quindi il modo migliore è passare l'eccezione al metodo chiamante così che possa (si spera) essere gestita a qualche altro livello da un metodo competente a farlo. Utilizzando questa semplice filosofia permette alla gestione delle eccezioni di essere fatta in modo strutturato e sistematico.
Esempio di Eccezioni Lanciate da Main
Lanciare eccezioni dal metodo Main()
generalmente non è una buona pratica. Invece, è meglio che tutte le eccezioni vengano catturate in Main()
.
Tuttavia è ovviamente possibile lanciare eccezioni da Main()
proprio come da qualsiasi altro metodo:
static void Main()
{
throw new Exception("Ooops!");
}
Ogni eccezione che non viene gestita in Main()
viene alla fine catturata dal CLR e visualizzata stampando lo stack trace sull'output della console o in qualche altro modo.
Mentre per applicazioni piccole non è un problema così grave, applicazioni grandi e complesse generalmente non dovrebbero andare in crash in un modo così poco elegante.
Esempio: Gestire le Eccezioni a Diversi Livelli
La capacità di passare (o propagare) le eccezioni attraverso un determinato metodo fino al metodo chiamante consente di gestire le eccezioni strutturate a più livelli.
Questo significa che possiamo catturare certi tipi di eccezioni in determinati metodi e passare tutte le altre eccezioni ai livelli precedenti nello stack di chiamate.
Nell'esempio seguente, le eccezioni nel metodo LeggiFile()
sono gestite a due livelli (nel blocco try-catch
del metodo LeggiFile()
stesso e nel blocco try-catch
del metodo Main()
):
public class DemoEccezioni
{
static void Main()
{
try
{
string nomeFile = "NomeFileSbagliato.txt";
LeggiFile(nomeFile);
}
catch (Exception e)
{
throw new ApplicationException("È successo qualcosa di brutto", e);
}
}
static void LeggiFile(string nomeFile)
{
try
{
TextReader lettore = new StreamReader(nomeFile);
string linea = lettore.ReadLine();
Console.WriteLine(linea);
lettore.Close();
}
catch (FileNotFoundException fnfe)
{
Console.WriteLine("Il file {0} non esiste!",
nomeFile);
}
}
}
In questo esempio il metodo LeggiFile()
cattura e gestisce solo FileNotFoundException
mentre passa tutte le altre eccezioni al metodo Main()
. Nel metodo Main()
gestiamo solo le eccezioni di tipo IOException
e lasciamo che sia il CLR a gestire tutte le altre eccezioni (ad esempio, se OutOfMemoryException
viene lanciata durante l'esecuzione del programma, sarà gestita dal CLR).
Se il metodo Main()
passa un nome file sbagliato, FileNotFoundException
sarà lanciata durante l'inizializzazione del TextReader
in LeggiFile()
. Questa eccezione sarà gestita dal metodo LeggiFile()
stesso. Se d'altra parte il file esiste ma c'è qualche problema nel leggerlo (permessi insufficienti, contenuti del file danneggiati ecc.), la rispettiva eccezione che sarà lanciata sarà gestita nel metodo Main()
.
Gestire le eccezioni a diversi livelli consente di gestire le condizioni di errore nel luogo più adatto per quel particolare errore. Questo permette al codice del programma di essere chiaro e strutturato e la flessibilità ottenuta è enorme.