mattone
dopo mattoneLA COLLANA
IT/EN
← Guida Java

Java 25 · 12/39

12. Eccezioni e gestione degli errori

Java 25 · Guida completa · Bozza in revisione

Questa guida conserva lo stato di revisione del libro. La verifica editoriale e le prove di comprensione con lettori indipendenti sono ancora da completare.

Cerca in tutta la guida →

Quando il percorso normale si interrompe

Finora abbiamo letto gli esempi seguendo le istruzioni dall'alto verso il basso. Un programma reale incontra anche situazioni in cui un'operazione non può concludersi come previsto: un file non esiste, un dato non rispetta un vincolo, un riferimento è null. Java rappresenta molti di questi eventi con un oggetto eccezione. L'istruzione throw avvia un'uscita dal percorso corrente; il controllo risale le chiamate fino a trovare un catch adatto, oppure arriva fuori dal programma e la JVM mostra uno stack trace.

Uno stack trace elenca le chiamate attive al momento dell'errore. Per iniziare la diagnosi, leggi prima il tipo e il messaggio dell'eccezione, poi trova la prima riga che appartiene al tuo codice. Il numero di riga è un indizio riferito alla versione compilata del sorgente, non una spiegazione completa della causa. Nel capitolo 7 avevamo provocato una NullPointerException chiamando un metodo su null: il nome dell'eccezione descrive l'evento, ma occorre comunque capire da dove sia arrivato quel riferimento.

La pila vuota e il problema del valore speciale

In una prima versione della Pila, pop() restituiva 0 quando non c'erano elementi. È un espediente che sembra risolvere il problema di un metodo obbligato a restituire un int, ma introduce un'ambiguità: anche 0 è un numero valido che un chiamante può avere inserito. Se pop() restituisce 0, non sappiamo se abbiamo estratto proprio quel valore oppure se la pila era vuota. Una push() che ignori il tentativo di inserimento avrebbe il difetto complementare: quando la pila era piena, non faceva nulla e il dato andava perso senza avviso.

La versione del capitolo 8 rifiuta entrambe le operazioni impossibili con un'eccezione. Il percorso normale può così restituire qualunque intero, incluso zero; il percorso anomalo trasporta un tipo e un messaggio separati dal valore. Non è l'unico disegno possibile: potremmo offrire anche un metodo che controlla se la pila è vuota prima dell'estrazione. In presenza di chiamanti concorrenti, però, controllare e poi estrarre come due operazioni separate richiederebbe ulteriori garanzie. Qui la lezione è più generale: un valore speciale funziona soltanto se il dominio lo riserva davvero e se il contratto lo dichiara.

Le eccezioni non rendono innocuo un errore. Interrompono il flusso ordinario e lasciano al livello che possiede abbastanza informazioni la decisione su come reagire. Quando un costruttore scopre che i dati necessari non sono validi, può lanciare un'eccezione invece di consegnare un oggetto già incoerente. Quando fallisce una lettura da file, il programma può comunicare quale file e quale operazione erano coinvolti. In entrambi i casi il valore della soluzione sta nella qualità del contratto e della gestione, non nell'avere scritto throw.

Una famiglia di tipi, due obblighi diversi

Throwable è la radice dei tipi lanciabili. Due suoi rami principali sono Error ed Exception. Sotto Exception, RuntimeException raccoglie molte eccezioni che segnalano problemi come argomenti non validi o riferimenti nulli. La figura 12.1 mostra questa struttura essenziale.

Gerarchia essenziale di Throwable
Figura 12.1 – La posizione nella gerarchia determina il controllo del compilatore. Un'eccezione che estende Exception senza essere una RuntimeException è in genere checked; RuntimeException ed Error sono unchecked.

Una eccezione checked, come IOException, deve essere gestita con catch oppure dichiarata con throws da un metodo che può lasciarla propagare. Un'eccezione unchecked non ha questo obbligo di compilazione. La distinzione non dice che una sia sempre più grave dell'altra: descrive che cosa il linguaggio impone al chiamante. Gli Error rappresentano situazioni che un normale programma di solito non cerca di recuperare catturandole genericamente. La specifica Java 25 definisce la classificazione e le regole di controllo.

Per leggere la gerarchia senza imparare una lista di nomi, seguiamo quattro casi concreti. ClassNotFoundException estende Exception e non RuntimeException: è checked in un'API che cerca una classe per nome. IndexOutOfBoundsException estende RuntimeException: un indice fuori dai limiti viene segnalato durante l'esecuzione, senza obbligo di throws. NullPointerException appartiene allo stesso ramo unchecked. OutOfMemoryError è invece un Error; non diventa un'eccezione checked soltanto perché interrompe un programma. La grafia IndexOutOfBoundException manca della s finale e non è il nome del tipo standard.

Tipo dell'esempio Ramo della gerarchia Obbligo del compilatore
ClassNotFoundException Exception checked gestire o dichiarare se l'API la può lanciare
IndexOutOfBoundsException RuntimeException nessun obbligo di catch o throws
NullPointerException RuntimeException nessun obbligo di catch o throws
OutOfMemoryError Error nessun obbligo di catch o throws

