versione italiana versione italiana
english version english version

Gestione degli errori in .NET (1/3): cosa sono le eccezioni

È noto che il modello di gestione degli errori del Framework .NET è basato sulle eccezioni; meno chiaro è il modo in cui questo modello va usato nella pratica quotidiana di programmazione.

Cerchiamo di fare un po’ di chiarezza e di vedere cosa cambia per chi arriva da Visual Basic 6.0: per questo motivo tutti gli esempi sono scritti in VB6 e VB.NET.

Partiremo con una descrizione della gestione degli errori in VB6 e dell’equivalente sintattico in VB.NET. Vedremo poi quale è la sintassi nativa di VB.NET basata sulle keyword Try, Catch e Finally, cercando in particolare di definire le migliori linee guida per un uso corretto delle eccezioni. Chiuderemo le nostre considerazioni analizzando cosa comporta l’uso delle eccezioni dal punto di vista delle prestazioni e quali siano le valutazioni da fare in fase di progettazione per ottenere risultati ottimali.

Cosa sono le eccezioni

Un’eccezione è una situazione di errore individuata durante l’esecuzione di un programma. L’eccezione è un evento inaspettato.

Ad esempio: se leggiamo sequenzialmente un array di caratteri (una stringa in C), il fatto di raggiungere il carattere di “fine stringa” non è un evento così inaspettato.

In linea generale, potremmo dire che tutte le condizioni anomale o di errore vanno gestite con un’eccezione. Per motivi prestazionali (le eccezioni comportano un costo computazionale maggiore), esistono delle situazioni in cui può avere senso fare diversamente. Vorrei però soffermarmi inizialmente più sull’aspetto semantico.

Se in un loop di acquisizione dati da un file di testo bisogna ignorare tutte le parole che non rappresentano un valore numerico, non ha molto senso scrivere del codice che tenta di convertire la parola in un numero e poi, in caso di errore, la ignora.

Questo approccio ha diversi svantaggi: non si capisce qual è la reale intenzione del codice, sembra che nel file ci sia solo un elenco di valori numerici (sapendo che non è così) e infine le prestazioni sono peggiori rispetto a un algoritmo che faccia un minimo di parsing del testo per decidere cosa va interpretato come numero e cosa no. Come si vede, le prestazioni (che approfondiremo più avanti) non sono l’elemento decisivo, o più importante, rispetto alle considerazioni semantiche.

Quindi, liberandoci momentaneamente dall’assillo di pensare esclusivamente in termini di prestazioni, passiamo a occuparci del mondo delle eccezioni.

Gestione tradizionale degli errori

Uno dei metodi più classici di gestione degli errori in un metodo consiste nel restituire un valore che informa il chiamante di un’eventuale condizione di errore. Questa tecnica è usata dalle API di Win32, dai livelli più bassi di COM e da milioni di programmatori di tutto il pianeta.

Restituire un codice di errore è semplice, per una funzione che non deve restituire valori al chiamante:


    Public Function SendMessage(ByVal message As String) As Boolean
        If message.Length > 0 Then
            ' Manda il messaggio...
            SendMessage = True
Else
' Errore, stringa vuota
            SendMessage = False
        End If
    End Function
 

Le cose si complicano se è necessario restituire un valore al chiamante. In questo caso si devono restituire due valori (il codice di errore e il risultato dell’operazione).


    Public Function ReadMessage(ByRef message As String) As Boolean
        If dataReady Then
' Dati disponibili
message = buffer
ReadMessage = True
Else
' Errore, dati non disponibili
ReadMessage = False
End If
End Function

Sarebbe stato molto più comodo scrivere ReadMessage in questo modo:


    Public Function ReadMessage() As String
        ' Dati disponibili
        ReadMessage = buffer
    End Function
 

In questo modo, però, non avremmo più nessuno strumento per comunicare eventuali errori al chiamante di ReadMessage.

