Il problema nascosto in una catena di chiamate
Immaginiamo di cercare un libro tramite il suo codice. Il catalogo può trovare la scheda, ma può anche non trovarla. Se il metodo restituisce Libro e usa null per dire «assente», chi lo chiama deve ricordarsi di controllare il risultato. Un solo controllo dimenticato può portare a una NullPointerException in una riga lontana dalla ricerca. Nel capitolo precedente abbiamo visto che findFirst() restituisce un valore che può non esserci: è lo stesso problema, espresso attraverso un tipo della libreria standard.
Alcune funzioni non possono produrre un risultato per ogni input. Una ricerca, per esempio, può non trovare nulla: spesso si restituisce null e le chiamate successive si riempiono di if per evitare un errore. In Java, Optional<T> rappresenta un risultato che contiene un valore non nullo di tipo T oppure è vuoto. Il tipo restituito avverte chi legge la firma che l'assenza è una possibilità ordinaria del dominio. Non elimina ogni controllo: lo colloca in un punto esplicito della composizione, dove possiamo scegliere se trasformare, fornire un'alternativa, lanciare un'eccezione o non fare nulla.
Definizione – Valore assente. Un
Optional<Libro>vuoto è un oggettoOptionalesistente che descrive l'assenza di un libro. Non ènull, e non contiene un libro il cui valore sianull. La variabile stessa non dovrebbe esserenull: restituirenullda un metodo dichiaratoOptional<Libro>vanificherebbe il contratto. La documentazione Java 25 indicaOptionalsoprattutto come tipo di ritorno di un metodo quando «nessun risultato» è una risposta normale enullsarebbe fonte di errori.
La parola monade compariva nel titolo precedente. È un termine della teoria delle strutture computazionali che può aiutare in un percorso avanzato, ma qui non serve a capire la ricerca. Non attribuiamo a Optional il potere di correggere automaticamente un programma: un risultato vuoto può ancora essere ignorato, un effetto collaterale può ancora nascondersi in una funzione, e un get() sul valore vuoto può ancora lanciare un'eccezione. La proprietà concreta che ci interessa è la composizione di trasformazioni che mantengono visibile l'assenza.
Costruire i due casi
La prima distinzione da padroneggiare è fra Optional.empty(), Optional.of(valore) e Optional.ofNullable(valore). La prima forma produce un risultato vuoto. La seconda richiede un valore non nullo e lancia NullPointerException se gli passiamo null: è adatta quando il programma sa che il valore deve esserci e vuole scoprire subito la violazione dell'invariante. La terza accetta un riferimento che può essere null: se lo è, restituisce un Optional vuoto, altrimenti un Optional presente.
Nel programma completo, il metodo cerca interroga una piccola Map del catalogo. Map.get(codice) restituisce il libro associato oppure null se la chiave non esiste. Proprio al confine con questa API, il metodo converte il possibile null in un Optional<Libro>:
static Optional<Libro> cerca(String codice) {
return Optional.ofNullable(CATALOGO.get(codice));
}
Quando cerchiamo "J-01", il valore è presente; quando cerchiamo "X", il risultato è vuoto. cerca("X").isEmpty() stampa true. Si può chiedere anche isPresent(), che nel medesimo caso darebbe false. Sono domande sul risultato, non metodi per estrarre direttamente il libro. Possiamo provarle entrambe, ma se l'intero percorso diventasse una successione di if (isPresent()) seguiti da get(), torneremmo a gestire manualmente ogni ramo nel punto meno espressivo.
Nota bene – Vuoto non significa errore. Se il codice libro non compare nel catalogo, «nessun libro» può essere una risposta prevista. Se invece il catalogo è indisponibile per un guasto, restituire
Optional.empty()farebbe sembrare assente un libro che non abbiamo potuto cercare. Quel guasto richiede un altro canale, per esempio un'eccezione o un tipo di risultato che distingua assenza e fallimento. La forma del tipo deve corrispondere alle risposte reali dell'operazione.
Presenza, azione e valore finale
Quando vogliamo compiere un'azione soltanto se il valore esiste, ifPresent riceve un Consumer<T>. Nel nostro caso potremmo scrivere cerca("J-01").ifPresent(libro -> System.out.println(libro.titolo()));. Se la ricerca è vuota, la lambda non viene eseguita. ifPresentOrElse accetta una seconda azione per il caso vuoto: è utile quando entrambi i rami sono effetti, come stampare un messaggio o aggiornare un'interfaccia. Non restituisce un titolo; se dobbiamo produrre un valore per un'espressione successiva, è più adatto un metodo della famiglia orElse.
orElse("assente") restituisce il titolo presente oppure il testo di ripiego. La prima riga del programma passa da Optional<Libro> a Optional<String> con map(Libro::titolo) e poi a String con orElse("assente"): stampa Java. La chiamata non costruisce un libro fittizio quando la ricerca fallisce; sceglie soltanto quale stringa mostrare al lettore. Se il chiamante deve conservare la distinzione fra titolo vero e titolo non trovato, non deve applicare troppo presto orElse: può mantenere Optional<String> fino al confine in cui serve una risposta concreta.
Il metodo get() estrae il valore presente ma lancia NoSuchElementException se il risultato è vuoto. Una traccia d'errore ci aiuterebbe a vedere il punto del fallimento, ma non renderebbe sicura la chiamata. In Java 25 get() è ancora nell'API; la documentazione indica orElseThrow() come alternativa preferita quando vogliamo esprimere esplicitamente che l'assenza, in quel punto, è eccezionale. La differenza non è un trucco per evitare un'eccezione: orElseThrow() senza argomenti lancia anch'esso NoSuchElementException sul vuoto. Il nome fa leggere meglio la decisione. Se invece abbiamo un'alternativa valida, scegliamo quella; se non dobbiamo fare nulla, manteniamo l'assenza.
Un ripiego calcolato subito o soltanto quando serve?
Il confronto fra orElse e orElseGet merita un esperimento completo. Consideriamo Optional.of("Java"): il valore esiste, quindi entrambi i metodi restituiscono "Java". Eppure la valutazione dell'argomento è diversa. In presente.orElse(nomeDiDefault()), Java calcola nomeDiDefault() prima di entrare in orElse, perché gli argomenti di un metodo vengono valutati prima della chiamata. In presente.orElseGet(DemoOptional::nomeDiDefault), passiamo una funzione; Optional la invoca soltanto se è vuoto.
Nel programma nomeDiDefault() incrementa un contatore per rendere osservabile la chiamata. La riga default calcolati: 1 mostra che il ripiego di orElse è stato calcolato anche se non usato, mentre quello di orElseGet non è stato calcolato. Se l'alternativa fosse una costante come "sconosciuto", la forma orElse sarebbe semplice e leggibile. Se richiedesse una query, una lettura da file o un'operazione costosa, orElseGet eviterebbe lavoro inutile quando il valore è presente. L'effetto del nostro contatore serve solo alla prova: in codice applicativo una funzione chiamata come ripiego dovrebbe avere un comportamento documentato e non dipendere da un ordine di chiamata nascosto.
| Forma | Argomento passato | Quando si calcola il ripiego | Risultato con valore presente |
|---|---|---|---|
orElse(alternativa) |
un valore T |
prima della chiamata a orElse |
restituisce il valore presente |
orElseGet(fornitore) |
un Supplier<T> |
solo se l'Optional è vuoto |
restituisce il valore presente senza invocare il fornitore |
La tabella non dice che orElseGet sia sempre migliore. Se il valore alternativo è già disponibile, creare una lambda per restituirlo non aggiunge chiarezza. D'altro canto, una chiamata a un servizio remoto non diventa sicura solo perché la mettiamo in un Supplier: quando l'Optional è vuoto, l'operazione avverrà comunque e potrà fallire. Il contratto del metodo deve spiegare che cosa accade in quel caso.
Quando l'assenza deve diventare un'eccezione
Il catalogo di un negozio può rispondere tranquillamente «libro non trovato» a una ricerca pubblica. La stessa risposta può essere inaccettabile mentre prepariamo una spedizione già pagata: in quel punto il codice si aspetta che il libro esista. orElseThrow() traduce questa decisione in modo evidente. Senza argomenti lancia NoSuchElementException; con un Supplier possiamo costruire un'eccezione più pertinente al dominio. Nell'esempio completo, cerca("X").orElseThrow(() -> new LibroAssenteException("X")) produce un errore che porta con sé il codice cercato. Il fornitore dell'eccezione non viene invocato se la ricerca ha successo.
Libro libro = cerca(codice)
.orElseThrow(() -> new LibroAssenteException(codice));
Qui la variabile libro ha tipo Libro, non Optional<Libro>: dopo questa riga il flusso normale prosegue solo con un libro presente. Se chiamiamo orElseThrow subito dopo ogni ricerca, però, abbiamo perso la ragione per cui abbiamo scelto Optional: l'assenza ordinaria diventa sistematicamente un'eccezione. Il metodo appartiene ai punti in cui il contratto cambia, non a ogni riga che vuole leggere un valore.
Approfondimento – Eccezione controllata. La versione con fornitore è generica anche rispetto al tipo dell'eccezione. Possiamo fornire un'eccezione controllata e dichiararla nella firma del metodo chiamante, oppure usarne una non controllata. La scelta dipende dal contratto dell'operazione. Una ricerca fallita non obbliga di per sé a scegliere una delle due famiglie: prima si decide se è davvero un fallimento in quel contesto.
È facile confondere orElseThrow con get() perché entrambi possono estrarre il valore e fallire sul vuoto. La differenza pratica è la leggibilità della scelta; la versione con fornitore permette anche di dare al fallimento un significato specifico. Il fallimento con NoSuchElementException è utile da osservare in un esperimento: si può eseguire Optional.<Libro>empty().get() in un test isolato, osservare l'eccezione e poi sostituirlo con una gestione intenzionale. Non mettiamo quell'esperimento nel flusso normale del programma, dove interromperebbe gli esempi successivi.
Una seconda ricerca con or
Talvolta vogliamo conservare il risultato come Optional ma cercare altrove se il primo catalogo non contiene il libro. or riceve un Supplier<Optional<? extends T>>: il fornitore deve restituire un altro Optional, non un Libro nudo. Se il primo è presente, il fornitore non viene chiamato e il risultato è il primo Optional. Se è vuoto, viene usato il risultato della seconda ricerca, che può essere ancora vuoto. Non è dunque una promessa di successo; è un piano di ricerca alternativo.
Nel programma la ricerca di "R-02" nel primo catalogo è vuota; il fornitore consulta il catalogo di riserva e trova Reti. La riga di output è Reti. Se la prima ricerca trovasse già un libro, non pagheremmo il costo della seconda consultazione. Se entrambe fallissero, il successivo map(Libro::titolo).orElse("assente") renderebbe esplicito il valore da mostrare.
String titolo = cerca("R-02")
.or(() -> cercaNelCatalogoDiRiserva("R-02"))
.map(Libro::titolo)
.orElse("assente");
Il contrasto con orElseGet chiarisce i tipi. orElseGet esce da Optional e restituisce un T; or conserva l'involucro e restituisce un Optional<T>. Se la fase successiva deve ancora distinguere il caso assente, or mantiene l'informazione. Se invece occorre un valore concreto alla fine del percorso, orElseGet è adatto. Il codice diventa leggibile quando ogni operazione corrisponde a una decisione reale del dominio, non quando si sommano metodi solo per evitare un if.
Filtrare un risultato trovato
Supponiamo che il catalogo contenga il libro, ma la schermata debba mostrare soltanto volumi disponibili. filter non interroga il catalogo: riceve l'Optional già prodotto e verifica un Predicate sul valore presente. Se il risultato era vuoto, non chiama il predicato e resta vuoto. Se il risultato era presente ma il predicato restituisce false, diventa vuoto. Se restituisce true, il valore rimane presente.
Optional<Libro> disponibile = cerca("J-01")
.filter(Libro::disponibile);
La semplicità di questa forma ha un prezzo informativo: dopo filter un risultato vuoto può significare «codice inesistente» oppure «libro esistente ma indisponibile». Se il lettore dell'applicazione deve distinguere i due casi, non dobbiamo fonderli. Possiamo modellare i due esiti con un tipo dedicato o mantenere la verifica della disponibilità nel ramo in cui il libro è presente. È un esempio importante del limite di Optional: trasporta presenza o assenza, non il motivo di ogni assenza.
Il predicato va scelto per il suo significato e deve essere comprensibile al punto d'uso. filter(libro -> libro.prezzo() > 0) ha senso solo se abbiamo spiegato perché il prezzo positivo è la condizione desiderata. In generale, un predicato con effetti collaterali rende meno facile ragionare sulla composizione; soprattutto, non affidiamoci a filter per modificare il libro mentre fingiamo di limitarci a verificarlo.
Cambiare il contenuto con map
Con map trasformiamo il valore presente e lasciamo invariato il caso vuoto. Da Optional<Libro> e una funzione Libro -> String otteniamo Optional<String>. In cerca("J-01").map(Libro::titolo) la funzione viene chiamata sul libro trovato. In cerca("X").map(Libro::titolo) non viene chiamata, perché non c'è un libro a cui applicarla. Possiamo proseguire con altre trasformazioni senza introdurre un controllo a ogni passaggio:
String titoloPerSchermo = cerca("J-01")
.map(Libro::titolo)
.map(String::toUpperCase)
.orElse("NON DISPONIBILE");
La catena va letta da sinistra a destra. Prima cerchiamo un libro; se lo troviamo, ne prendiamo il titolo; se il titolo esiste, lo convertiamo in maiuscolo; infine scegliamo cosa mostrare quando l'intero percorso non ha prodotto una stringa. Il valore di ripiego compare una sola volta, alla fine, invece di essere duplicato in ogni trasformazione.
map applica internamente una logica equivalente a ofNullable al risultato della funzione: se la funzione restituisce null, la trasformazione produce Optional.empty(). È una proprietà dell'API, non un invito a restituire null senza spiegarlo. In un modello curato, un titolo obbligatorio non dovrebbe diventare null; se accade, è più utile correggere l'invariante che nascondere il problema dietro un vuoto. Quando invece la funzione interroga davvero un dato facoltativo, conviene dichiarare quella facoltatività nel suo tipo di ritorno e usare flatMap.
Schema di lettura –
map.Optional<Libro>seguito daLibro -> StringproduceOptional<String>. Il contenitore mantiene la possibilità di assenza, mentre cambia il tipo del valore eventualmente presente. La freccia descrive una funzione: riceve unLibroe restituisce unaString; non indica che il libro venga modificato.
Comporre due ricerche con flatMap
Un autore può avere un nome facoltativo. Nel nostro modello, autore(Libro) restituisce Optional<String>. Se usassimo map(DemoOptional::autore), la trasformazione avvolgerebbe un risultato già facoltativo e otterremmo Optional<Optional<String>>. Per leggere il nome dovremmo aprire due livelli: il libro potrebbe mancare e, anche se presente, il nome potrebbe mancare. flatMap applica la funzione e appiattisce il risultato in un solo Optional<String>.
Optional<String> nomeAutore = cerca("J-01")
.flatMap(DemoOptional::autore);
Seguiamo una catena di ricerche: dalla chiave troviamo forse un oggetto; dall'oggetto troviamo forse un dato; soltanto al termine decidiamo come rappresentare il vuoto. Se la ricerca del libro è vuota, autore non viene chiamato. Se il libro esiste ma il campo autore non è disponibile, autore restituisce Optional.empty() e il risultato della catena è vuoto. Se entrambi esistono, il nome viene passato al passo seguente. Nel nostro programma il nome ottenuto è Ada.
flatMap richiede che la funzione restituisca un Optional non nullo. Scrivere una funzione che restituisce null invece di Optional.empty() viola il contratto e può provocare una NullPointerException. È coerente con l'idea del metodo: il risultato deve poter dire in modo esplicito se il secondo passo ha trovato qualcosa. map e flatMap non sono varianti stilistiche intercambiabili; scegliamo guardando il tipo restituito dalla funzione che passiamo.
Approfondimento – Due assenze, un solo tipo. Appiattire non conserva automaticamente la causa.
Optional.empty()dopoflatMappuò significare che mancava il libro o che mancava il suo autore. Quando la causa conta, un sempliceOptionalnon basta. Si può mantenere la prima decisione in un ramo esplicito, oppure definire un tipo risultato che descriva i casi. La composizione è utile quando entrambi i vuoti conducono davvero alla stessa scelta finale.
Da un risultato facoltativo a uno stream
Optional.stream() produce uno stream con zero elementi quando il risultato è vuoto e con un elemento quando è presente. Questo ponte diventa utile quando abbiamo molti risultati facoltativi. Una lista di codici può generare una sequenza di Optional<Libro>; flatMap(Optional::stream) elimina i risultati vuoti e lascia scorrere i libri trovati. Nel programma, i codici "J-01", "X" e "R-02" vengono cercati prima nel catalogo principale e poi, se serve, in quello di riserva. L'output è [Java, Reti].
List<String> titoli = codici.stream()
.map(codice -> cerca(codice)
.or(() -> cercaNelCatalogoDiRiserva(codice)))
.flatMap(Optional::stream)
.map(Libro::titolo)
.toList();
Qui i due flatMap che abbiamo incontrato appartengono a tipi diversi. Quello di Optional compone una funzione che restituisce un altro Optional; quello di Stream prende, per ciascun elemento, uno stream e ne concatena gli elementi nel flusso risultante. Optional::stream è l'adattatore fra le due forme. Questo dettaglio evita l'impressione che si tratti di una formula da memorizzare: la catena rappresenta tre scelte comprensibili, cercare, ignorare le assenze e prendere i titoli.
Una stream pipeline è consumata dall'operazione terminale toList(). Prima di quella chiamata le trasformazioni sono definite ma non hanno ancora prodotto la lista. Per una sola ricerca, costruire uno stream sarebbe prolisso; per una collezione di ricerche consente di esprimere senza sentinelle la regola «mantieni soltanto i successi». Se la posizione dei codici assenti o il motivo dell'assenza è importante, non scartiamoli con flatMap(Optional::stream): conserviamo un risultato per ogni codice.
Dove conviene usare Optional
Un metodo di ricerca che può non trovare un oggetto è un buon candidato per Optional<T>. Una variabile locale che rappresenta un esito già prodotto può esserlo quando rende il flusso più chiaro. La documentazione dell'API lo presenta soprattutto come tipo di ritorno, non come sostituto universale di ogni riferimento. Un campo Optional<Libro> in un'entità, un parametro Optional<Libro> imposto a tutti i chiamanti o una collezione piena di Optional possono aggiungere un involucro dove basterebbe un contratto più diretto. Sono scelte da valutare nel contesto, non divieti assoluti.
Optional è una classe basata sul valore. Non confrontiamo due istanze con == per stabilire se descrivono lo stesso risultato e non usiamole come monitor di sincronizzazione. Per confrontare i contenuti, equals rispetta il contratto documentato. Non assumiamo che Optional.empty() sia sempre il medesimo oggetto in memoria: ciò che conta è che sia vuoto. L'API offre anche OptionalInt, OptionalLong e OptionalDouble per risultati facoltativi primitivi, evitando il boxing del valore quando quella scelta è appropriata; il modo di comporli non coincide sempre con l'API generica e va letto nei rispettivi contratti.
Proviamo infine a formulare il contratto prima di scegliere il metodo. «Il codice potrebbe non esistere» suggerisce Optional<Libro>. «Se manca nel primo catalogo, consulta il secondo» suggerisce or. «Se esiste, mostra il titolo» suggerisce map e un valore finale per la vista. «Se manca durante una spedizione già confermata, l'operazione è incoerente» suggerisce orElseThrow nel punto della spedizione. Così Optional non diventa una catena decorativa: rende esplicite le decisioni che in una sequenza di null e if resterebbero sparse.
Una difficoltà frequente emerge quando il ripiego è esso stesso assente. orElse(null) è permesso dal metodo, ma riconduce il chiamante alla necessità di gestire null; non abbiamo guadagnato chiarezza. orElseGet può a sua volta ricevere un fornitore che restituisce null, e or può restituire un Optional vuoto. Prima di scrivere la catena, chiediamoci se il punto di arrivo ammette ancora l'assenza. Se sì, manteniamo Optional e lasciamo la decisione al livello che possiede il contesto; se no, scegliamo un ripiego significativo oppure un'eccezione appropriata. Il valore di ripiego non deve fingere che la ricerca sia riuscita: una stringa come "sconosciuto" può andare bene per una vista, ma sarebbe un pessimo codice ISBN da salvare come dato reale.
| Intenzione del chiamante | Metodo di partenza | Tipo del risultato |
|---|---|---|
| Sapere se il valore esiste | isPresent, isEmpty |
boolean |
| Eseguire un'azione quando esiste | ifPresent |
void |
| Eseguire una delle due azioni | ifPresentOrElse |
void |
| Trasformare un valore presente | map |
Optional<U> |
| Comporre una ricerca facoltativa | flatMap |
Optional<U> |
| Escludere un valore che non soddisfa una regola | filter |
Optional<T> |
| Cercare un'altra fonte | or |
Optional<T> |
| Ottenere un valore o un ripiego | orElse, orElseGet |
T |
| Ottenere un valore o segnalare un errore | orElseThrow |
T |
| Inserire il risultato in una pipeline | stream |
Stream<T> |
La tabella è una guida alla lettura dei tipi, non una scorciatoia per scegliere il metodo solo dal nome. Se si parte da Optional<Libro> e si applica map(Libro::titolo), la lettera U della tabella è String. Se si applica flatMap a un metodo che restituisce Optional<String>, il risultato resta un solo Optional<String>. Questo piccolo esercizio di tipi permette di controllare la catena prima ancora di eseguirla e di individuare subito un Optional<Optional<...>> comparso per errore.
Per verificare la comprensione. Si modifichi il programma aggiungendo un libro presente senza autore e si osservi il risultato di
flatMap(DemoOptional::autore). Poi si sostituiscaorElseGetconorElseper una ricerca vuota e una presente, annotando quante volte viene calcolato il ripiego. Infine si decida se un codice inesistente e un catalogo indisponibile debbano produrre la stessa risposta. La spiegazione della scelta conta più della sintassi usata.
Una migrazione ragionata da null
Immaginiamo un metodo esistente Libro trova(String codice) che restituisce null quando la chiave non è nel catalogo. Prima di cambiare la firma chiediamoci chi lo chiama e che cosa fa con il risultato. Un chiamante usa if (libro != null) per mostrare la scheda; un altro passa subito il risultato a un metodo che non accetta null; un terzo interpreta il vuoto come un errore. Cambiare la firma in Optional<Libro> cerca(String codice) rende esplicita la possibilità di assenza, ma non rende identiche le tre decisioni. Ogni chiamante deve scegliere il proprio punto di uscita: mostrare un messaggio, richiedere un'altra fonte oppure segnalare l'incoerenza con un'eccezione.
La migrazione va fatta ai confini dell'API che conosce il significato di null. Nel nostro esempio quel confine è Map.get: Optional.ofNullable(CATALOGO.get(codice)) traduce la risposta della mappa. Non è utile disseminare Optional.ofNullable dopo ogni chiamata se il metodo precedente ha già promesso un risultato non nullo; in quel caso un null inatteso segnala un contratto rotto. Al contrario, se una libreria esterna usa null come esito ordinario, la conversione in un Optional nel nostro adapter rende il resto del codice più leggibile. La scelta richiede di conoscere il contratto della fonte, non soltanto la sintassi di ofNullable.
Un cambiamento della firma è visibile anche nei test. Prima un test poteva asserire che la ricerca assente restituisse null; dopo deve asserire che l'Optional sia vuoto. Per il caso presente può verificare che il titolo sia quello atteso, senza basarsi sull'identità dell'oggetto Optional. Un terzo test dovrebbe chiedere cosa accade quando il catalogo stesso fallisce: restituire vuoto in quel caso sarebbe una falsa risposta «codice non trovato». Una buona suite misura queste tre risposte distinte, perché sono proprio quelle che i chiamanti devono saper gestire.
Approfondimento – Assenza di un numero. Se una ricerca calcola facoltativamente un
int,Optional<Integer>può descrivere l'esito, ma comporta il boxing del valore primitivo.OptionalIntrappresenta presenza o assenza di unintsenza usareIntegercome contenuto. Ha metodi propri, per esempioempty,of,isPresent,orElseestream; non assumiamo che ogni metodo generico diOptional<T>abbia una copia identica. La scelta fra le due forme si fa per chiarezza del contratto e profilo del lavoro, non per trasformare ogni numero del programma in un involucro facoltativo.
Due casi ulteriori: email e studente
Il catalogo ci ha permesso di seguire un solo problema per tutto il capitolo. Due esempi indipendenti ci aiutano a verificare che i metodi non dipendano dal nome Libro: una email facoltativa e l'età di uno studente. Nel programma StudenteEFiltri, un piccolo record Studente(String nome, Integer eta) rende visibili le proprietà necessarie senza richiedere librerie aggiuntive. La scelta del record non cambia le regole di Optional; serve soltanto a concentrare l'attenzione sul valore che può mancare.
Nel primo caso un Optional<String> contiene un indirizzo email. filter applica un predicato costruito con una espressione regolare. Con ada@example.org il predicato restituisce true e l'Optional resta presente; con ada.example.org restituisce false e il risultato è vuoto. Il programma stampa true e false. Il predicato controlla qui una forma elementare, non certifica che la casella esista o che l'indirizzo soddisfi ogni regola possibile di consegna. Questa precisazione conta: filter applica esattamente la condizione che gli diamo, non una validazione universale nascosta nella classe Optional.
Optional<String> email = Optional.of("ada@example.org");
boolean formaAccettata = email
.filter(valore -> FORMA_EMAIL.matcher(valore).matches())
.isPresent();
Se il valore iniziale fosse vuoto, il predicato non verrebbe eseguito e isPresent() restituirebbe comunque false. Dopo questa pipeline, dunque, false può significare «non era stato fornito alcun indirizzo» oppure «l'indirizzo fornito non rispetta la forma scelta». Se la schermata deve mostrare due messaggi diversi, occorre conservare la distinzione prima del filtro. Lo stesso limite era apparso con la disponibilità del libro: una trasformazione che porta più cause al medesimo vuoto è corretta solo quando quelle cause hanno davvero lo stesso esito per il chiamante.
Nel secondo caso il campo eta è di tipo Integer e può essere nullo nel piccolo modello didattico. studente.map(Studente::eta) produce Optional<Integer>: quando il campo è presente, il nuovo Optional contiene il numero; quando il campo è nullo, il risultato è vuoto. Così passiamo da Optional<Studente> a Optional<Integer> senza perdere il significato dell'assenza. Il programma stampa eta presente: true per lo studente di vent'anni. Se cambiamo la costruzione in new Studente("Ada", null), la stampa diventa false. Non abbiamo «riparato» un'età obbligatoria mancante: abbiamo descritto un modello in cui l'età può davvero non essere nota. Se nel dominio l'età è richiesta, il record dovrebbe impedirne l'assenza invece di affidare l'errore a una pipeline.
Il metodo nomeFacoltativo(Studente) restituisce Optional<String>. Partendo da Optional<Studente>, flatMap(StudenteEFiltri::nomeFacoltativo) evita il doppio involucro e produce direttamente Optional<String>. Il programma ne estrae Ada con un ripiego dichiarato per il caso vuoto. In questo modo l'esempio dello studente mostra, con dati diversi dal catalogo, la ragione dei tipi Optional<Integer> e Optional<String> e la differenza fra map e flatMap. Il lettore può disegnare i due percorsi come trasformazioni di tipi prima di guardare l'output.
Un parametro facoltativo è sempre una buona idea?
Che cosa cambia se il valore facoltativo è un parametro del metodo che filtra gli studenti? La documentazione presenta Optional soprattutto come tipo di ritorno; un parametro Optional<Integer> obbliga ogni chiamante a costruire un involucro per dire «nessun filtro» o «filtra per questa età». Nel programma usiamo un parametro Integer eta che può essere null al confine di quel metodo, e traduciamo la scelta in una condizione esplicita: eta == null || eta.equals(studente.eta()). È un esempio locale di contratto, non una raccomandazione a diffondere null senza controllo nel resto del programma.
Se i criteri crescono, per esempio nome, età minima, sezione e disponibilità, molti parametri facoltativi diventano difficili da leggere. Un tipo CriteriRicerca con campi e regole dichiarate può essere più chiaro; un builder può permettere al chiamante di specificare soltanto i criteri desiderati. La decisione dipende dall'API e dai suoi chiamanti. La regola didattica da conservare non è «mai Optional nei parametri» come divieto assoluto, ma «usalo quando il tipo chiarisce davvero il contratto e non obbliga a un involucro superfluo». Anche qui la chiarezza della chiamata conta quanto la concisione del corpo del metodo.
Esercizio di lettura. Si prenda la lista con due studenti chiamati Ada, uno di venti e uno di ventuno anni.
filtraStudenti(lista, "Ada", 20)restituisce un solo elemento. Con il terzo argomentonullil metodo restituisce entrambi. Il lettore deve spiegare perché la condizioneeta == null || eta.equals(studente.eta())evita di dereferenziare il parametro assente e perché il confronto del nome resta necessario in entrambi i casi. Poi può decidere se questo contratto è abbastanza chiaro per l'applicazione immaginata o se merita un tipo di criteri dedicato.
Per verificare
Riprendi il catalogo e distingui tre esiti: codice trovato, codice assente e catalogo non disponibile. Scrivi quale tipo restituiresti per una ricerca riuscita o assente e dove renderesti visibile il guasto della fonte. Solo dopo scegli fra map, flatMap, or e orElseThrow per costruire una risposta al chiamante: per ogni passaggio annota il tipo del valore prima e dopo. Una prova con un codice presente e una con un codice assente devono confermare le tue previsioni; una terza prova deve impedire che il guasto venga presentato come semplice assenza. L'attività C19 permette di trasferire la stessa distinzione a un problema nuovo.