La tabella non suggerisce di catturare sistematicamente le ultime tre. Un indice errato spesso richiede di correggere i limiti del ciclo o controllare un dato in ingresso. Un riferimento nullo richiede di capire perché il contratto permetteva o non permetteva quell'assenza. Un processo che ha esaurito memoria potrebbe non essere in grado di completare neppure un'operazione di recupero; prendere Error genericamente per proseguire sarebbe una promessa che il programma di solito non può mantenere. Per contro, un'eccezione checked non garantisce da sola che la gestione sia buona: un catch vuoto soddisfa la forma richiesta dal compilatore ma perde il problema.

Anche una classe definita da noi può rappresentare un errore specifico. Nel programma dei voti, VotoNonValidoException estende Exception: il metodo leggiVoto la dichiara con throws, mentre il chiamante la gestisce con catch. Per "27" otteniamo un voto; per "31" un valore fuori intervallo; per "xx" l'errore di conversione viene conservato come causa. Il nome del tipo comunica al chiamante quale operazione non è riuscita meglio di un generico Exception.

static int leggiVoto(String testo) throws VotoNonValidoException {
    try {
        int voto = Integer.parseInt(testo);
        if (voto < 0 || voto > 30) {
            throw new VotoNonValidoException("fuori intervallo: " + voto);
        }
        return voto;
    } catch (NumberFormatException causa) {
        throw new VotoNonValidoException("non è un numero: " + testo, causa);
    }
}

La chiamata a Integer.parseInt può lanciare NumberFormatException, una RuntimeException e quindi unchecked. Il nostro catch la trasforma in un errore del dominio dei voti mantenendo la causa originale. Il throw nel ramo fuori intervallo lancia invece direttamente la nostra eccezione checked. Il compilatore richiede che il metodo dichiari questa possibilità, e che chi lo chiama la tratti o la dichiari a sua volta.

throw e throws si assomigliano nella scrittura ma occupano posti diversi. throw new VotoNonValidoException(...) è un'istruzione dentro il metodo: in quel punto nasce l'uscita anomala. throws VotoNonValidoException fa parte della dichiarazione del metodo e avvisa il chiamante della possibilità di propagazione checked. La dichiarazione non lancia nulla da sola. Un metodo può dichiarare più tipi checked, ma un elenco sempre più lungo può segnalare che il confine della sua responsabilità non è chiaro.

Vediamo che cosa accade a throws quando un metodo viene ridefinito. Se la classe base promette un metodo che può lanciare una certa eccezione checked, la sottoclasse può realizzarlo senza lanciare quell'eccezione oppure dichiararne una più specifica compatibile. Non può sorprendere chi usa il tipo base aggiungendo una nuova eccezione checked più ampia che il contratto della base non prevedeva: il chiamante, compilato contro la base, non sarebbe stato obbligato a gestirla. Le eccezioni unchecked seguono regole diverse e non richiedono quella dichiarazione. È un altro esempio di sostituibilità: la firma della sottoclasse deve rispettare ciò che il chiamante era autorizzato ad aspettarsi.

Anche dentro un catch possiamo decidere di propagare. Se abbiamo soltanto aggiunto una riga di log e scriviamo throw errore;, lasciamo risalire l'oggetto catturato. In alcuni casi il compilatore riesce a determinare tipi checked più precisi di quelli apparentemente indicati dalla variabile del catch: è il cosiddetto precise rethrow. Non serve usarlo per il primo programma sulle eccezioni, ma evita una conclusione falsa: catturare con una variabile di tipo generale non obbliga sempre a dichiarare throws Exception quando rilanciamo l'eccezione senza riassegnare quella variabile. La specifica del controllo delle eccezioni definisce i limiti esatti; nel codice quotidiano preferiamo comunque firme che espongano i tipi utili al chiamante.

Nel gestore catch (VotoNonValidoException errore), il tipo fra parentesi determina quali oggetti può intercettare. Il nome errore è una variabile locale con cui leggere messaggio, causa e altri dati dell'eccezione catturata. Il catch non riprende l'esecuzione dalla riga che ha fallito: si entra nel blocco di gestione e, se questo termina normalmente, si prosegue dopo il costrutto try–catch. Le istruzioni rimaste nel blocco try dopo il punto di errore sono saltate. Per questo mettere dentro un grande try molte operazioni indipendenti può rendere difficile capire quali siano state completate.

Definizione – Causa e soppressione. La causa è l'errore precedente che una nuova eccezione conserva per spiegare da dove deriva. Un'eccezione soppressa è invece un altro errore avvenuto durante la chiusura di una risorsa mentre era già presente un errore principale. getCause() e getSuppressed() raccontano quindi due relazioni diverse; non sono due nomi per la stessa lista.

Concetto chiave – Scegliere la risposta. Un catch dovrebbe compiere un'azione sensata nel punto in cui il problema è comprensibile: mostrare un messaggio utile, riprovare se è appropriato, usare un'alternativa o aggiungere contesto e propagare. Catturare Exception per stampare una riga e continuare può lasciare il programma in uno stato errato.

Che cosa contiene un oggetto eccezione