I problemi che abbiamo restituendo un codice di errore sono i seguenti:

  • Il singolo valore restituito non contiene molte informazioni sull’errore.
  • La gestione futura di più condizioni di errore può modificare il prototipo della funzione (magari un Boolean non è più sufficiente), invalidando il codice chiamante già realizzato.
  • Si spostano informazioni sull’errore anche quando l’errore non si verifica. Il costo è sempre presente, anche quando tutto va bene (la cosa è penalizzante soprattutto se si definiscono strutture dati specializzate per il trasporto delle informazioni sull’errore).
  • Il chiamante diretto della funzione che genera un errore spesso non è in grado di gestire l’errore ricevuto, quindi si limita a propagarlo al proprio chiamante, e così via, in una catena spesso molto lunga.

Concentriamo la nostra attenzione proprio su quest’ultimo punto.

Chiamando una funzione a un livello di astrazione alto (chiamiamola A), le cause di errore possono essere le più disparate e sovente sono individuate a livelli di astrazione molto più bassi (quindi dopo diverse chiamate nidificate, ad esempio dalla funzione Z). In tali condizioni, tutte le chiamate tra A e Z devono fare da “passacarte”: ricevono una condizione di errore e la propagano al rispettivo chiamante, per far “emergere” l’errore al livello più alto. In questa propagazione ci potrebbe essere una modifica del contenuto semantico dell’errore (da “divisione per zero” potrebbe diventare “parametro non valido”).

Tutto ciò ha un costo: l’errore viene ricopiato più volte sullo stack di chiamata (una volta per ogni livello di chiamata) e il programmatore deve scrivere più codice ripetitivo, che propaga unicamente errori non generati direttamente e che non è in grado di gestire. Il rischio è che il programmatore si “dimentichi” di gestire l’errore in uno di questi livelli intermedi, «tanto questa chiamata non potrà mai dare errore»: ecco come si perde inesorabilmente la possibilità di individuare alcuni errori!

Tornando all’esempio precedente, basta pensare a cosa succede se si scrive questo codice che usa ReadMessage:


    Public Function GetStatus(ByRef s1 As String, ByRef s2 As String) As Boolean
        ReadMessage( s1 )
        ReadMessage( s2 )
        GetStatus = True
    End Function
 

Il codice corretto avrebbe dovuto essere il seguente:


    Public Function GetStatus(ByRef s1 As String, ByRef s2 As String) As Boolean
        GetStatus = False
        If Not ReadMessage( s1 ) Then Exit Function
        If Not ReadMessage( s2 ) Then Exit Function
        GetStatus = True
End Function

Nel primo caso, il chiamante di GetStatus non ottiene mai nessuna segnalazione di errore, considerando sempre validi i valori restituiti da tale funzione.

Stiamo necessariamente usando esempi semplici e un po’ banali, ma in tutta onestà chi non si è mai “dimenticato” di propagare eventuali errori nel modo corretto?

On Error Goto

Visual Basic 6 offriva un meccanismo per gestire gli errori che non si basava su codici di ritorno. Tale meccanismo è disponibile, per compatibilità, anche in Visual Basic .NET ma, come vedremo più avanti, in realtà si tratta solo di una sintassi che viene tradotta dal compilatore in un particolare uso delle eccezioni di .NET Framework.

Quindi, che sia chiaro da subito: tutte le volte che usiamo On Error Goto in Visual Basic .NET, in realtà stiamo usando le eccezioni di .NET Framework! Come ho detto, torneremo su questo più avanti.

Partiamo subito da un esempio articolato in VB6.


Private Sub F1()
    On Error GoTo F1err
    Debug.Print "Before call F2"
    Call F2
    Debug.Print "After F2"
    Exit Sub
F1err:
    Debug.Print Err.Description
End Sub
 
Private Sub F2()
    Debug.Print "Before call F3"
    Call F3
    Debug.Print "After call F3"
End Sub
 
Private Sub F3()
    Dim a As Collection
    Debug.Print "Start of F3"
    Call a.Add("Test") ' Genera un errore, a vale Nothing
    Debug.Print "End of F3"
End Sub

Il metodo f3 genera un errore in corrispondenza della chiamata di Add: poiché a non è stato inizializzata, non c’è un’istanza valida su cui effettuare la chiamata. Il suo chiamante diretto, f2, non fa nulla per intercettare l’errore, che viene catturato da f1 perché ha impostato un On Error Goto.

