mattone
dopo mattoneLA COLLANA
IT/EN
← Guida Java

Java 25 · 17/39

17. Scegliere una collezione

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 →

Prima la domanda, poi la classe

La pila dei capitoli precedenti ci ha aiutato a capire come si costruisce una struttura dati. In un'applicazione Java, però, quasi sempre useremo le collezioni della libreria standard. Il Java Collections Framework raccoglie interfacce, implementazioni e algoritmi per gestire gruppi di oggetti. La sua ampiezza può invitare a memorizzare un catalogo di classi; è più utile partire dalle domande del problema. I duplicati sono ammessi? L'ordine conta? Accediamo per posizione, per chiave o da un'estremità? La collezione deve cambiare dopo la creazione?

Insieme, lista, pila, coda, deque e mappa rispondono a domande diverse. Prima di scegliere una classe concreta, correggiamo due scorciatoie frequenti. Una lista Java non è necessariamente una catena di nodi: ArrayList usa un array ridimensionabile, LinkedList usa collegamenti. Una Map appartiene al framework ma non estende Collection: associa chiavi a valori invece di contenere semplicemente elementi. La figura 17.1 confronta le due famiglie senza suggerire una falsa gerarchia unica.

Famiglie principali del Collections Framework
Figura 17.1 – Collection e Map rispondono a esigenze diverse. Le interfacce dichiarano il contratto; classi come ArrayList, LinkedHashSet e LinkedHashMap sono scelte concrete con proprietà proprie.

Definizione – Interfaccia e implementazione. List<String> è un tipo di riferimento che promette operazioni di lista. new ArrayList<String>() crea un'implementazione concreta. Dichiarare una variabile con l'interfaccia rende visibile il contratto richiesto; scegliere la classe alla creazione stabilisce proprietà come costo delle operazioni e gestione dell'ordine.

Dalla pila costruita a una famiglia di soluzioni

Ripensiamo alla pila realizzata nei capitoli 7 e 8. Era utile perché ci costringeva a stabilire una regola: l'ultimo elemento inserito è il primo a uscire. Avevamo dovuto scegliere come memorizzare i dati, come riconoscere una pila piena e che cosa fare con una pila vuota. Ora il problema cambia. La biblioteca deve gestire non una sola struttura, ma titoli in sequenza, codici senza ripetizioni, prestiti in attesa e libri ricercabili per codice. Scrivere ogni volta una nuova classe significherebbe ripetere molto lavoro e moltiplicare gli errori. Il framework offre contratti comuni e implementazioni già pronte; a noi resta la responsabilità di scegliere il contratto adatto al problema.

Storicamente Java aveva già Vector, Stack e Hashtable, prima che il framework organizzasse le collezioni attorno a interfacce comuni. Da Java 5 i generics permettono inoltre di esprimere il tipo degli elementi, per esempio List<Libro>, senza affidare ogni lettura a un cast da Object. Incontreremo ancora quelle classi nei programmi esistenti, ma non dobbiamo usarle automaticamente per il codice nuovo. Il passaggio importante non è ricordare una data: è capire perché una famiglia di interfacce facilita il cambio di implementazione quando il bisogno resta lo stesso.

Considera List<String> titoli = new ArrayList<>();. La parte a sinistra dice che il codice che usa titoli chiede una sequenza indicizzata di stringhe; la parte a destra sceglie una rappresentazione con array ridimensionabile. Se domani cambiamo rappresentazione, possiamo farlo soltanto se il codice non dipende da proprietà specifiche di ArrayList. Dichiarare l'interfaccia non rende tutte le implementazioni equivalenti: ordine, mutabilità, ammissione di null, costo delle operazioni e comportamento concorrente restano scelte da verificare.

Per capire – Tre pezzi del framework. Le interfacce definiscono il comportamento richiesto: List consente accesso per posizione, Set non ammette duplicati, Map associa chiavi e valori. Le implementazioni conservano i dati e realizzano quel comportamento: ArrayList, HashSet o HashMap, per esempio. Gli algoritmi lavorano sulle collezioni, come l'ordinamento di una lista tramite Collections.sort. Separare queste tre parti ci permette di chiederci prima che cosa deve fare la struttura, poi come conviene realizzarla.

Le operazioni comuni prima delle classi concrete

Collection<E> estende Iterable<E> ed è la radice della famiglia che comprende liste, insiemi e code. Per una collezione possiamo domandare size(), isEmpty() e contains(elemento); possiamo attraversarla, aggiungere o togliere elementi se l'implementazione lo consente, oppure convertirne il contenuto in un array. Map<K,V> segue un ramo distinto: size() e isEmpty() hanno un significato analogo, ma un elemento della mappa è un'associazione e non un semplice E. Quando serve attraversare una mappa, otteniamo una vista delle chiavi, dei valori o delle coppie tramite keySet(), values() ed entrySet().

Le operazioni che lavorano su gruppi interi sono importanti quanto quelle sul singolo elemento. catalogo.containsAll(selezionati) chiede se ogni elemento di selezionati compare nel catalogo; addAll trasferisce elementi da una raccolta all'altra; removeAll elimina quelli presenti nell'altra; retainAll conserva soltanto quelli comuni. Il valore booleano restituito da queste modifiche indica se la collezione è cambiata, non quanti elementi sono stati mossi. Per esempio, chiamare addAll con un insieme di titoli già presenti in un Set lascia l'insieme uguale e restituisce false. Non tutte le collezioni permettono le modifiche: la firma del metodo esiste nell'interfaccia, ma una collezione non modificabile può lanciare UnsupportedOperationException.

Nota bene – Un tipo generico non promette mutabilità. Collection<Libro> dice quale tipo di elemento possiamo leggere senza cast. Non dice che add riuscirà, che null sia ammesso o che l'iterazione abbia un ordine stabile. Queste promesse appartengono al contratto della collezione concreta e dell'API che ce la consegna. Per questo una funzione che riceve Collection<Libro> dovrebbe usare soltanto le operazioni di cui ha realmente bisogno e documentare se intende modificarla.

Per non confondere le strutture con il loro uso, pensiamo a due domande concrete. «Quali titoli compaiono nella lista di lettura, anche se ripetuti?» porta a una List. «Quali codici sono stati già visitati?» porta a un Set. La prima domanda attribuisce significato alla posizione e alla ripetizione; la seconda all'unicità. Solo dopo decidiamo se l'ordine d'incontro o la velocità di ricerca richiedano ArrayList, LinkedHashSet o un'altra implementazione. Questo ordine del ragionamento è un mattone che useremo anche quando arriveremo alle mappe.

Liste: sequenza, duplicati e posizione

Una List<E> mantiene una sequenza di elementi e può contenere duplicati. get(indice) legge una posizione; l'indice 0 è il primo elemento. Nel programma completo, new ArrayList<>(List.of("Java", "Reti", "Java")) crea una lista modificabile con tre elementi. L'output [Java, Reti, Java] conferma che il secondo Java non viene eliminato. Il costruttore riceve una lista iniziale, ma crea una nuova ArrayList: aggiungere o rimuovere elementi dalla nuova lista non cambia quella passata al costruttore.