Leggiamo i costruttori di Throwable a partire dalle informazioni che vogliamo conservare. new Exception() crea un'eccezione senza un messaggio scelto da noi; new Exception("lettura fallita") aggiunge un messaggio. La forma con un'altra eccezione come causa, new Exception("catalogo non disponibile", errore), conserva sia il contesto nuovo sia l'evento da cui siamo partiti. Esiste anche la forma che riceve soltanto la causa. Le sottoclassi non espongono necessariamente tutti i costruttori con le stesse firme: quando definiamo una nostra eccezione, scegliamo esplicitamente quali offrire e chiamiamo il costruttore appropriato della superclasse.

Il messaggio è una frase per comprendere il problema, non un campo strutturato da analizzare per prendere decisioni. Un programma che vuole distinguere «voto fuori intervallo» da «testo non numerico» può usare tipi specifici o dati propri dell'eccezione, anziché cercare parole in getMessage(). Il messaggio può essere null; inoltre può cambiare per migliorare la diagnosi senza che cambi il significato dell'errore. La causa, quando esiste, conserva la traccia originale e si ottiene con getCause(). Se la scartiamo nel momento in cui traduciamo un'eccezione di basso livello in una del dominio, chi diagnostica il problema perde una parte importante della storia.

Nota bene – Tipo, messaggio, causa e traccia. Il tipo permette al catch di selezionare ciò che sa gestire. Il messaggio spiega l'evento a una persona. La causa collega un errore a quello che lo ha provocato. Lo stack trace mostra il percorso delle chiamate attive nel punto in cui l'eccezione è stata prodotta. Nessuno di questi quattro elementi sostituisce gli altri; stamparne uno soltanto può rendere difficile la diagnosi.

Una IOException è checked: se un metodo la lascia uscire, il suo contratto deve dichiararlo con throws e il chiamante deve a sua volta gestirla o dichiararla. NumberFormatException, pur potendo essere prevista davanti a dati esterni, è unchecked: il compilatore non obbliga a citarla nella firma. La classificazione dipende dalla posizione del tipo nella gerarchia, non dal fatto che l'errore ci sembri «frequente». Un metodo che riceve input umano può comunque scegliere di intercettare la conversione e restituire un risultato più adatto all'interfaccia. Al contrario, dichiarare throws Exception per evitare di scegliere i tipi perde informazione utile per chi chiama.

Dove gestire un problema: tre livelli della stessa operazione

Supponiamo che leggiCatalogo apra un file, mostraCatalogo chiami quel metodo e main avvii il programma. Se il file non esiste, leggiCatalogo conosce il percorso tentato e può aggiungerlo al messaggio. mostraCatalogo conosce lo scopo dell'operazione e può decidere se esiste un catalogo alternativo. main conosce il contesto dell'utente e può mostrare un avviso, terminare con un esito chiaro o chiedere un nuovo percorso. Non c'è una regola che obblighi il metodo più vicino al file a catturare l'eccezione: la gestione va nel livello che possiede davvero una risposta sensata.

Se leggiCatalogo cattura IOException e restituisce una lista vuota, il chiamante potrebbe mostrare «nessun libro» anche se in realtà non è riuscito a leggere nulla. Abbiamo trasformato un guasto in un risultato valido e perso l'informazione necessaria per reagire. Può essere corretto restituire una lista vuota quando il file è stato letto e non contiene libri; è un evento diverso. Questa distinzione aiuta a decidere se usare un valore di ritorno, un'eccezione checked o un'eccezione unchecked. La scelta non nasce dal desiderio di scrivere meno catch, ma dal significato che il chiamante deve poter attribuire all'esito.

Un metodo che dichiara throws IOException non si assume che l'errore avverrà sempre: dichiara una possibilità del suo contratto. Un chiamante può catturarla e scegliere un'alternativa; un altro metodo può dichiararla a sua volta e lasciare la decisione al proprio chiamante. Ogni propagazione conserva la stessa eccezione finché non la si intercetta o si lancia un nuovo oggetto con una causa. Quando si aggiunge contesto, un messaggio come "Impossibile leggere catalogo.txt" aiuta più di "Errore", ma non deve contenere credenziali o dati che non dovrebbero finire nei log.

Per capire – throw non è return. return completa normalmente un metodo consegnando il valore dichiarato; throw interrompe il percorso normale e cerca un gestore. Dopo throw, le istruzioni successive dello stesso blocco non vengono eseguite lungo quel percorso. Un catch compatibile può gestire l'evento più in alto nella catena; se non c'è, l'errore arriva fuori dal punto di ingresso e il runtime lo segnala. È questo movimento fra livelli, e non il solo nome dell'oggetto, che chiamiamo propagazione.

Seguire la pila delle chiamate

Torniamo alla classe Pila del capitolo 8, senza crearne una nuova versione. estrai() lancia IllegalStateException con il messaggio pila vuota quando non c'è un elemento da restituire. Nel programma sulla propagazione, main chiama mostra, che chiama leggi, che infine chiama estrai. Nessuno dei due metodi intermedi ha un catch: l'eccezione risale fino al gestore di main.

static int leggi(Pila pila) {
    return pila.estrai();
}

static int mostra(Pila pila) {
    return leggi(pila);
}