L’esecuzione della funzione f1 genera questo output sulla finestra di debug.

 
Before call F2
Before call F3
Start of F3
Object variable or With block variable not set
 

Come vedremo, tale architettura è molto simile alla gestione delle eccezioni di .NET. Per ora, limitiamoci ad alcune considerazioni.

Il tipo di errore generato contiene alcune informazioni: un codice numerico, una descrizione testuale, l’origine dell’errore e un eventuale link a un file di help. Manca la possibilità di aggiungere altre informazioni in forma tipizzata: l’unica cosa che si può fare è sfruttare la stringa di descrizione inserendo in forma testuale il contenuto di altri oggetti che si vogliono trasmettere.

Se si verifica un errore su una connessione a un database, ad esempio, è utile avere un riferimento all’oggetto che rappresenta la connessione stessa: l’analisi delle sue proprietà può aiutare ad individuare la causa del problema. Questa operazione è possibile solo ricorrendo a dati esterni (come variabili globali) che contengono le informazioni accessorie desiderate: non c’è uno standard e quindi nulla è disponibile per integrare in modo uniforme componenti scritti da produttori diversi.

Uso delle eccezioni

Per introdurre l’uso delle eccezioni, trasformiamo il codice precedente usando, al posto di On Error Goto, le eccezioni di .NET; per l’output usiamo la finestra Console al posto di quella di debug di VB6. Ovviamente il codice che segue è compilabile solo con VB.NET.


Private Sub F1()
    Try
        Console.WriteLine("Before call F2")
        Call F2()
        Console.WriteLine("After F2")
        Return
    Catch e As Exception
        Console.WriteLine(e.Message)
    End Try
End Sub
 
Private Sub F2()
    Console.WriteLine("Before call F3")
    Call F3()
    Console.WriteLine("After call F3")
End Sub
 
Private Sub F3()
    Dim a As Collection
    a = Nothing
    Console.WriteLine("Start of F3")
    Call a.Add("Test") ' Genera un errore, a vale Nothing
    Console.WriteLine("End of F3")
End Sub

Il risultato prodotto è identico al precedente.

La keyword Try definisce un blocco di codice, all’interno di una funzione, i cui eventuali errori sono diretti a uno dei blocchi Catch seguenti. In questo caso abbiamo un solo blocco Catch, ma i blocchi possono essere più d’uno, in funzione del tipo di errore che si intende gestire. Torneremo sulla sintassi Try/Catch più avanti.

Potremmo dire che non ci sono differenze, in realtà non è proprio così.

Il codice che fa uso di On Error Goto genera un codice molto meno efficiente, ma per un motivo molto semplice: deve prevedere la possibilità di Resume.

Pensiamo a questo codice.


    Private Sub F1Resume()
        On Error Resume Next
        Dim a, b, c, d As Int32
        a = 0
        b = 5
        c = b \ a
        d = b Mod a
    End Sub
 

Il codice realmente generato dal compilatore VB.NET è simile a questo (per semplicità di lettura vediamo un codice VB.NET equivalente, non il codice IL realmente generato).


    Private Sub F1Exception()
        Dim currentStatement As Integer
        Dim a, b, c, d As Int32
        Try
Line_1:
            currentStatement = 1
            a = 0
Line_2:
            currentStatement = 2
            b = 5
Line_3:
            currentStatement = 3
            c = b \ a
Line_4:
            currentStatement = 4
            d = b Mod a
Line_5:
            currentStatement = 5
        Catch e As Exception
            Select Case currentStatement
                Case 1 : GoTo Line_2
                Case 2 : GoTo Line_3
                Case 3 : GoTo Line_4
                Case 4 : GoTo Line_5
            End Select
End Try
End Sub

Il codice è meno efficiente rispetto a un blocco Try/Catch per la presenza di una variabile che mantiene il numero di linea corrente in modo corrispondente al sorgente VB. Da notare che tale variabile è generata, a livello di codice IL, anche quando si fa uso di un On Error Goto anziché di un On Error Resume Next, come nell’esempio appena visto. Questo spiega la minor efficienza in generale dell’approccio basto su On Error rispetto a quello basato direttamente su Try/Catch.

Nella prossima parte vedremo quali sono le linee guida per il miglior uso delle eccezioni in .NET.