ArrayList è spesso la prima scelta per una lista: l'accesso per indice è diretto e l'aggiunta in fondo è normalmente efficiente, pur potendo richiedere una ridimensione dell'array interno. Inserire o rimuovere vicino all'inizio richiede in genere lo spostamento di elementi. LinkedList ha una struttura diversa, ma non diventa automaticamente più veloce per ogni inserimento: raggiungere la posizione può costare una traversata e ogni nodo richiede memoria e collegamenti. Se il problema usa soprattutto le estremità, spesso conviene guardare a Deque prima di scegliere LinkedList per abitudine.

La documentazione Java 25 di List distingue le operazioni obbligatorie dalle operazioni di modifica che alcune implementazioni possono rifiutare. Un metodo che riceve List non può dedurre soltanto dal tipo statico se add sia consentito. Per questo il contratto dell'API che consegna la lista deve spiegare anche la sua mutabilità.

Due liste, stessa interfaccia, costi diversi

Una ArrayList conserva i riferimenti in un array che può crescere. get(0) o get(500) raggiunge direttamente la posizione richiesta: non deve attraversare i precedenti 500 elementi. Se aggiungiamo in fondo e la capacità interna basta, l'operazione è semplice; quando non basta, la lista deve predisporre più spazio e copiare i riferimenti. Questo costo occasionale spiega perché si parla di costo ammortizzato per le aggiunte in fondo: molte aggiunte economiche e qualche riallocazione più onerosa. Se inseriamo alla posizione zero, invece, gli elementi già presenti devono spostarsi per lasciare spazio. La capacità interna non coincide con size(): una lista appena creata può avere spazio disponibile pur contenendo zero elementi, e il valore preciso della capacità è un dettaglio di implementazione su cui il programma non deve fondarsi.

Una LinkedList conserva ogni elemento in un nodo collegato al precedente e al successivo. Aggiungere o togliere da un'estremità può evitare lo spostamento di molti riferimenti; raggiungere la posizione 500 richiede però di attraversare nodi. Anche l'inserimento «nel mezzo» è rapido solo dopo che il punto è stato raggiunto, per esempio con un ListIterator già collocato lì. Se per ogni inserimento chiamiamo get(indice) o add(indice, elemento) partendo dall'inizio, il costo della ricerca del punto può dominare. I nodi richiedono inoltre spazio e possono peggiorare la località dei dati in memoria. Non esiste dunque una regola «LinkedList è migliore quando si modifica una lista» senza dire da dove arriviamo al punto da modificare.

La figura 17.2 rende visibile perché le due liste hanno costi diversi: nell’array una posizione è individuata dall’indice, mentre nella lista collegata si seguono i riferimenti fra nodi.

ArrayList e LinkedList a confronto
Figura 17.2 – ArrayList raggiunge una posizione per indice; LinkedList deve attraversare i collegamenti per arrivare a un nodo interno. Il disegno rappresenta i riferimenti fra elementi, non le dimensioni fisiche in memoria.
Bisogno ArrayList LinkedList
Leggere spesso la posizione i Accesso diretto; scelta naturale. Richiede un percorso fra nodi.
Aggiungere in fondo Normalmente economico; qualche crescita copia i riferimenti. Aggiunge un nodo all'estremità.
Inserire nel mezzo conoscendo solo l'indice Sposta una parte dei riferimenti. Prima raggiunge il punto, poi modifica i collegamenti.
Usare una coda dalle estremità Possibile con operazioni di lista, ma il tipo comunica poco l'intenzione. Implementa anche Deque; spesso ArrayDeque è più semplice se non servono gli indici.

La tabella confronta operazioni, non dichiara un vincitore assoluto. Se la biblioteca mostra spesso la pagina 20 di un elenco, ArrayList è plausibile. Se ci serve una coda di richieste, il contratto giusto è Deque o Queue: la rappresentazione si sceglie dopo. Vector è un'altra lista con array ridimensionabile, nata prima del framework e con metodi storicamente sincronizzati. La sincronizzazione dei singoli metodi non risolve da sola un'operazione composta, come «controlla se il titolo manca e poi aggiungilo». Per il codice nuovo, scegliamo il contratto e la politica di concorrenza esplicitamente, senza usare Vector come scorciatoia.

Cercare e lavorare su una porzione

Una lista non serve soltanto per leggere un indice. indexOf(titolo) cerca la prima posizione di un elemento uguale secondo equals; lastIndexOf(titolo) cerca l'ultima. Se il titolo non c'è, il risultato è -1, quindi va controllato prima di usarlo come indice. set(i, valore) sostituisce l'elemento in posizione i se la lista supporta la modifica; add(i, valore) inserisce e sposta i successivi; remove(i) elimina e restituisce l'elemento che occupava quella posizione. Per una lista di stringhe, remove(1) sceglie l'indice 1, mentre remove("Java") sceglie la prima occorrenza del valore. Con una List<Integer> la distinzione fra indice e valore merita ancora più attenzione: remove(1) indica l'indice, remove(Integer.valueOf(1)) indica il valore.

subList(inizio, fine) descrive una porzione con limite finale escluso, come substring per le stringhe. Da List.of("A", "B", "C", "D"), la porzione da 1 a 3 contiene B e C. È una vista della lista: non equivale a un nuovo archivio indipendente. Se il chiamante deve conservare una fotografia dei titoli mentre la lista originale continua a cambiare, crea una copia esplicita. Quando l'elemento è mutabile, anche una nuova lista copia soltanto i riferimenti agli elementi, non ogni oggetto contenuto: la differenza fra copia del contenitore e copia dei libri torna qui in forma molto concreta.

Insiemi: unicità e ordine

Un Set<E> non contiene due elementi uguali secondo il criterio di uguaglianza usato dall'implementazione. HashSet usa hashCode ed equals: il contratto di C10 diventa quindi concreto. Se due oggetti risultano uguali con equals, devono produrre lo stesso hash; altrimenti l'insieme può non riconoscere correttamente i duplicati. Non promettere un ordine di iterazione per HashSet. LinkedHashSet, invece, mantiene un ordine d'incontro definito. Nel programma, dalla lista con due Java otteniamo [Java, Reti], poi la vista reversed() si legge come [Reti, Java].

TreeSet mantiene gli elementi ordinati secondo il loro ordine naturale oppure un Comparator. Nel programma il comparatore ordina prima per lunghezza e poi alfabeticamente. I valori API, Java e Reti vengono stampati in quell'ordine. Qui la seconda parte del comparatore è essenziale: se confrontassimo soltanto la lunghezza, Java e Reti sarebbero giudicati equivalenti per il set, che ne conserverebbe soltanto uno. L'ordine di un TreeSet non è una decorazione successiva: decide anche quando due elementi occupano la stessa posizione logica nell'insieme.

EnumSet è un insieme specializzato per i valori di un enum: quando il dominio è proprio un enum, rende esplicito il tipo e offre un'implementazione dedicata. Non è necessario usarlo per ogni insieme; la scelta segue il tipo dei dati e le operazioni richieste.