La figura 12.2 segue l'eccezione dal punto in cui nasce al gestore. Non mostra una pila di numeri come quella del capitolo 8: mostra la pila delle chiamate, cioè i metodi ancora attivi nel momento dell'errore. Sono due usi della parola pila, collegati dall'ordine in cui le chiamate entrano ed escono, ma non sono lo stesso oggetto del programma.

Propagazione di un'eccezione attraverso le chiamate
Figura 12.2 – L'eccezione parte da Pila.estrai() e viene intercettata nel main. leggi e mostra lasciano risalire l'errore perché non hanno un gestore adatto.

Il programma stampa pila vuota, poi i nomi estrai, leggi, mostra e main. Per ottenere questi nomi legge i StackTraceElement dell'eccezione, oggetti che descrivono le singole chiamate. Nella stampa ordinaria di uno stack trace comparirebbero anche file e numeri di riga; il nostro esempio mostra solo i nomi, così l'output resta stabile se spostiamo il codice nel file. Per riprodurlo, compila insieme Pila.java e DemoPropagazione.java con javac --release 25 -Xlint:all -d build Pila.java DemoPropagazione.java da una cartella in cui hai copiato i due sorgenti, poi avvia java -cp build DemoPropagazione.

Nota bene – Propagazione non significa soluzione. Togliere un catch da leggi non corregge la pila vuota: permette soltanto a un livello chiamante di decidere che cosa fare. Nel nostro main la catturiamo per osservare la traccia; in un'applicazione il contratto dovrebbe spiegare quando l'estrazione è lecita e quale risposta è appropriata se la pila è vuota.

Leggere una traccia partendo dal punto giusto

Se un'eccezione nasce in Pila.estrai(), la prima riga applicativa dello stack trace indica proprio quel metodo; le righe successive mostrano leggi, mostra e main nell'ordine inverso alle chiamate fatte per arrivarci. Non leggere la traccia come un elenco di istruzioni eseguite dall'alto verso il basso: è una fotografia delle chiamate ancora attive al momento del problema. La riga più in alto individua dove l'eccezione è stata costruita o lanciata; la causa logica può essere più indietro, per esempio nel codice che ha chiamato estrai senza aver inserito nulla.

Alcuni errori ricorrenti meritano nomi precisi. NullPointerException può nascere quando si dereferenzia null; ArrayIndexOutOfBoundsException quando si accede a un indice fuori dall'array; ArithmeticException per una divisione intera per zero. La divisione in virgola mobile per zero segue regole diverse e può produrre infinito o NaN. ClassNotFoundException è checked in API che cercano una classe per nome; OutOfMemoryError appartiene al ramo Error, e intercettarlo come un normale caso di recupero non rende affidabile un processo che ha esaurito memoria. Il tipo restringe la diagnosi, ma nessuno di questi nomi spiega da solo perché il programma ha raggiunto quella situazione.

Per una verifica rapida, puoi chiedere a un'eccezione getMessage(), getCause() e getStackTrace(). Il messaggio può essere assente; la causa può essere null; gli elementi della traccia possono includere codice della libreria e del runtime. In un'applicazione reale spesso è meglio registrare l'eccezione completa con uno strumento di logging, conservando le cause, invece di stampare soltanto errore.getMessage() e perdere il percorso che ha condotto al guasto.

Ritrovare il caso dell'array nullo

Seguiamo main, metodo1 e metodo2: il primo passa un array nullo al secondo, che lo passa al terzo, dove un accesso all'elemento zero causa NullPointerException. La stessa catena compare nel sorgente DemoTracciaNull.java, ma stampiamo soltanto tipo e nomi dei metodi, così l'output non dipende dai numeri di riga del file.

public class DemoTracciaNull {
    static void metodo1(int[] a) {
        metodo2(a);
    }

    static void metodo2(int[] b) {
        System.out.println(b[0]);
    }

    public static void main(String[] args) {
        try {
            metodo1(null);
        } catch (NullPointerException errore) {
            System.out.println(errore.getClass().getSimpleName());
            for (StackTraceElement elemento : errore.getStackTrace()) {
                System.out.println(elemento.getMethodName());
            }
        }
    }
}

Le righe sono NullPointerException, metodo2, metodo1, main. La chiamata è avvenuta nell'ordine main → metodo1 → metodo2, mentre la traccia elenca per primo il punto del problema e poi i chiamanti. Questa inversione non è un dettaglio grafico: spiega perché il metodo in cima alla traccia è spesso il posto migliore in cui osservare l'operazione fallita, ma il valore nullo può avere origine più in basso nella storia delle chiamate. In questo esempio lo abbiamo passato deliberatamente da main; in un programma reale potrebbe provenire da un file, da un parametro o da un risultato non controllato.

I JDK moderni possono offrire messaggi di NullPointerException più precisi, indicando l'operazione tentata su un riferimento nullo. Il testo esatto del messaggio non è un'API da analizzare per prendere decisioni, e può dipendere dal contesto. Il nostro programma lo evita nell'output atteso, ma durante la diagnosi va letto: spesso aiuta a riconoscere quale riferimento era nullo. La soluzione non è catturare ogni NullPointerException e continuare; è capire se il contratto ammetteva null e correggere la sorgente del valore o il controllo mancante.

