Java/Gestione delle eccezioni
Introduzione
modificaUn metodo accetta zero o più parametri di ingresso, svolge operazioni, ed eventualmente restituisce un valore in uscita.
Il metodo può incappare in una situazione anomala per molte cause diverse, per esempio:
- riceve parametri sbagliati (
calcolaRadiceQuadrata(-1)
) - si verifica un imprevisto durante l'esecuzione, dipendente da fattori esterni (accesso al file system, comunicazioni di rete, errori interni della macchina virtuale, imprevisti in altri metodi, ...)
- una asserzione o un altro meccanismo rileva che il programma contiene un bug
- ...
Sono avvenimenti speciali, che compromettono la normale esecuzione di quel metodo, e nel linguaggio comune sono chiamati "errori". Tuttavia, in gergo, c'è una terminologia più precisa.
- La maggior parte di essi può essere gestita dal programma, purché, durante la scrittura del codice, il programmatore sia stato abbastanza attento da prevederli e istruire il programma per gestirli. In questo caso, si parla di eccezioni.
- In altre situazioni, viene meno la certezza che il programma possa continuare a funzionare correttamente; per il programma non è tecnicamente possibile gestirle con successo. Per esempio, problemi interni della VM o nella comunicazione con le librerie native del sistema operativo. In questo secondo caso, si parla di errori in senso stretto.
Le informazioni che caratterizzano l'evento anomalo sono raggruppate in un oggetto che prende il nome di eccezione nel primo caso, errore nel secondo.
Quando un metodo incontra una situazione anomala, può fare solo due cose: assumersi il compito di gestirla, proseguendo con l'esecuzione, oppure interrompersi e segnalarla al chiamante.
- Nel primo caso, si dice che il metodo ha catturato l'eccezione o l'errore.
- Nel secondo caso, se è il metodo che ha istanziato l'oggetto eccezione o errore, si dice che l'ha lanciata, altrimenti l'ha rilanciata.
Questa regola è chiamata handle or declare, e permette di gestire l'errore nell'ambito più specifico in cui si è verificato: spesso, lo stesso codice che l'ha prodotto dispone di elementi sufficienti per poterlo gestire. Per esempio, un metodo che scrive su file può scoprire che il file non esiste, nel qual caso può banalmente crearlo senza neanche informare il chiamante. Tuttavia, se quello stesso metodo scopre che il disco è pieno, non può assumersi la responsabilità di decidere come reagire; i metodi che l'hanno chiamato possono decidere quali contromisure prendere: per es., se il metodo è parte di una libreria per la scrittura su supporti RAID, i dati rimanenti vanno scritti su un disco diverso; se è parte di una libreria per la deframmentazione del disco, si libera spazio in un altro punto del file system; se è parte di un programma di videoscrittura, probabilmente si limiterà a segnalare il problema all'utente, chiedendogli di liberare manualmente spazio su disco.
I metodi si passano l'eccezione l'un l'altro, come una patata bollente, andando a ritroso lungo la catena delle invocazioni, finché qualcuno di questi metodi non la cattura. L'esecuzione riprende da questo metodo. Se nessun metodo è in grado di catturare l'eccezione, essa finisce alla macchina virtuale, la quale ha invocato il primo metodo sulla catena delle invocazioni (ora terminata). La VM cerca un oggetto associato al thread, chiamato uncaught exception handler, che il programmatore potrebbe aver impostato come "ultima spiaggia". Se lo trova, gli passa l'eccezione perché sia gestita, infine termina il thread.
Un esempio
modificaSi vuole implementare una calcolatrice. Si divide il programma in due parti: le classi che realizzano l'interfaccia grafica, e le classi del "motore" matematico che esegue i calcoli. A tempo di esecuzione, l'utente inserisce un'espressione matematica nell'interfaccia grafica, quest'ultima passa l'espressione al motore, che calcola e restituisce il risultato, e l'interfaccia grafica lo mostra all'utente. La UI accede al motore tramite i metodi di questa classe:
public class MathServer {
/**
* Calcola l'espressione indicata e restituisce il risultato.
*/
public int calcola(String espressione) {
...
}
}
Il metodo "promette" ai client che esso
- prende in ingresso una stringa, che rappresenta un'espressione matematica
- calcola il risultato dell'espressione
- restituisce il risultato, sotto forma di
int
.
- Si verifica un imprevisto
Supponiamo che l'utente inserisca "3 / 0
". La UI invoca MathServer.calcola("3 / 0")
. Il MathServer non può mantenere le "promesse" documentate, infatti non può calcolare il risultato. Inoltre, non può informare l'utente, perché non sa se i risultati appaiono su riga di comando, su interfaccia grafica, se transitano su connessione di rete, se sono conservati in RAM per essere usati in seguito da un programma automatico, ecc. Quindi, potrebbe non azzeccare il "canale" giusto per informare l'utente, e potrebbe addirittura non esserci un utente a cui notificare il problema!
Si potrebbe implementare un controllo nelle classi dell'interfaccia utente, affinché al MathServer non siano passate espressioni non valide anche se l'utente le inserisce, tuttavia questa soluzione è mal progettata, perché non assegna le responsabilità alle classi giuste: alle classi della UI spetta solo il compito di creare l'interfaccia utente, e il compito di controllare le espressioni matematiche spetta invece al motore matematico, che sia costituito dalla classe MathServer o da un'altra.
- Prepararsi in anticipo
Il problema è che il metodo fa delle promesse che potrebbe non essere in grado di mantenere. In particolare:
- La promessa 1 assume che la stringa sia sempre una espressione matematica formalmente corretta
- La promessa 2 assume che l'espressione sia dotata di un risultato
- La promessa 3 assume che il risultato sia un numero, e che esso sia esprimibile entro i limiti del tipo
int
.
La soluzione è fargli promettere qualcos'altro. Per esempio:
- prende in ingresso una stringa
- se la stringa non rappresenta un'espressione matematica valida, si interrompe e segnala il problema al chiamante
- prova a calcolare il risultato; se l'espressione non ha un risultato, si interrompe e segnala il problema al chiamante
- se il risultato non rientra nei limiti del tipo
int
, si interrompe e segnala il problema al chiamante - altrimenti, restituisce il risultato.
public class MathServer {
/**
* Calcola il risultato di un'espressione.
* Se la stringa non rappresenta un'espressione matematica valida, lancia una {@link java.lang.IllegalArgumentException}.
* Se l'espressione non ha un risultato, lancia una {@link java.lang.ArithmeticException}.
* Se il risultato non rientra nei limiti del tipo <code>int</code>, lancia una {@link java.lang.IllegalArgumentException}.
* altrimenti, restituisce il risultato.
*/
public int calcola(String espressione) {
...
}
}
Equivalentemente:
public class MathServer {
/**
* Calcola il risultato di un'espressione.
* @throws java.lang.IllegalArgumentException Se la stringa non rappresenta un'espressione matematica valida.
* @throws java.lang.ArithmeticException Se l'espressione non ha un risultato.
* @throws java.lang.IllegalArgumentException Se il risultato non rientra nei limiti del tipo <code>int</code>.
*/
public int calcola(String espressione) {
...
}
}
In questo caso si è sostituita la clausola "si interrompe e segnala il problema al chiamante" con "lancia una eccezione", tuttavia esistono altre tecniche.
Chi implementa un client del MathServer sa che deve predisporre il suo codice per gestire le situazioni eccezionali documentate. Che lo faccia o no, è un problema suo: le eccezioni sono documentate. D'altra parte, ora, il MathServer va implementato in modo diverso, e in particolare va istruito per lanciare le eccezioni indicate, quando opportuno.
- Prova
Eseguendo questo programma di prova...
public class TestDivisione {
public static void main(String[] args) {
int dividendo = 10;
int divisore = 0;
System.out.println("Il risultato di " + dividendo + " diviso " + divisore + " è:");
int risultato= MathServer.calcola("" + dividendo + " / " + divisore);
System.out.println(risultato);
}
}
... si otterrà un risultato come questo, sulla console:
Il risultato di 10 diviso 0 è:
Exception in thread "main" java.lang.ArithmeticException: Divisione per zero
at MathServer.calcola(MathServer.java:24)
at TestDivisione.main(TestDivisione.java:7)
Il significato dell'output è questo: il programma ha eseguito con successo il codice fino alla prima System.out.println
, ma non è mai arrivato alla seconda, in quanto è incappato in una java.lang.ArithmeticException
, il cui messaggio è "Divisione per zero", che è stata lanciata dal metodo MathServer.calcola al metodo TestDivisione.main. È indicato anche il punto esatto in cui ciascun metodo ha ricevuto l'eccezione: MathServer l'ha lanciata all'istruzione presente nella riga 24 del file MathServer.java (non riportata nel codice sopra) e TestDivisione l'ha ricevuta all'istruzione presente nella riga 7 del file TestDivisione.java, ovvero
int risultato= MathServer.calcola("" + dividendo + " / " + divisore);
Questo è esattamente il comportamento che ci si deve aspettare dal programma, in quanto la specifica del metodo MathServer.calcola() stabilisce chiaramente che deve essere così.
- Eccezioni diverse per imprevisti diversi
Nell'esempio si è considerato solo il caso in cui viene passata un'espressione non valida, ma il MathServer potrebbe incontrare altre condizioni di errore. Ad esempio, potrebbe essere stato implementato in modo da inviare la richiesta ad un server remoto apposito per svolgere i calcoli, nel qual caso potrebbe verificarsi un errore dovuto a guasti tecnici che interrompono la connessione di rete. Oppure, l'espressione potrebbe essere tecnicamente risolvibile, ma il MathServer potrebbe accorgersi che i calcoli richiedono un tempo eccessivamente lungo (per es. si chiede di fattorizzare un numero troppo grande). Questi casi potranno essere gestiti documentando eccezioni apposite.
Un altro esempio: eccezioni lanciate dalla VM
modificaLe eccezioni lanciate dalla macchia virtuale sono del tutto analoghe a quelle lanciate dal programmatore, sia per le modalità di lancio e cattura, sia per il significato che hanno per il programma. Queste eccezioni corrispondono ad un numero ristretto di casi documentati nella specifica di linguaggio. Un esempio già noto al lettore è il caso in cui si accede ad un array con un indice non valido.
Eseguendo questo programma di prova...
public class TestDivisione {
public static void main(String[] args) {
int dividendo = 10;
int divisore = 0;
System.out.println("Il risultato di " + dividendo + " diviso " + divisore + " è:");
int risultato= dividendo / divisore;
System.out.println(risultato);
}
}
... si otterrà un risultato come questo, sulla console:
Il risultato di 10 diviso 0 è:
Exception in thread "main" java.lang.ArithmeticException: / by zero
at TestDivisione.main(TestDivisione.java:7)
Il significato dell'output è questo: il programma ha eseguito con successo il codice fino alla prima System.out.println
, ma non è mai arrivato alla seconda, in quanto è incappato in una java.lang.ArithmeticException
il cui messaggio è "/ by zero", che è stata lanciata nel metodo TestDivisione.main(), in particolare nell'istruzione presente nella riga 7 del file TestDivisione.java, ovvero
int risultato= dividendo / divisore;
In effetti, la specifica di linguaggio stabilisce che l'operatore di divisione lancia una ArithmeticException se l'operando a destra (il divisore) è pari a zero.
Eccezioni
modifica- Le classi delle eccezioni
Un'eccezione è un oggetto di tipo java.lang.Throwable
che identifica il tipo di condizione anomala e le informazioni che la descrivono. Anche se è tecnicamente possibile gestire tutte le condizioni anomale di un programma con questa classe, è bene (oltre che consigliato) definire una sottoclasse specifica di Throwable per ciascun tipo di condizione anomala. La stessa gerarchia delle classi cataloga e tiene ordinata la casistica delle eccezioni che si prevede possano verificarsi in un programma. Le classi delle eccezioni non hanno nulla di intrinsecamente diverso rispetto alle altre; la differenza è nell'uso: i loro nomi possono apparire in punti speciali del codice sorgente, e, a tempo di esecuzione, le loro istanze possono essere passate come argomento a determinate istruzioni previste dal linguaggio.
In corrispondenza con la classificazione tra eccezioni ed errori, Throwable ha due sottoclassi: Exception ed Error. Exception raggruppa tutte le eccezioni, checked ed unchecked. Le seconde sono sottoclassi di RuntimeException, le prime sono sottoclassi di Exception ma non di RuntimeException.
Il nome della classe RuntimeException può trarre in inganno: tutte le eccezioni e tutti gli errori si verificano a tempo di esecuzione; la peculiarità delle eccezioni unchecked sta nel fatto che il rispetto della regola "handle or declare" viene imposto solo a run-time, invece che anche a compile-time.
È tecnicamente possibile estendere direttamente Throwable, così come istanziare direttamente alcuna delle classi fin qui elencate, però è vivamente sconsigliato.
Le classi delle eccezioni possono estendersi fra di loro. Ad esempio, java.lang.IllegalArgumentException
identifica la condizione di errore che si verifica quando un metodo riceve dei parametri in ingresso proibiti nella documentazione. java.util.IllegalFormatException
estende questa classe ed identifica la condizione specifica in cui è sbagliata la sintassi della stringa di formattazione di un messaggio tipo printf
. java.util.DuplicateFormatFlagsException
estende IllegalFormatException, ed identifica ancora più precisamente il caso in cui la stringa di formattazione contiene parametri di formattazione duplicati. E così via.
- Informazioni specifiche di una condizione anomala
Ciascuna di queste classi fornisce costruttori e metodi che permettono di parametrizzare un'eccezione con le informazioni che descrivono una determinata condizione anomala che si è verificata in un certo istante. La maggior parte delle classi nel JDK definisce solo parametri per uso generico, che nella maggior parte dei casi sono
- un breve messaggio informativo
- cause
- suppressed exceptions
In aggiunta, la macchina virtuale fornisce parametri aggiuntivi come
- lo stack trace del punto in cui l'eccezione è stata istanziata
Una classe può definire parametri specifici caratteristici di un determinato tipo di condizione anomala. Ad esempio, java.nio.file.AccessDeniedException
definisce tre parametri: due identificano il file o i file coinvolti, il terzo contiene un breve messaggio informativo.
- Lanciarle
Le eccezioni sono lanciate per volontà esplicita del programmatore o dalla JVM.
Il programmatore lancia una eccezione usando la parola-chiave throw
:
throw espressione;
dove espressione è di tipo java.lang.Throwable
o sottotipo. Tipicamente, anche se non è obbligatorio, l'eccezione viene creata appena prima di essere lanciata.
throw new ArithmeticException("Divisione per zero!");
La JVM può lanciare eccezioni al verificarsi di determinati casi stabiliti dalla specifica, e sono lanciate in assenza di una istruzione throw
.
Handle...
modificaPer gestire le eccezioni si usa la seguente sintassi[aggiornare con ARM e multi-catch clause]:
try {
// Codice che può lanciare eccezioni
} catch(TipoEccezione1 e1) {
// Exception handler per TipoEccezione1
} catch(TipoEccezione2 e2) {
// Exception handler per TipoEccezione12
} finally {
// Codice da eseguire prima dell'uscita dal try-catch-finally
}
Questa istruzione viene eseguita come di seguito:
- All'inizio, il programma entra nel blocco
try
. - Se il codice del blocco
try
lancia un'eccezione, il blocco viene interrotto; se ci sono clausolecatch
compatibili con l'eccezione, viene attivata tra di esse quella più specifica. - Infine, che sia stata lanciata un'eccezione oppure no, il codice del
finally
viene eseguito. - Se non è stata lanciata alcuna eccezione, o se l'eccezione è stata gestita, ora l'esecuzione procede dopo il
finally
. Altrimenti, se il codice fa parte di un bloccotry
esterno, questo viene interrotto e si prosegue dal punto 2; se non ci sono blocchitry
esterni, l'intero metodo è interrotto, lanciando quell'eccezione.
I blocchi catch
possono essere in numero arbitrario e contengono il codice che gestisce la situazione anomala corrispondente ad una certa eccezione. Ogni blocco catch è specifico di un determinato tipo di eccezione, che ovviamente deve essere una sottoclasse di Throwable. Quel blocco cattura tutte le eccezioni che sono istanze di quel tipo, a meno che non sia preceduto da un blocco catch più specifico, ad esempio:
catch(FileNotFoundException x) { ... }
catch(IOException x) { ... }
In questo caso, il secondo blocco cattura tutte le IOException che non sono anche FileNotFoundException, in modo che ogni eccezione sia gestita una sola volta.
I blocchi catch
possono essere in numero arbitrario. I blocchi catch
e finally
possono essere omessi singolarmente, ma almeno uno di essi deve essere presente.
Il blocco finally
contiene il codice che deve essere eseguito in ogni caso, che l'eccezione sia lanciata oppure no. Per questo motivo, è usato tipicamente per deallocare le risorse allocate nel blocco try
. Se il suo codice fosse integrato in fondo al blocco try
, sarebbe eseguito solo se nessuna eccezione viene lanciata, e in genere questo non è il risultato desiderato.
Ad esempio, il blocco try
può cercare un file sul sistema, allocando oggetti nativi del sistema operativo per interagire con il file system; il blocco catch
può essere usato per gestire il caso in cui il file non esiste; e il blocco finally
può essere usato per deallocare quelle risorse.
Ovviamente, in qualunque punto è possibile lanciare apposta una eccezione con l'istruzione throw
, sia nel try
, sia nel catch
(magari impostando il parametro cause), sia nel finally
.
... or declare
modifica/**
* @throws IllegalArgumentException Se <code>prova</code> è minore di zero.
* @throws FileNotFoundException Se il file al percorso <code>path</code> non esiste.
* @throws IOException Se si verificano problemi nell'accesso al file indicato da <code>path</code>.
*/
public void metodo(int prova, String path) throws IOException {
...
}
Un metodo dovrebbe sempre dichiarare tutte le eccezioni che si prevede esso potrà lanciare. Ciò si fa con la clausola throws
. Essa contiene obbligatoriamente tutte le eccezioni checked, e in più può contenere anche eccezioni unchecked. In ogni caso, è bene documentare ciascuna eccezione riportando una descrizione nella documentazione del metodo.
La distinzione tra eccezioni checked ed eccezioni unchecked viene fatta solo dal compilatore; al contrario, la macchina virtuale distingue solo tra oggetti che sono eccezioni e oggetti che non lo sono.
Nota: non confondere le parole-chiave throw
e throws
: la prima fa parte del corpo del metodo e lancia una eccezione, la seconda fa parte dell'intestazione del metodo (prima del corpo) e dichiara che quella eccezione può essere lanciata.
Classi personalizzate
modificaIl linguaggio permette di creare tipi di eccezioni personalizzate. Già esistono diversi tipi di eccezioni, utili nella maggioranza dei casi:
- package java.lang
NullPointerException
ArithmeticException
IllegalArgumentException
ClassCastException
IndexOutOfBoundsException
UnsupportedOperationException
- package java.util
ConcurrentModificationException
MissingResourceException
NoSuchElementException
IllegalStateException
- package java.io
IOException
e sottoclassi
Nei casi più comuni, si possono riutilizzare queste classi. Nel caso serva avere un tipo di eccezione personalizzato, si può tranquillamente definire una propria classe (eventualmente derivata da qualcuna delle classi elencate sopra o delle rispettive sottoclassi) e crearne istanze.
Nello scegliere la superclasse, è bene avvicinarsi a quella che più si avvicina concettualmente a quello che il nostro tipo di eccezione vuole rappresentare; per un client non è concettualmente la stessa cosa dover catturare una istanza di UnsupportedOperationException o di IllegalArgumentException. Inoltre, scegliere il tipo più appropriato aiuta chi sviluppa il client ad avere del codice leggibile ed intuitivo.
In caso di dubbio, ci si può "mantenere sul generico": usare Exception per le eccezioni checked e RuntimeException per quelle unchecked.
Il nuovo tipo eccezione, in genere, dovrebbe fornire almeno i quattro costruttori "standard" (senza argomenti, con argomento String, con argomento Throwable, con argomenti String e Throwable). Questa decisione, però, è lasciata al programmatore: il tipo eccezione può non definirli o definire altri costruttori che prendano in ingresso altri parametri, magari per memorizzarli in qualche campo interno e renderli accessibili tramite appositi metodi getter. In quest'ultimo caso, si ricordi che la classe Throwable discende da java.io.Serializable
.
Checked o unchecked?
modificaQuando scrivo un nuovo tipo eccezione, devo sceglierlo checked o unchecked?
La differenza generale tra eccezioni checked e eccezioni unchecked è che le prime vengono usate quando il problema corrispondente può essere previsto (ed evitato) da chi ha invocato il metodo. Ad esempio, IOException e le sue sottoclassi sono eccezioni checked, perché un problema di I/O può verificarsi senza che il programmatore sia in grado di prevedere il problema.[1] Al contrario, IllegalArgumentException è unchecked, perché chi invoca un certo metodo può passare al metodo dei parametri diversi che soddisfino le precondizioni necessarie.
Riferimenti
modifica- Specifiche e documentazione
- Capitolo 11 della specifica di linguaggio, con particolare riferimento alla sezione 11.1.1, The Kinds of Exceptions
- Gerarchia delle classi del package java.lang
- Gerarchia delle classi del package java.io
- Gerarchia delle classi del package java.util
- Guide
- The Java Tutorials, Exceptions: Unchecked Exceptions — The Controversy
Aspetti avanzati
modificaException chaining
modificaQuesta sezione è ancora vuota; aiutaci a scriverla! |
Suppressed exceptions
modificaQuesta sezione è ancora vuota; aiutaci a scriverla! |
Eccezioni e generics
modificaSottoclassi generiche di Throwable
modificaQuesta sezione è ancora vuota; aiutaci a scriverla! |
Type variables che estendono Throwable
modificaQuesta sezione è ancora vuota; aiutaci a scriverla! |
Altre tecniche
modificaLa gestione delle eccezioni è solo uno dei meccanismi possibili per controllare le condizioni di errore.
Uno stile diverso consiste nel far restituire al metodo un valore che di norma non restituisce, e che il chiamante controlla per sapere se l'operazione è andata a buon fine. Per esempio, un metodo che calcola la radice quadrata di un numero, che restituisce tipicamente un valore positivo o uguale a zero, potrebbe restituire -1
se gli viene passato come argomento un numero negativo. Questo meccanismo è comune in C e in C++.
Esso ha lo svantaggio di vincolare il client a intervallare il codice che svolge le operazioni "normali" con quello che gestisce le condizioni eccezionali. Le eccezioni sono nate appunto perché così il client separa visivamente il flusso di esecuzione del programma nei due casi, e inoltre permettono di gestire in uno stesso punto le situazioni anomale che si verificano in più righe di codice.
Tecnicamente, il linguaggio non può obbligare il programmatore a scegliere la gestione delle eccezioni piuttosto che un altro meccanismo. La scelta di una soluzione piuttosto che un'altra dipende dalle scelte di progettazione della classe. L'importante è che la scelta sia fatta in modo coerente in una stessa classe.
Note
modifica- ↑ Alcuni programmatori credono di poter evitare la gestione di questa eccezione in casi particolari; ad esempio, nel caso in cui si accede ad un file per due volte consecutive nel programma. Il programmatore parte dal presupposto che, se il file esisteva la prima volta, deve esistere anche la seconda. Tuttavia, non è detto che sia effettivamente così a run-time; e partire da un presupposto del genere corrisponde ad un grave errore.
Bibliografia
modifica- Capitolo 11 della specifica di linguaggio