Nota bene – Tre significati di «ordine». Una lista ha posizioni e un ordine della sequenza. Un LinkedHashSet preserva un ordine d'incontro definito, ma non consente duplicati né accesso indicizzato. Un TreeSet ordina secondo un comparatore. Dire soltanto che una collezione «è ordinata» lascia aperta la domanda decisiva: secondo quale regola?

La figura 17.3 mette a confronto le promesse delle tre implementazioni principali di Set: tutte eliminano duplicati, ma rispondono a esigenze diverse di visita.

Tre scelte di ordine per un insieme
Figura 17.3 – HashSet non promette ordine, LinkedHashSet conserva la prima comparsa, TreeSet usa il comparatore. I nomi nel riquadro HashSet rappresentano i membri, non una sequenza di stampa.

Le operazioni sugli insiemi risolvono domande del dominio

Supponiamo di avere i codici dei libri posseduti da una biblioteca e quelli richiesti per una mostra. posseduti.containsAll(richiesti) risponde alla domanda «li abbiamo tutti?». Se vogliamo i codici che compaiono in almeno uno dei due gruppi, partiamo da una copia modificabile e chiamiamo addAll: è l'unione. Se vogliamo solo quelli comuni, chiamiamo retainAll: è l'intersezione. Se vogliamo i posseduti che non sono richiesti, chiamiamo removeAll: è la differenza. Le operazioni modificano il set su cui sono invocate; per conservare i due insiemi di partenza, creiamo prima una copia. Il risultato di containsAll è booleano e non cambia i dati.

Per capire – La notazione degli insiemi. Se A = {Java, Reti} e B = {Reti, API}, allora l'unione A ∪ B contiene Java, Reti e API; l'intersezione A ∩ B contiene soltanto Reti; la differenza A \ B contiene soltanto Java. I simboli sono un modo breve per nominare una domanda concreta. In Java, su una copia modificabile di A, addAll(B), retainAll(B) e removeAll(B) realizzano rispettivamente queste tre domande. Dopo ogni chiamata controlliamo quale insieme è cambiato: non vogliamo alterare per errore l'archivio originale.

Il fatto che HashSet non prometta ordine non significa che «ordini per hash»: l'ordine che osserviamo in una stampa può cambiare e non è un contratto da usare. LinkedHashSet preserva l'ordine d'incontro, utile quando vogliamo togliere duplicati da una lista senza perdere l'ordine della prima comparsa. TreeSet applica invece una regola di confronto: se la regola considera due elementi equivalenti, il secondo non trova una posizione distinta nell'insieme. Per questo un comparatore scelto soltanto per una bella stampa può cancellare informazione. Se il dominio considera diversi due titoli della stessa lunghezza, il comparatore deve saperli distinguere, per esempio aggiungendo il confronto alfabetico.

Un insieme costruito sui valori di un enum

Riprendiamo un enum Giorno { LUNEDI, MARTEDI, MERCOLEDI, GIOVEDI, VENERDI, SABATO, DOMENICA }. Se una biblioteca apre dal lunedì al venerdì, EnumSet.range(Giorno.LUNEDI, Giorno.VENERDI) esprime la fascia inclusiva secondo l'ordine di dichiarazione delle costanti. EnumSet.noneOf(Giorno.class) crea l'insieme vuoto mantenendo noto il tipo; allOf(Giorno.class) prende tutti i giorni; of(Giorno.SABATO, Giorno.DOMENICA) sceglie due valori espliciti. copyOf parte da una collezione esistente, ma per una collezione vuota generica il tipo dell'enum non è ricavabile: in quel caso usiamo noneOf e aggiungiamo gli elementi. Queste factory evitano di rappresentare giorni inesistenti con stringhe digitate male.

EnumSet non accetta null ed è modificabile. Non diventa automaticamente sicuro per modifiche concorrenti solo perché è specializzato ed efficiente. Se l'orario di apertura dipende anche da festività, sede o stagione, un set statico dei giorni non basta: il modello dovrà includere quelle condizioni. Questo è il passaggio di generalizzazione: la soluzione EnumSet è adatta al problema «quali costanti dell'enum appartengono al gruppo?», non a qualunque regola temporale della biblioteca. La specifica Java 25 elenca le factory e le loro condizioni di errore.

Mappe: cercare per chiave

Una Map<K,V> associa una chiave K a un valore V. In un catalogo, la chiave potrebbe essere il codice del libro e il valore la sua scheda. Una chiave compare al più una volta; chiamare di nuovo put con la stessa chiave sostituisce il valore associato. HashMap offre ricerca basata su hash senza promettere un ordine d'iterazione. LinkedHashMap mantiene un ordine definito. TreeMap ordina le chiavi secondo un comparatore o il loro ordine naturale. EnumMap è una scelta specializzata quando le chiavi appartengono a un enum.

Nel programma usiamo SequencedMap<String, Integer> prestiti = new LinkedHashMap<>();. Dopo aver inserito Java e Reti, firstEntry().getKey() restituisce Java; la vista reversed() mostra Reti come prima voce. La parola vista conta: una vista espone gli stessi dati con una prospettiva diversa e può riflettere modifiche della mappa sottostante, secondo il contratto della specifica implementazione. Non va scambiata automaticamente per una copia indipendente.

Per HashMap le chiavi richiedono un hashCode coerente con equals, come per HashSet. Usare come chiave un oggetto mutabile e poi cambiare i campi coinvolti in uguaglianza e hash può rendere la voce difficile da ritrovare. Una chiave immutabile, come un record ben scelto, riduce questo rischio. Se la chiave può essere null, il comportamento va controllato nell'implementazione concreta; le factory Map.of non accettano chiavi o valori nulli.

La figura 17.4 riunisce le mappe più comuni: prima resta identica la regola delle chiavi uniche, poi cambia l’ordine con cui possiamo visitare le voci.

Contratti di visita delle mappe
Figura 17.4 – HashMap, LinkedHashMap, TreeMap ed EnumMap distinguono quattro esigenze di visita. La figura non attribuisce un ordine a HashMap.

Dal codice del libro alla sua scheda

La domanda «il libro con codice J-17 è disponibile?» è diversa da «in quale posizione della lista si trova J-17?». Se il codice identifica univocamente il libro, una mappa descrive direttamente l'associazione. put("J-17", scheda) registra la voce; get("J-17") recupera il valore oppure null se non esiste una voce per la chiave. Se i valori nulli sono ammessi dalla mappa, un null restituito da get può anche significare che la chiave c'è ma il suo valore è nullo: containsKey distingue i due casi. Un'API di dominio può scegliere un modello ancora più chiaro, per esempio Optional<Libro> in C19, se l'assenza è una risposta normale.

Una seconda put con la stessa chiave sostituisce il valore precedente e restituisce quel valore precedente, oppure null secondo le regole appena viste. La dimensione non aumenta. Questo è ciò che permette di aggiornare la scheda di un libro senza creare due identità concorrenti. I valori, invece, possono ripetersi: due codici diversi possono avere la stessa categoria o lo stesso autore. containsValue chiede se almeno una voce possiede quel valore, ma non è un sostituto di una ricerca per chiave quando il problema richiede prestazioni di accesso ripetuto.