Tre esiti dello stesso calcolo

Prima di occuparci della chiusura delle risorse, osserviamo un programma in cui il percorso normale e due errori diversi partono dalla stessa operazione. prova riceve due stringhe, le converte in interi e divide il primo numero per il secondo. L'esempio è completo nel sorgente DemoDivisione.java.

public class DemoDivisione {
    static void prova(String primo, String secondo) {
        try {
            int a = Integer.parseInt(primo);
            int b = Integer.parseInt(secondo);
            System.out.println("risultato: " + (a / b));
        } catch (NumberFormatException errore) {
            System.out.println("serve un numero intero");
        } catch (ArithmeticException errore) {
            System.out.println("non si divide per zero");
        } finally {
            System.out.println("prova terminata");
        }
    }

    public static void main(String[] args) {
        prova("8", "2");
        prova("otto", "2");
        prova("8", "0");
    }
}

La prima chiamata stampa risultato: 4 e poi prova terminata. Nella seconda, Integer.parseInt("otto") lancia NumberFormatException: non si arriva alla divisione, si stampa serve un numero intero e quindi prova terminata. Nella terza, la conversione riesce ma la divisione intera per zero lancia ArithmeticException: si stampa non si divide per zero e infine prova terminata. In tutto vediamo sei righe, due per ogni chiamata. Il blocco finally viene eseguito anche dopo un catch; non sostituisce il gestore e non rende valido un dato errato.

I due catch sono separati perché il programma vuole dare messaggi diversi. Se la risposta fosse davvero identica, potremmo scrivere catch (NumberFormatException | ArithmeticException errore) e un solo corpo. Non potremmo invece mettere nello stesso multi-catch due tipi dei quali uno è sottotipo dell'altro: il ramo più specifico sarebbe già coperto dall'altro. Né avrebbe senso catturare genericamente Throwable per nascondere ogni problema. Il gestore si sceglie in base al tipo dell'eccezione che arriva dal try; se più gestori separati sono applicabili, l'ordine deve lasciare prima quello più specifico.

Vediamo la forma completa nel programma DemoMultiCatch. Stavolta le due anomalie hanno una risposta comune: l'operazione richiesta non può produrre un quoziente, quindi il metodo segnala che il tentativo è stato rifiutato. Il nome del tipo resta nella stampa per capire quale controllo ha fermato il calcolo; non usiamo quel nome come messaggio destinato all'utente finale.

public class DemoMultiCatch {
    static void calcola(String dividendo, String divisore) {
        try {
            int primo = Integer.parseInt(dividendo);
            int secondo = Integer.parseInt(divisore);
            System.out.println("quoziente: " + primo / secondo);
        } catch (NumberFormatException | ArithmeticException errore) {
            System.out.println("rifiutato: " + errore.getClass().getSimpleName());
        }
    }

    public static void main(String[] args) {
        calcola("12", "3");
        calcola("dodici", "3");
        calcola("12", "0");
    }
}

Prima di eseguirlo, percorri le tre chiamate. La prima passa entrambe le conversioni e stampa quoziente: 4. La seconda si ferma già su Integer.parseInt("dodici") e stampa rifiutato: NumberFormatException; la divisione non viene tentata. La terza converte i due valori, ma il divisore zero provoca ArithmeticException, perciò stampa rifiutato: ArithmeticException. La risposta comune non cancella la distinzione fra i due eventi. Se volessimo chiedere all'utente di correggere un numero oppure vietare esplicitamente lo zero, torneremmo ai due gestori separati del programma precedente, perché le risposte sarebbero diverse.

Il multi-catch non significa «cattura tutte le eccezioni»: nomina soltanto i tipi elencati. Per esempio, il calcolo potrebbe ricevere il testo "8" e "0": ArithmeticException è gestita; potrebbe anche contenere un errore di programmazione in un'altra riga che produce NullPointerException, e il multi-catch mostrato non lo intercetterebbe. Questo è desiderabile quando la risposta «input non valido» sarebbe fuorviante per un difetto diverso. La precisione del gestore protegge il significato dell'errore. Se più tipi condividono davvero una risposta, il blocco comune evita duplicazione; se richiedono messaggi o recuperi diversi, due catch separati raccontano meglio il programma.

Consideriamo poi l'ordine di due gestori ipotetici: catch (RuntimeException e) seguito da catch (NumberFormatException e). Il secondo non potrebbe mai ricevere una NumberFormatException, perché il primo la catturerebbe già: è una sottoclasse di RuntimeException. Il compilatore segnala il gestore irraggiungibile. Invertendo l'ordine, il caso specifico può essere trattato prima e il caso generale resta per altri errori runtime. Anche quando il codice compila, però, un gestore ampio può nascondere difetti inattesi. La domanda da porre non è «quanti tipi posso mettere nel catch?», ma «per quali di questi eventi so davvero scegliere una risposta?».

