È 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.