HashMap è una scelta frequente quando non ci serve alcuna garanzia sull'ordine di visita. La stampa di quattro voci può apparire ordinata in un'esecuzione e diversa in un'altra: non dobbiamo dedurne che HashMap ordini le chiavi. LinkedHashMap conserva un ordine d'incontro definito, di norma quello d'inserimento: se inseriamo le chiavi 1, 3, 2, 4 e poi sostituiamo il valore di 4, l'ordine resta 1, 3, 2, 4 nel caso ordinario. TreeMap ordina invece in base alle chiavi, usando il loro ordine naturale o un comparatore. Se i codici devono apparire sempre ordinati e il catalogo cambia di continuo, questo contratto può essere utile; se ci serve soltanto una stampa occasionale, potremmo ordinare una vista delle chiavi al momento della presentazione.

Domanda Scelta iniziale Garanzia da controllare
Trovo un libro dal codice senza un ordine di visita richiesto? HashMap Chiave stabile per equals e hashCode; accesso non sincronizzato.
Devo ricordare l'ordine di registrazione? LinkedHashMap L'ordine promesso è d'incontro, non automaticamente alfabetico.
Devo attraversare sempre le chiavi in ordine? TreeMap Il comparatore deve distinguere le chiavi che il dominio considera diverse.
Le chiavi sono costanti di un enum? EnumMap Ordine delle costanti e divieto di chiave null.

La tabella è una guida di scelta, non una misura delle prestazioni. Una mappa non corregge una chiave progettata male. Se una classe CodiceLibro usa campi modificabili per equals e hashCode, cambiarli dopo l'inserimento può impedire di ritrovare la voce. Un record che conserva un codice normalizzato è più facile da usare correttamente. Anche l'ordine di TreeMap deve essere coerente con il significato di identità scelto: se due codici diversi vengono confrontati come uguali, una mappa ordinata può trattarli come la stessa chiave.

Quando la chiave è una costante

Se registriamo per ogni StatoPrestito il numero di prestiti presenti, le chiavi appartengono a un insieme finito noto al compilatore. EnumMap<StatoPrestito, Integer> conteggi = new EnumMap<>(StatoPrestito.class) crea una mappa vuota del tipo corretto. Possiamo aggiungere una voce con put, rimuoverla con remove e creare una nuova mappa da una EnumMap esistente. Durante l'iterazione le chiavi seguono l'ordine di dichiarazione dell'enum, non l'ordine in cui abbiamo chiamato put. Una chiave null è vietata; un valore null è invece ammesso, anche se per un conteggio è più chiaro decidere se l'assenza significhi zero oppure «non ancora rilevato».

Nota bene – EnumMap non è un enum. L'enum definisce le possibili chiavi; la mappa associa a ciascuna chiave un valore che può cambiare. EnumSet risponde alla domanda «quali stati sono presenti?», EnumMap alla domanda «quale valore corrisponde a ogni stato?». Questa differenza è il criterio per trasferire la soluzione a un problema nuovo. Le regole Java 25 di EnumMap precisano anche che le sue viste hanno iteratori debolmente consistenti: non lanciano ConcurrentModificationException, ma possono mostrare o non mostrare una modifica intervenuta durante il percorso. Non è una licenza per modificare la mappa da più thread senza coordinamento.

Pile, code e due estremità

Una pila estrae per primo l'ultimo elemento inserito: LIFO. Una coda estrae per primo il più vecchio: FIFO. Deque<E> supporta operazioni da entrambe le estremità e può rappresentare entrambi i comportamenti. ArrayDeque è una scelta ordinaria per una pila o una coda non concorrente. Nel programma chiamiamo push("primo"), poi push("secondo"); pop() stampa secondo. È lo stesso comportamento della pila didattica, ma con un tipo generico della libreria. La vecchia classe Stack esiste per compatibilità; per nuovo codice non è la scelta da insegnare come pila predefinita.

Queue<E> esprime la coda. I metodi add/remove e offer/poll differiscono nel modo in cui segnalano certe condizioni di fallimento o assenza; il chiamante deve conoscere il contratto dell'implementazione. PriorityQueue non conserva il FIFO generale: estrae secondo una priorità, definita dall'ordine naturale o da un comparatore. Il nome «queue» non basta a prevedere quale elemento uscirà.

Le interfacce sequenced, disponibili in Java 25, modellano collezioni con un ordine d'incontro definito e operazioni sulle estremità. SequencedCollection, SequencedSet e SequencedMap permettono anche una vista reversed(). La specifica API di SequencedCollection chiarisce il contratto. Non tutte le collezioni sono sequenced: chiedere il primo elemento di un HashSet come se avesse un ordine promesso sarebbe un errore di modello.

La figura 17.5 separa la classe Stack, che si può incontrare in programmi esistenti, dal contratto Deque che useremmo per una nuova pila.

Pila storica e Deque
Figura 17.5 – Il comportamento LIFO resta lo stesso; nel codice nuovo Deque esprime meglio la scelta della struttura. ArrayDeque è una possibile implementazione non concorrente.

La pila della libreria e la pila del libro

La nostra Pila didattica aveva capacità fissa e metodi scelti per capire invarianti ed errori. java.util.Stack<E> esiste ancora: estende Vector, fornisce push, pop, peek, empty e search. search(oggetto) restituisce la distanza dalla cima contando da uno, non l'indice di una lista; se l'oggetto non compare, restituisce -1. Saper leggere una Stack è utile in codice esistente. Per nuovo codice la documentazione di Deque raccomanda il contratto Deque, spesso realizzato da ArrayDeque. push inserisce in testa, pop toglie dalla testa e peek osserva la testa senza rimuoverla: la stessa regola LIFO, espressa con una struttura più adatta al bisogno.

Non dobbiamo però confondere questa pila di oggetti con lo stack delle chiamate mostrato dalle eccezioni. Una Deque<Libro> è un contenitore che il nostro programma modifica; lo stack delle chiamate è parte del contesto di esecuzione di un thread. Il nome comune nasce dall'ordine LIFO, ma leggere pop() nella nostra collezione non rimuove un metodo dalla pila delle chiamate. La distinzione evita di trasferire un'analogia utile oltre il suo ambito.

Tre modi di chiedere il primo elemento di una coda

Per una coda di richieste, add(r) e offer(r) provano entrambe a inserire. La prima segnala con un'eccezione il rifiuto dovuto a limiti di capacità; la seconda può restituire false. remove() e poll() estraggono il primo elemento: su una coda vuota, remove() lancia un'eccezione e poll() restituisce null. element() e peek() guardano il primo senza toglierlo, con la stessa differenza fra eccezione e null quando la coda è vuota. La scelta dipende da come vogliamo rappresentare il caso vuoto nel contratto del chiamante. Non serve memorizzare sei nomi isolati: ricordiamo le tre operazioni — inserire, estrarre, osservare — e le due forme di segnalazione.