Il finally del programma serve a mostrare il flusso. Immagina di aggiungere una riga System.out.println("dopo") dopo l'intero try–catch–finally: in questi tre casi verrebbe eseguita dopo prova terminata, perché ogni errore è stato gestito. Se togliessimo il catch adatto, il finally sarebbe comunque attraversato, ma l'eccezione continuerebbe a propagarsi e la riga dopo non verrebbe raggiunta da quel percorso. La differenza fra «esegui un'azione finale» e «risolvi l'errore» è il cuore del costrutto. Un return nel finally renderebbe il percorso ancora più difficile da seguire e potrebbe nascondere un'eccezione già in viaggio.

Per capire l'ordine. Immagina di dichiarare int risultato; prima del try, assegnargli a / b dentro il try e stamparlo dopo il try–catch. Il compilatore segnalerà che risultato potrebbe non essere stato inizializzato: uno dei due percorsi di errore salta infatti l'assegnazione. Disegnare i tre percorsi prima di spostare le istruzioni aiuta a distinguere il flusso normale da quello eccezionale.

Chiudere una risorsa anche quando qualcosa fallisce

Una risorsa come un file o una connessione va rilasciata quando il suo uso termina. Il costrutto try-with-resources chiama automaticamente close() sugli oggetti dichiarati nella parentesi del try che implementano AutoCloseable. Non è un sostituto della comprensione degli errori: rende affidabile il passaggio di chiusura anche quando il corpo lancia un'eccezione.

Nel programma completo usiamo una risorsa didattica che stampa chiusura e poi lancia un'IOException. Anche il corpo del try lancia un'IOException. È una situazione costruita apposta per vedere quale errore resta principale.

try (Risorsa risorsa = new Risorsa()) {
    risorsa.usa();
    throw new IOException("errore durante il lavoro");
} catch (IOException errore) {
    System.out.println("principale: " + errore.getMessage());
    for (Throwable secondaria : errore.getSuppressed()) {
        System.out.println("secondaria: " + secondaria.getMessage());
    }
}

L'output, nell'ordine, è lavoro, chiusura, principale: errore durante il lavoro e secondaria: errore durante la chiusura. L'errore nel corpo resta quello principale; l'errore di chiusura viene conservato fra le eccezioni soppresse, accessibili con getSuppressed(). Non viene perso. Se il corpo terminasse normalmente e fallisse soltanto close(), sarebbe invece l'errore di chiusura a essere propagato. La documentazione di AutoCloseable spiega il contratto della chiusura.

La variabile risorsa rende evidente quale oggetto sarà chiuso. Se ne dichiariamo più di uno nella parentesi, Java li chiude in ordine inverso rispetto alla loro creazione. In un'applicazione reale il metodo close() deve fare il possibile per rilasciare ciò che possiede anche quando segnala un problema; il nostro esempio lancia sempre soltanto per rendere osservabile la regola delle eccezioni soppresse.

Con due risorse, il percorso si può seguire come una piccola pila. Se scriviamo try (Risorsa prima = ...; Risorsa seconda = ...), seconda viene chiusa prima di prima. Se il corpo e entrambe le chiusure falliscono, l'errore del corpo resta principale e gli errori di chiusura vengono conservati come soppressi, nell'ordine in cui emergono. Se il corpo termina normalmente, un errore di chiusura può diventare l'eccezione principale. Questa regola impedisce che un problema secondario durante la pulizia cancelli il problema che ha interrotto il lavoro. La struttura delle eccezioni non è però una scusa per ignorare il rilascio delle risorse: nel codice applicativo bisogna ancora decidere quali dati e quali file sono validi dopo un fallimento.

Una risorsa dichiarata nella parentesi deve essere compatibile con AutoCloseable, cioè offrire l'operazione close() prevista dall'interfaccia. Il costrutto governa la chiusura; non sa se il file che abbiamo scritto contiene dati corretti, né annulla le modifiche già compiute dal corpo del try. Se il programma deve mantenere un'operazione atomica, occorre una strategia ulteriore, per esempio scrivere in un file temporaneo e sostituire quello finale soltanto dopo il successo. Il confine fra gestione della risorsa e consistenza dei dati merita di essere nominato: altrimenti «chiuso automaticamente» sembra promettere più di quanto faccia.

Il vecchio stile con un finally che chiama manualmente close() obbliga a considerare varie situazioni: la risorsa potrebbe non essere stata aperta, il lavoro potrebbe fallire, e la chiusura stessa potrebbe lanciare un'altra eccezione. Un finally scritto ingenuamente può sostituire l'errore del lavoro con quello della chiusura, facendo perdere la causa che ci interessava per prima. Il try-with-resources rende esplicita la proprietà della risorsa e conserva l'errore di chiusura fra i soppressi quando esiste già un errore principale. Non impedisce di usare un finally per altre attività di completamento; distingue semplicemente ciò che il linguaggio può gestire in modo strutturato dalla logica specifica dell'applicazione.

Per verificare – Due fallimenti, due relazioni. Nel nostro DemoRisorse, l'eccezione di chiusura compare in getSuppressed() dell'errore principale. Se invece un metodo catturasse un errore di lettura e lanciasse una nuova eccezione «catalogo non disponibile», l'errore iniziale dovrebbe comparire in getCause() della nuova eccezione. Disegna due frecce diverse: errore principale → errore di chiusura soppresso e nuova spiegazione → causa originale. Chiamare entrambi «errore precedente» impedisce di leggere correttamente una diagnosi reale.