Con ArrayDeque non si inseriscono valori null, perciò null restituito da poll ha un significato non ambiguo. Una PriorityQueue risponde a una domanda diversa: «quale richiesta ha la priorità maggiore secondo un criterio?» e non «quale è arrivata prima?». Se due richieste hanno la stessa priorità e l'ordine di arrivo conta, il comparatore deve includere anche un numero progressivo o un altro criterio di spareggio. Altrimenti il programma non dovrebbe promettere FIFO all'interno dello stesso livello di priorità.

Modificabile, non modificabile e immutabile

List.of, Set.of e Map.of creano collezioni non modificabili. List.copyOf produce una lista non modificabile dai valori di un'altra collezione; nel programma la copia dei tre titoli ha dimensione 3. Queste factory rifiutano null; Set.of rifiuta anche elementi duplicati già nella creazione. Non modificabile significa che non possiamo aggiungere, sostituire o rimuovere elementi attraverso quella collezione. Non significa che gli oggetti contenuti siano immutabili: se una lista contiene un oggetto mutabile, quello può ancora cambiare.

Collections.unmodifiableList(lista) crea invece una vista non modificabile della lista ricevuta: attraverso la vista non si può chiamare add, ma modifiche fatte alla lista originale possono apparire nella vista. Una copia non modificabile e una vista non modificabile rispondono quindi a esigenze differenti. Quando un'API promette uno snapshot, deve produrre una copia adeguata e considerare anche la mutabilità degli elementi, se rilevante.

Arrays.asList permette di costruire una lista in poche parole. È una scorciatoia utile, ma proprio la sua brevità può nascondere una differenza importante. Passando un array, otteniamo una lista a dimensione fissa collegata a quell'array: set può sostituire un elemento e il cambiamento appare nell'array, mentre add e remove non possono cambiare il numero degli elementi. Questa lista non è né una normale ArrayList ridimensionabile né una copia immutabile. Se il programma deve poter aggiungere titoli, costruiamo per esempio new ArrayList<>(Arrays.asList(dati)); se deve conservare una fotografia non modificabile dei riferimenti correnti, scegliamo List.copyOf quando i valori non sono nulli.

Il programma DemoVisteListe.java fa cambiare un solo elemento e osserva tre percorsi. fissa modifica l'array; copia mantiene i riferimenti presenti quando è stata costruita; vista impedisce la modifica attraverso il proprio riferimento ma continua a mostrare il contenuto della lista sottostante. Usiamo stringhe, che sono immutabili, per isolare il comportamento della struttura. Con oggetti modificabili, anche una copia della lista potrebbe mostrare lo stato mutato di un oggetto condiviso: una copia della struttura non è automaticamente una copia profonda degli elementi.

import java.util.Arrays;
import java.util.Collections;
import java.util.List;

public class DemoVisteListe {
    public static void main(String[] args) {
        String[] dati = {"Java", "Reti"};
        List<String> fissa = Arrays.asList(dati);
        List<String> copia = List.copyOf(fissa);
        List<String> vista = Collections.unmodifiableList(fissa);

        fissa.set(1, "API");
        System.out.println("Array: " + Arrays.toString(dati));
        System.out.println("Copia: " + copia);
        System.out.println("Vista: " + vista);
        try {
            fissa.add("JVM");
        } catch (UnsupportedOperationException errore) {
            System.out.println("Aggiunta: " + errore.getClass().getSimpleName());
        }
    }
}

Le righe attese sono Array: [Java, API], Copia: [Java, Reti], Vista: [Java, API] e Aggiunta: UnsupportedOperationException. Prima di eseguirlo, prova a prevederle: la differenza fra copia e vista diventa chiara solo se segui quando ciascuna osserva i dati. Ora cambia il requisito: un chiamante deve ricevere i titoli esistenti ma non deve vedere aggiunte future al catalogo. Una vista non modificabile non basta; occorre una copia con il contratto adatto. Se invece il chiamante deve osservare aggiornamenti in tempo reale senza poterli eseguire, la vista può essere appropriata, purché il codice dichiari che non è uno snapshot e definisca anche la disciplina di accesso concorrente.

Iterare e modificare con criterio

Un Iterator<E> permette di attraversare una collezione con hasNext() e next(). Il for esteso usa questo modello quando attraversa un Iterable. Se durante l'iterazione vogliamo rimuovere l'elemento corrente da una collezione che lo consente, possiamo usare iterator.remove() secondo il contratto, invece di modificare la collezione attraverso un altro riferimento nel mezzo del ciclo. Alcuni iteratori di implementazioni comuni rilevano modifiche strutturali inattese e lanciano ConcurrentModificationException; non sono però un meccanismo di sincronizzazione né una garanzia assoluta di individuazione in ogni circostanza. Le collezioni concorrenti saranno trattate nel capitolo sulla concorrenza.

Il nome e il problema. Il pattern Iterator risponde alla domanda «come percorro gli elementi senza esporre la struttura interna della collezione?». Per questo, nella classificazione dei pattern, è un pattern comportamentale, non creazionale: creare l'oggetto iteratore è soltanto il modo per iniziare quel comportamento. L'interfaccia Java Iterator<E> applica questa idea con un contratto preciso; Iterable<T> è invece il contratto di chi sa fornire un nuovo iteratore. Non serve memorizzare l'etichetta prima di capire la coppia problema–soluzione: una collezione custodisce gli elementi, un iteratore governa un percorso.

Una piccola mappa di scelta riassume le domande del capitolo senza sostituire la spiegazione. Se servono posizioni e duplicati, si parte da List e spesso ArrayList; se serve unicità, da Set; se serve ricerca per chiave, da Map; se servono estremità, da Deque; se serve priorità, da PriorityQueue. Poi si decide l'implementazione chiedendo quali garanzie di ordine, modifica, nullità e costo siano necessarie. Il costo di un'operazione dipende anche dalla dimensione, dalla distribuzione delle chiavi, dal comparatore e dal modo d'uso: non basta uno slogan come «la mappa è sempre veloce».

Il catalogo della biblioteca, decisione per decisione

Proviamo a scegliere una collezione senza guardare prima l'elenco delle classi. Il catalogo deve trovare un libro dato il suo codice univoco. La domanda è una ricerca per chiave: Map<CodiceLibro, Libro> descrive bene il contratto. Se il codice è una stringa normalizzata, la mappa può usare String come chiave; se ha più parti, un record immutabile può riunirle. La scelta fra HashMap, LinkedHashMap e TreeMap dipende poi da una seconda esigenza: non ci serve un ordine, vogliamo quello d'inserimento o ci occorre quello dei codici? Scegliere un TreeMap soltanto perché «ordina» aggiunge un criterio che forse il catalogo non richiede.

Ora vogliamo mostrare gli ultimi prestiti in ordine di registrazione, anche se due prestiti riguardano lo stesso titolo. Una List<Prestito> conserva duplicati e posizione; una Set<Prestito> potrebbe eliminarne alcuni secondo equals, perdendo informazione. Se i prestiti crescono senza limite, una singola lista in memoria può non essere il modello completo del sistema: la collezione rappresenta il tratto caricato dall'applicazione, non sostituisce un archivio persistente. Le API di collezione non decidono da sole quanto storico conservare o come proteggere dati concorrenti.