La distinzione conta anche quando stampiamo la traccia completa: le cause e le eccezioni soppresse possono comparire con intestazioni diverse e con parti comuni della pila abbreviate. Non è una perdita casuale di righe; la presentazione cerca di evitare di ripetere le stesse chiamate. Prima di concludere che una risorsa non sia stata chiusa o che una causa manchi, consulta l'oggetto dell'eccezione e le sue relazioni esplicite. Il semplice messaggio principale, isolato dal resto, non racconta necessariamente tutta la sequenza del fallimento.

Un blocco finally viene eseguito quando si lascia il corrispondente try, sia che il corpo termini normalmente sia che un'eccezione venga lanciata, salvo eventi che interrompono l'esecuzione della JVM. È utile per operazioni di completamento che non sono risorse AutoCloseable; per queste ultime, try-with-resources rende più chiaro anche l'ordine di chiusura e la conservazione delle eccezioni soppresse. Evita di introdurre un nuovo return o un nuovo throw in finally senza comprenderne l'effetto: può sostituire il risultato o l'eccezione precedente.

Quando due tipi di errore indipendenti richiedono la stessa risposta, Java consente un multi-catch, per esempio catch (IOException | NumberFormatException errore). Il gestore deve avere senso per entrambi. L'ordine dei catch separati conta: un tipo più specifico deve essere intercettato prima di una sua superclasse, altrimenti il ramo specifico non sarebbe raggiungibile. Queste scelte si applicano a errori reali del metodo; non occorre avvolgere ogni riga in un try.

Propagare senza perdere la causa

Un metodo può aggiungere contesto a un errore prima di lasciarlo risalire. Per esempio, quando la lettura di un file fallisce, il livello che conosce il nome del documento può lanciare una nuova eccezione con un messaggio che lo includa, conservando l'eccezione originale come causa. Così il chiamante legge il significato dell'operazione fallita e può ancora risalire all'errore iniziale con getCause().

throw new IllegalStateException("Impossibile caricare il catalogo", errore);

Questa riga è un frammento da collocare in un contesto in cui errore sia stato catturato. Non trasformiamo però ogni errore in una RuntimeException solo per evitare throws: se il chiamante può reagire all'evento, la scelta fra gestione e propagazione fa parte del contratto del metodo. Per un argomento che viola un vincolo immediato, come il voto 31 del capitolo 8, IllegalArgumentException comunica invece che il chiamante ha passato un valore non ammesso.

Nota bene – Non usare eccezioni per il percorso ordinario. Una ricerca che può non trovare un elemento dovrebbe esprimere quel risultato nel proprio contratto, anziché lanciare un'eccezione a ogni mancata corrispondenza. Le eccezioni servono a segnalare percorsi che richiedono attenzione e gestione. La scelta concreta dipende dall'API e dal dominio, non dal solo fatto che try e catch siano disponibili.

Prima di scrivere un catch, formulare la risposta

Se un utente inserisce un voto testuale non valido, possiamo chiedergli di correggerlo. Se il programma legge un file che non esiste, possiamo permettere di scegliere un altro percorso. Se una pila è piena, possiamo rifiutare il nuovo elemento e mostrare la capacità raggiunta. Queste tre risposte appartengono a contesti diversi e richiedono informazioni diverse. Scrivere catch (Exception errore) { System.out.println("Errore"); } per tutti i casi cancella proprio le informazioni che il meccanismo delle eccezioni era stato progettato per trasportare.

Un buon esercizio consiste nel leggere ogni gestore e completare la frase: «Dopo questo blocco, il programma può proseguire perché…». Nel DemoDivisione, il metodo prova termina dopo aver spiegato il risultato o l'input errato; non consegna al resto dell'applicazione un numero inventato. In DemoRisorse, il gestore mostra sia l'errore principale sia quello di chiusura, poi l'esempio termina. Nel caso della pila checked, il gestore sa quale valore è stato rifiutato e può decidere se comunicarlo o proporre una pila più capiente. Se non riusciamo a completare la frase senza fingere che un'operazione sia riuscita, quel livello probabilmente deve aggiungere contesto e propagare, oppure lasciare risalire l'eccezione.

Le eccezioni possono nascere anche in un costruttore. Il Cliente del capitolo 8 rifiuta nome e cognome vuoti; se il costruttore lancia IllegalArgumentException, il chiamante non ottiene un cliente completato. In una lambda passata a un'API funzionale o in un'attività concorrente, l'errore segue inoltre il contratto dell'interfaccia o del meccanismo che esegue il lavoro: non è corretto supporre che arrivi sempre nel catch posto attorno alla riga che ha avviato il task. Quando incontreremo lambda e concorrenza torneremo su quei confini con esempi completi. Qui fissiamo il principio generale: ogni passaggio di controllo va letto nel contesto di chi esegue davvero l'operazione che può fallire.

Progettare una nostra eccezione senza perdere il dominio