Per tenere i codici dei libri già esposti in una vetrina, senza duplicati e nell'ordine in cui sono stati aggiunti, LinkedHashSet<String> combina le due proprietà. Se l'ordine non conta, HashSet esprime meno garanzie e può essere sufficiente. Se la vetrina deve mostrarsi sempre in ordine alfabetico, TreeSet o un ordinamento al momento della stampa possono essere valutati in base a quanto spesso cambiano i dati e quanto spesso vengono letti. Il criterio di confronto deve restare coerente con il significato di duplicato voluto dal dominio.

Per le richieste di prestito in attesa, una coda FIFO è naturale se l'ordine di arrivo decide chi viene servito per primo. Una ArrayDeque<Richiesta> può esprimere questo comportamento nel singolo thread. Se entrano richieste da più thread contemporaneamente, non basta cambiare il nome della variabile: serve una struttura concorrente e un contratto sull'ordine e sulla sincronizzazione, da affrontare nel capitolo dedicato. Se alcune richieste hanno priorità, PriorityQueue può rappresentare la regola, ma occorre definire come gestire parità e arrivi successivi.

Questo percorso mostra perché il tipo della collezione non è soltanto una scelta di prestazioni. È parte del significato del programma: una lista dice che le ripetizioni contano, un set dice che no, una mappa identifica valori mediante chiavi, una deque espone le estremità. Dopo aver espresso il significato, si misurano i costi se il carico reale lo richiede. Non si sceglie LinkedList perché «inserire è veloce» senza specificare dove, come si raggiunge il punto e quanti elementi ci sono.

Buona pratica – Esporre soltanto la garanzia utile. Un metodo che restituisce il catalogo non dovrebbe consegnare la HashMap interna modificabile se il chiamante deve soltanto leggere. Può restituire una vista o una copia con un contratto dichiarato. La scelta fra vista e copia determina se aggiornamenti successivi diventeranno visibili; va spiegata a chi usa l'API.

Attraversare non significa possedere

Proviamo il percorso di Iterable e Iterator con una lista di tre titoli: Java, Reti, Java. Chiamare iterator() crea un cursore per quell'attraversamento. hasNext() chiede se resta un elemento, next() lo restituisce e fa avanzare il cursore. Dopo tre chiamate valide, un'ulteriore next() non restituisce null come segnale di fine: lancia NoSuchElementException. Il for (String titolo : titoli) evita di scrivere a mano il cursore, ma percorre la stessa interfaccia Iterable. Non trasforma la lista in un array né produce una copia.

L'iteratore non è il proprietario dei dati: è un modo disciplinato di percorrerli. Su una lista modificabile, se vogliamo togliere durante il percorso ogni titolo vuoto, usiamo iterator.remove() dopo aver letto l'elemento, se l'implementazione consente quell'operazione. ListIterator aggiunge il movimento all'indietro e operazioni come add e set nel contesto di una lista. Queste capacità non appartengono a un semplice Iterator e non sono garantite da ogni collezione. Per una trasformazione di tutti i valori può essere più chiaro costruire una nuova lista; per una rimozione mirata removeIf esprime direttamente il predicato. La scelta dipende da quale risultato deve essere osservato dopo l'operazione.

Un cursore non è una copia della collezione

Immagina di segnare con un dito la riga che stai leggendo in un catalogo. Il dito non contiene il catalogo: indica soltanto dove ti trovi. Un Iterator<E> svolge questo ruolo. hasNext() controlla se un'altra lettura è possibile, next() restituisce l'elemento e avanza. Per questo for (Libro libro : libri) è comodo quando dobbiamo leggere tutti i libri, ma non quando serve una posizione precisa o un percorso all'indietro. Iterable è il contratto dell'oggetto che può fornire un iteratore; Iterator è il cursore ottenuto per un singolo percorso. Chiamare di nuovo iterator() crea un nuovo percorso, non riporta indietro quello già consumato.

La vecchia interfaccia Enumeration<E> compare ancora in alcune API e classi storiche. Offre hasMoreElements() e nextElement(), senza un metodo per rimuovere l'elemento corrente. Se incontriamo codice che la restituisce, possiamo leggerlo come un cursore in avanti; asIterator() rende i suoi elementi rimanenti disponibili attraverso l'interfaccia Iterator. Non c'è motivo di introdurre Enumeration come prima scelta per una nuova collezione. Questo passaggio storico è utile perché i nomi delle API vecchie possono comparire in un progetto reale, anche se il percorso didattico usa il for esteso e Iterator.

Un ListIterator<E> aggiunge ciò che manca al cursore generico quando la struttura è una lista: hasPrevious() e previous() permettono il ritorno, mentre nextIndex() e previousIndex() indicano la posizione che verrebbe letta in ciascuna direzione. Il cursore si trova fra gli elementi. Se una lista contiene A, B, C e chiamiamo listIterator(1), una chiamata a next() restituisce B, mentre previous() restituisce A. All'inizio previousIndex() vale -1; alla fine nextIndex() vale size(). Questi numeri sono posizioni di una prossima lettura, non elementi immaginari ai bordi della lista.

Quando l'implementazione consente modifiche, ListIterator.add(e) inserisce nel punto del cursore; set(e) sostituisce l'ultimo elemento restituito da next() o previous(), se la sequenza di chiamate lo permette. Non è corretto descrivere set come «sostituisce sempre l'elemento che verrà letto dopo»: il suo bersaglio dipende dalla lettura appena compiuta. Questa precisione serve quando trasformiamo una lista mentre la attraversiamo. Per una lista non modificabile, invece, i metodi di modifica possono lanciare UnsupportedOperationException anche se compaiono nella firma dell'interfaccia.

La modifica durante il percorso non è una gara fra thread

Un ArrayList può lanciare ConcurrentModificationException se, mentre lo percorriamo, cambiamo strutturalmente la lista attraverso un altro riferimento. L'eccezione aiuta a scoprire un errore di uso, ma non è una promessa di rilevarlo sempre e non rende la lista sicura per più thread. Se vogliamo rimuovere elementi durante una singola iterazione, usiamo Iterator.remove() dopo next(), quando l'implementazione lo supporta, oppure removeIf quando la regola è un predicato semplice. Se un altro thread modifica la stessa struttura, serve una politica di concorrenza definita per tutti i partecipanti; la sola presenza di un iteratore non la fornisce.

Le etichette «fail-fast» e «fail-safe» che si incontrano in alcuni testi sono scorciatoie per comportamenti diversi, non due interfacce standard da scegliere. Un iteratore di ArrayList tenta di segnalare alcune modifiche strutturali inattese. Le viste di EnumMap hanno iteratori debolmente consistenti: possono continuare senza eccezione e osservare o non osservare modifiche sopraggiunte. CopyOnWriteArrayList crea invece un percorso su una fotografia dell'array al momento dell'iterazione. Sono contratti differenti; dire che tutti gli iteratori che non falliscono lavorano «su un clone» sarebbe falso. Prima di modificare una collezione durante un percorso, leggiamo il contratto della sua implementazione e decidiamo quale risultato vogliamo osservare.