VotoNonValidoException racconta meglio di una generica Exception quale operazione è fallita. Il suo costruttore che riceve un messaggio serve quando il voto numerico è fuori intervallo; quello che riceve anche una causa serve quando la conversione del testo è fallita. Il chiamante può trattare entrambi come mancata acquisizione di un voto, mentre chi diagnostica il programma conserva la differenza fra i due percorsi. Se il chiamante ha bisogno di distinguere automaticamente i casi, possiamo aggiungere alla classe un dato strutturato, per esempio un codice o una categoria, invece di obbligarlo a interpretare la frase del messaggio.

La scelta fra estendere Exception e RuntimeException non è una graduatoria morale della gravità. Con Exception chiediamo ai chiamanti di considerare esplicitamente il percorso di errore nella compilazione. Con RuntimeException non imponiamo quel vincolo sintattico, utile per violazioni di precondizioni che indicano un difetto nel codice del chiamante. Nel nostro esempio l'input testuale può essere sbagliato senza che il programma sia scritto male; la versione checked rende visibile questa possibilità nella firma. In un'altra API si potrebbe scegliere un risultato che rappresenta successo o fallimento. Qualunque scelta facciamo, documentiamo il comportamento e lo proviamo con input validi, valori fuori intervallo e testo illeggibile.

Una pila che dichiara l'errore nella firma

La Pila del capitolo 8 segnala la condizione di pieno con IllegalStateException, che è unchecked. Possiamo costruirne una versione con una eccezione personalizzata checked capace di trasportare la capacità massima. Le due scelte sono possibili, ma i loro contratti per il chiamante sono diversi. Il programma DemoPilaChecked mette a confronto le due API dello stesso concetto e rende esplicita la differenza rispetto alla Pila già usata. Questa seconda pila ha capacità due, prova a inserire tre numeri e conserva la dimensione corretta dopo il rifiuto dell'ultimo.

public class DemoPilaChecked {
    static final class PilaPienaException extends Exception {
        private static final long serialVersionUID = 1L;
        private final int capacita;
        private final int valoreRifiutato;

        PilaPienaException(int capacita, int valoreRifiutato) {
            super("pila piena");
            this.capacita = capacita;
            this.valoreRifiutato = valoreRifiutato;
        }

        int capacita() {
            return capacita;
        }

        int valoreRifiutato() {
            return valoreRifiutato;
        }
    }

    static final class PilaControllata {
        private final int[] elementi;
        private int dimensione;

        PilaControllata(int capacita) {
            if (capacita <= 0) {
                throw new IllegalArgumentException("capacita non positiva");
            }
            elementi = new int[capacita];
        }

        void inserisci(int valore) throws PilaPienaException {
            if (dimensione == elementi.length) {
                throw new PilaPienaException(elementi.length, valore);
            }
            elementi[dimensione++] = valore;
        }

        int dimensione() {
            return dimensione;
        }
    }

    public static void main(String[] args) {
        PilaControllata pila = new PilaControllata(2);
        try {
            pila.inserisci(7);
            pila.inserisci(12);
            pila.inserisci(99);
        } catch (PilaPienaException errore) {
            System.out.println(errore.getMessage() + ": " + errore.capacita());
            System.out.println("rifiutato: " + errore.valoreRifiutato());
        }
        System.out.println("dimensione: " + pila.dimensione());
    }
}

L'output è pila piena: 2, rifiutato: 99, dimensione: 2. I campi dell'eccezione sono dati strutturati: il chiamante può leggere la capacità e il valore rifiutato senza analizzare la frase pila piena. Il metodo inserisci dichiara throws PilaPienaException e chi lo chiama deve gestire o propagare questa possibilità. Il controllo dimensione == elementi.length avviene prima della scrittura: così il terzo valore non viene perso sotto una falsa stampa di successo e non porta la dimensione a tre. L'esempio storico voleva insegnare proprio questo passaggio dal silenzio a un contratto osservabile; qui lo mostriamo senza far credere che ogni pila debba obbligatoriamente scegliere un'eccezione checked.

Il file contiene l'eccezione e la pila come classi annidate per rendere autosufficiente la prova. La parola static sulle due classi annidate dice che le loro istanze non hanno bisogno di un'istanza della classe DemoPilaChecked che le circonda. serialVersionUID serve a evitare un warning sulla serializzazione ereditata da Exception; non è il dato che rende checked l'eccezione. La proprietà checked deriva dal fatto che estende Exception senza estendere RuntimeException. Se togli il catch dal main senza aggiungere un throws adatto, il compilatore rifiuta il programma. È una prova negativa utile, distinta dalla normale esecuzione dell'esempio.

Per verificare

Compila il sorgente completo con javac --release 25 -Xlint:all -d build DemoRisorse.java ed esegui java -cp build DemoRisorse. Prima della prova, scrivi l'ordine delle quattro righe. Poi rimuovi soltanto il throw dentro il corpo del try: prevedi quale messaggio raggiungerà il catch e quante eccezioni soppresse troverai. L'attività C12 include questa diagnosi e una lettura guidata dello stack trace.

Prova gli esempi

Per eseguire i programmi serve JDK 25. Puoi scaricare i singoli file Java collegati nel capitolo oppure il progetto completo, che contiene istruzioni e uno script di avvio. Le spiegazioni confrontano anche l’output atteso: prevedilo prima di eseguire il programma.

Massimiliano Tarquini · CC BY-NC 4.0

Torna all’inizio ↑