forEach e forEachRemaining: dove comincia il lavoro?

Iterable.forEach(azione) attraversa gli elementi forniti da un nuovo percorso e applica un Consumer a ciascuno. Possiamo scrivere titoli.forEach(System.out::println) quando l'azione è stampare ogni titolo. Un Iterator già ottenuto ha invece forEachRemaining(azione): elabora soltanto gli elementi che restano dal punto corrente. Se titoli contiene Java, Reti, API, una chiamata a iteratore.next() consuma Java; poi iteratore.forEachRemaining(System.out::println) stampa Reti e API. Non stampa di nuovo Java, e non modifica i titoli. Il for esteso rende il controllo del percorso esplicito nel programma; forEach consegna l'azione all'oggetto iterabile. In entrambi i casi le eccezioni dell'azione vanno comprese: una lambda non rende innocua un'operazione che può fallire.

Una mappa offre forEach((chiave, valore) -> ...), in cui l'azione è un BiConsumer perché riceve due dati collegati. È comodo per stampare una coppia. entrySet() resta preferibile quando dobbiamo passare le voci ad altri metodi o lavorare con ogni Map.Entry come oggetto. Se l'ordine delle stampe è parte del requisito, scegliamo una mappa che lo promette, come LinkedHashMap o TreeMap; una lambda non crea un ordine che la mappa non aveva.

Algoritmi, viste e costo delle scelte

Il Framework non contiene soltanto strutture: offre operazioni generali come ordinare una lista, cercare un valore o mescolare gli elementi. Collections.sort(lista) e lista.sort(comparatore) modificano l'ordine della lista ricevuta, se essa supporta la modifica; lista.stream().sorted().toList() produce invece una nuova lista risultato e lascia la sorgente com'era. Chiamare entrambe le forme «ordinamento» senza dire quale oggetto cambia renderebbe difficile prevedere il seguito del programma. Una ricerca binaria richiede che la sequenza sia già ordinata secondo lo stesso criterio usato dalla ricerca: su una lista in ordine casuale, il metodo non fornisce il risultato desiderato solo perché il nome contiene search.

Una subList è una vista su una porzione della lista, non necessariamente una copia indipendente. Se il chiamante conserva la vista e qualcun altro cambia strutturalmente la lista di origine, il comportamento pratico può sorprendere. Quando serve uno snapshot autonomo, costruire una nuova ArrayList<>(lista.subList(inizio, fine)) esprime meglio l'intenzione. Anche reversed() delle interfacce sequenced è una vista invertita: iterare la vista parte dall'ultima posizione della collezione d'origine, ma non promette una copia dei dati. La differenza fra vista, copia e oggetti contenuti è una delle domande che ogni API dovrebbe risolvere nella propria documentazione.

Quanto costa una scelta? ArrayList.get(i) usa l'indice per raggiungere una posizione; per LinkedList.get(i) occorre percorrere collegamenti. Inserire alla fine di una ArrayList è normalmente economico, ma qualche inserimento può richiedere di riallocare e copiare l'array interno. Una HashMap offre in generale ricerche efficienti, ma il contratto non è la promessa che ogni input, su ogni macchina e con ogni funzione hash, costi lo stesso tempo. Il criterio pratico resta scegliere la struttura dal significato dei dati, poi controllare il costo delle operazioni dominanti e misurare se quel costo conta davvero.

Una mappa percorsa senza perdere la relazione

Se il catalogo usa Map<String, Libro>, attraversare soltanto keySet() restituisce i codici e attraversare soltanto values() restituisce i libri. Quando dobbiamo stampare il codice insieme al titolo corrispondente, entrySet() espone ogni coppia come Map.Entry<String, Libro>. Leggiamo getKey() e getValue() dalla stessa entry, senza cercare nuovamente il valore per ogni chiave. È un esempio in cui il tipo della vista comunica precisamente che cosa il ciclo sta percorrendo: non una lista generica di oggetti, ma associazioni fra chiavi e valori.

La regola delle chiavi uniche va osservata in sequenza. Dopo put("J-01", libroA), la mappa contiene un'associazione; put("J-01", libroB) non aggiunge una seconda voce con la stessa chiave, ma sostituisce il valore e restituisce quello precedente secondo il contratto di put. Il numero delle voci resta uno. Se il problema richiede più libri per lo stesso autore, una semplice Map<Autore, Libro> non è adatta: possiamo usare Map<Autore, List<Libro>> o un modello diverso, dichiarando chi crea e modifica le liste interne. Non basta scegliere una mappa per risolvere automaticamente una relazione uno-a-molti.

Le viste keySet, values ed entrySet sono collegate alla mappa secondo il contratto dell'implementazione. Una vista ottenuta da una mappa modificabile può riflettere aggiornamenti successivi; non è uno snapshot. Al contrario, una copia esplicita conserva i dati osservati al momento della copia, almeno al livello delle associazioni e dei riferimenti copiati. Se i valori Libro sono mutabili, la copia della mappa non è una copia profonda di ogni libro. Questa distinzione riprende la differenza già incontrata per le liste e impedisce di promettere isolamento quando abbiamo copiato soltanto il contenitore esterno.

Laboratorio: scegliere, attraversare e conservare il risultato

Mettiamo insieme i pezzi senza nascondere i passaggi. La biblioteca ha una lista di titoli in cui Java compare due volte: la ripetizione è lecita perché la lista rappresenta una sequenza. Deve confrontare due gruppi di titoli per una mostra, rappresentare stati fissati da un enum e conservare una scheda per codice. Ogni esigenza sceglie un contratto diverso. Il programma completo DemoPercorsiCollezioni.java usa soltanto dati piccoli, così possiamo prevedere ogni risultato prima di eseguirlo.

import java.util.ArrayList;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.Set;
import java.util.StringJoiner;

public class DemoPercorsiCollezioni {
    enum Stato { DISPONIBILE, PRESTATO, RIENTRATO }

    public static void main(String[] args) {
        List<String> titoli = new ArrayList<>(List.of("Java", "Reti", "Java"));
        ListIterator<String> cursore = titoli.listIterator(titoli.size());
        StringJoiner indietro = new StringJoiner(" | ");
        while (cursore.hasPrevious()) {
            indietro.add(cursore.previous());
        }
        System.out.println("Lista a ritroso: " + indietro);

        Set<String> catalogo = new LinkedHashSet<>(List.of("Java", "Reti"));
        Set<String> mostra = new LinkedHashSet<>(List.of("Reti", "API"));
        Set<String> unione = new LinkedHashSet<>(catalogo);
        unione.addAll(mostra);
        Set<String> comuni = new LinkedHashSet<>(catalogo);
        comuni.retainAll(mostra);
        System.out.println("Unione: " + unione);
        System.out.println("Comuni: " + comuni);

        EnumSet<Stato> statiVisibili = EnumSet.of(
                Stato.DISPONIBILE, Stato.PRESTATO);
        EnumMap<Stato, Integer> conteggi = new EnumMap<>(Stato.class);
        conteggi.put(Stato.PRESTATO, 2);
        conteggi.put(Stato.DISPONIBILE, 5);
        System.out.println("Stati: " + statiVisibili);
        System.out.println("Conteggi: " + conteggi);

        Iterator<String> iteratore = titoli.iterator();
        iteratore.next();
        StringJoiner restanti = new StringJoiner(" | ");
        iteratore.forEachRemaining(restanti::add);
        System.out.println("Restanti: " + restanti);

        Map<String, String> libri = new LinkedHashMap<>();
        libri.put("J-01", "Java");
        libri.put("R-02", "Reti");
        libri.put("R-02", "Reti, seconda edizione");
        libri.forEach((codice, titolo) ->
                System.out.println(codice + " -> " + titolo));
    }
}

La prima riga stampata è Lista a ritroso: Java | Reti | Java. Il cursore parte da titoli.size(), cioè dopo l'ultimo elemento, e previous() torna indietro. Il fatto che le due parole Java siano uguali non le fonde: la lista conserva due posizioni. Per l'unione copiamo catalogo in un nuovo LinkedHashSet e poi chiamiamo addAll(mostra): otteniamo [Java, Reti, API] senza cambiare i due gruppi iniziali. Per l'intersezione creiamo un'altra copia e chiamiamo retainAll: resta [Reti]. Se eliminassimo la copia, la prima operazione cambierebbe l'insieme originale e la seconda domanda partirebbe da dati diversi.

EnumSet produce gli stati [DISPONIBILE, PRESTATO], mentre EnumMap stampa {DISPONIBILE=5, PRESTATO=2} benché nel programma PRESTATO sia stato inserito per primo. La mappa segue l'ordine delle costanti, non quello delle chiamate a put. Dopo che l'iteratore consuma Java, forEachRemaining raccoglie Reti | Java: il cursore non ricomincia da capo. Infine LinkedHashMap stampa due voci, J-01 -> Java e R-02 -> Reti, seconda edizione. La seconda put con la chiave R-02 ha aggiornato una voce, non ne ha creata una terza. Ogni output conferma una proprietà diversa; il programma non è una gara fra implementazioni.

Per trasferire la soluzione. Se il catalogo dovesse mostrare i libri in ordine alfabetico per titolo, cambiare soltanto LinkedHashMap in TreeMap non basterebbe: la mappa è indicizzata dal codice, quindi verrebbero ordinate le chiavi. Dovremmo decidere se costruire una vista ordinata delle schede per titolo o usare un altro indice. Il criterio riusabile è chiedersi quale informazione identifica il valore e quale informazione stabilisce l'ordine di presentazione.

Quando l'azione di forEach può fallire

L'esempio più semplice è una divisione: divisori.forEach(n -> System.out.println(10 / n)) si interrompe con ArithmeticException se la lista contiene zero. Se mettiamo un try attorno all'intera chiamata, intercettiamo l'errore ma gli elementi dopo lo zero non vengono elaborati. Se il requisito è «prova ogni divisore e segnala quelli impossibili», il try va dentro l'azione oppure usiamo un for esplicito. La posizione del catch non è una questione di stile: decide se l'elaborazione continua. Questo è lo stesso ragionamento che abbiamo costruito nel capitolo 12 per le eccezioni, applicato ora a un'azione ripetuta.

Le eccezioni checked aggiungono un vincolo. forEach riceve un Consumer<T> il cui metodo accept(T) non dichiara throws IOException. Una lambda che chiama direttamente leggi(nome) se leggi dichiara throws IOException non compila. Possiamo gestire l'errore dentro la lambda; possiamo scegliere un for ordinario nel cui metodo chiamante propagare IOException; oppure possiamo adattare l'azione a un'eccezione unchecked, rendendo esplicita questa scelta nel contratto. Il prossimo programma completo confronta l'ultima scelta con la gestione elemento per elemento.

import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.List;
import java.util.function.Consumer;

public class DemoEccezioniCollezioni {
    @FunctionalInterface
    interface AzioneIO<T> {
        void esegui(T valore) throws IOException;
    }

    static <T> Consumer<T> propaga(AzioneIO<T> azione) {
        return valore -> {
            try {
                azione.esegui(valore);
            } catch (IOException errore) {
                throw new UncheckedIOException(errore);
            }
        };
    }

    static void leggi(String nome) throws IOException {
        if (nome.equals("mancante")) {
            throw new IOException("archivio assente");
        }
        System.out.println("letto: " + nome);
    }

    public static void main(String[] args) {
        List<String> nomi = List.of("catalogo", "mancante", "storico");
        try {
            nomi.forEach(propaga(DemoEccezioniCollezioni::leggi));
        } catch (UncheckedIOException errore) {
            System.out.println("interrotto: " + errore.getCause().getMessage());
        }

        for (String nome : nomi) {
            try {
                leggi(nome);
            } catch (IOException errore) {
                System.out.println("saltato: " + nome);
            }
        }
    }
}

La prima visita stampa letto: catalogo, poi incontra mancante e il wrapper trasforma la IOException in UncheckedIOException, conservandola come causa. Il catch esterno stampa interrotto: archivio assente; storico non viene letto. La seconda visita usa un for: stampa di nuovo letto: catalogo, poi saltato: mancante e infine letto: storico. Abbiamo cambiato la politica del programma, non soltanto la sintassi del ciclo. La classe UncheckedIOException comunica ancora che il fallimento nasce da I/O; una generica RuntimeException perderebbe questa informazione specifica, pur potendo conservare la causa.

Il metodo generico propaga è un adattatore: riceve una AzioneIO<T> capace di dichiarare IOException e restituisce un Consumer<T> accettato da forEach. Non è una scorciatoia da applicare automaticamente a ogni eccezione checked. Se chi chiama deve sapere quale file è fallito e continuare con gli altri, il secondo percorso o un risultato strutturato è più chiaro. Se l'operazione deve fermarsi al primo errore, l'adattatore rende l'errore visibile all'esterno e mantiene la causa. Il mattone da riusare è la domanda «che cosa deve accadere agli elementi successivi quando uno fallisce?»; la risposta decide dove intercettare l'eccezione e quale API usare.

Per verificare

Compila DemoCollezioni.java, DemoPercorsiCollezioni.java e DemoEccezioniCollezioni.java con javac --release 25 -Xlint:all -d build seguito dai tre nomi dei file; poi esegui ciascuna classe. Prima dell'esecuzione, prevedi perché la lista conserva due Java, l'insieme uno solo e la mappa aggiorna R-02 senza aumentare dimensione. Togli thenComparing dal comparatore del TreeSet: quanti titoli rimangono e quale informazione è stata persa? Prova a chiamare add sulla lista prodotta da List.copyOf e spiega l'errore. Infine cambia il requisito del laboratorio: dopo un file mancante bisogna continuare con i successivi. Indica quale dei due percorsi di DemoEccezioniCollezioni soddisfa la richiesta e perché. Se sai scegliere la struttura e la gestione dell'errore per questa variante, hai portato il mattone fuori dall'esempio iniziale.

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 ↑