1. Collector personalizzati: quando e come scriverli
Nello Java Stream API per trasformare uno stream in una collezione o in un aggregato si usa l'interfaccia Collector. Di solito usi i collector pronti della classe Collectors (toList(), toMap(), groupingBy() ecc.), ma a volte serve qualcosa di particolare — e allora puoi scrivere un Collector tutto tuo.
Collector — è un oggetto che descrive come raccogliere dagli elementi dello stream il risultato finale. Definisce quattro (in realtà cinque) componenti chiave:
- supplier — crea un nuovo contenitore per raccogliere gli elementi (per esempio, una nuova lista o mappa).
- accumulator — aggiunge l'elemento successivo al contenitore.
- combiner — unisce due contenitori (importante per gli stream paralleli!).
- finisher — trasforma il contenitore nel risultato finale (per esempio, lo rende immutabile o lo converte in un altro tipo).
- characteristics — insieme di flag che descrivono le proprietà del collector (per esempio, se supporta il parallelismo, se cambia il tipo del risultato, ecc.).
Firma:
Collector<T, A, R>
- T — tipo degli elementi dello stream,
- A — tipo dell'accumulatore intermedio,
- R — tipo del risultato.
2. Esempio: Collector per una MultiMap (Map<K, List<V>>)
Supponiamo di voler raccogliere uno stream di coppie Pair<K, V> in Map<K, List<V>> (multi‑mappa), dove a ogni chiave corrisponde una lista di valori.
Esempio di implementazione:
public static <K, V> Collector<Pair<K, V>, ?, Map<K, List<V>>> toMultiMap() {
return Collector.of(
HashMap::new, // supplier
(map, pair) -> map.computeIfAbsent(pair.key(), k -> new ArrayList<>()).add(pair.value()), // accumulator
(map1, map2) -> { // combiner
map2.forEach((k, vList) -> map1.merge(k, vList, (l1, l2) -> { l1.addAll(l2); return l1; }));
return map1;
},
Function.identity(), // finisher
Collector.Characteristics.UNORDERED
);
}
Utilizzo:
List<Pair<String, Integer>> pairs = List.of(
new Pair<>("a", 1), new Pair<>("b", 2), new Pair<>("a", 3)
);
Map<String, List<Integer>> multiMap = pairs.stream().collect(toMultiMap());
// multiMap: {a=[1, 3], b=[2]}
3. Esempio: Collector per i primi N elementi
Supponiamo che tu voglia raccogliere uno stream in una lista dei N elementi più grandi (per esempio, top‑5 in ordine decrescente).
Implementazione:
public static <T> Collector<T, ?, List<T>> topN(int n, Comparator<? super T> comparator) {
return Collector.of(
() -> new PriorityQueue<>(n, comparator), // supplier
(pq, t) -> {
pq.offer(t);
if (pq.size() > n) pq.poll(); // rimuoviamo il più piccolo
},
(pq1, pq2) -> {
pq2.forEach(t -> {
pq1.offer(t);
if (pq1.size() > n) pq1.poll();
});
return pq1;
},
pq -> {
List<T> result = new ArrayList<>(pq);
result.sort(comparator.reversed()); // in ordine decrescente
return result;
},
Collector.Characteristics.UNORDERED
);
}
Utilizzo:
List<Integer> top3 = Stream.of(5, 1, 9, 3, 7, 2).collect(topN(3, Comparator.naturalOrder()));
// top3: [9, 7, 5]
4. Quando NON conviene scrivere un proprio Collector
- Se il problema può essere espresso tramite una combinazione di collector standard e operazioni downstream (groupingBy, mapping, flatMapping, collectingAndThen ecc.), è meglio usare quelli.
- Un Collector personalizzato serve solo per scenari davvero non standard (struttura dati particolare, aggregazione complessa, top‑N, multi‑mappe, ecc.).
- Non scrivere un Collector solo per il gusto di farlo — complica manutenzione e test.
Esempio:
// Invece di un Collector personalizzato per Map<K, Set<V>>:
.collect(Collectors.groupingBy(
Pair::key,
Collectors.mapping(Pair::value, Collectors.toSet())
))
5. Spliterator personalizzato: perché e come
Spliterator — è un'interfaccia speciale per l'iterazione e la suddivisione efficiente di collezioni (o di altre sorgenti di dati) in parti, soprattutto per l'elaborazione parallela. A differenza di un iteratore normale, uno Spliterator può “dividere” (split) una collezione in pezzi indipendenti per l'elaborazione parallela.
Metodi chiave:
- tryAdvance(Consumer<? super T> action) — elabora l'elemento successivo.
- trySplit() — tenta di dividere la collezione in due parti (restituisce un nuovo Spliterator per una delle parti).
- estimateSize() — stima del numero di elementi rimanenti.
- characteristics() — maschera di bit delle caratteristiche (ORDERED, SIZED, SUBSIZED ecc.).
trySplit: strategie di suddivisione
Suddivisione bilanciata — è importante per gli stream paralleli: trySplit dovrebbe restituire parti di dimensioni circa uguali, così che i thread siano caricati in modo uniforme.
Se non c'è nulla da dividere (per esempio, pochi elementi) — restituiamo null.
Esempio: Spliterator per leggere un file a porzioni
Supponiamo di avere un file grande e di volerlo elaborare per 1000 righe alla volta (a porzioni), per non tenere tutto in memoria.
public class ChunkedLineSpliterator implements Spliterator<List<String>> {
private final BufferedReader reader;
private final int chunkSize;
public ChunkedLineSpliterator(BufferedReader reader, int chunkSize) {
this.reader = reader;
this.chunkSize = chunkSize;
}
@Override
public boolean tryAdvance(Consumer<? super List<String>> action) {
List<String> chunk = new ArrayList<>(chunkSize);
try {
String line;
for (int i = 0; i < chunkSize && (line = reader.readLine()) != null; i++) {
chunk.add(line);
}
if (chunk.isEmpty()) return false;
action.accept(chunk);
return true;
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Override
public Spliterator<List<String>> trySplit() {
// Per la lettura in streaming da file la suddivisione non ha senso — restituiamo null
return null;
}
@Override
public long estimateSize() {
return Long.MAX_VALUE; // sconosciuto a priori
}
@Override
public int characteristics() {
return ORDERED | NONNULL;
}
}
Utilizzo:
try (BufferedReader reader = Files.newBufferedReader(Path.of("big.txt"))) {
StreamSupport.stream(new ChunkedLineSpliterator(reader, 1000), false)
.forEach(chunk -> processChunk(chunk));
}
Caratteristiche di Spliterator
- ORDERED — gli elementi seguono un ordine definito (per esempio, una lista).
- SIZED — è nota la quantità esatta di elementi.
- SUBSIZED — tutti gli Spliterator ottenuti tramite trySplit sono anch'essi SIZED.
- IMMUTABLE — la sorgente non cambia durante l'iterazione.
- CONCURRENT — la sorgente supporta modifiche parallele sicure.
- DISTINCT, SORTED, NONNULL — proprietà aggiuntive.
Importante: indicare correttamente le caratteristiche influisce sull'ottimizzazione degli stream.
6. Esempi
- Lettura del file a porzioni (chunk) — consente di elaborare file grandi a pezzi, senza caricare tutto in memoria.
- Parsing senza allocazioni superflue — se stai facendo il parsing di uno stream di byte/caratteri e vuoi minimizzare la creazione di oggetti temporanei, puoi implementare uno Spliterator che restituisce “finestre” o “slice” dell'array sorgente.
Esempio: Spliterator per il parsing di un CSV riga per riga
public class CsvLineSpliterator implements Spliterator<String[]> {
private final BufferedReader reader;
public CsvLineSpliterator(BufferedReader reader) {
this.reader = reader;
}
@Override
public boolean tryAdvance(Consumer<? super String[]> action) {
try {
String line = reader.readLine();
if (line == null) return false;
action.accept(line.split(","));
return true;
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Override
public Spliterator<String[]> trySplit() {
return null; // parsing sequenziale
}
@Override
public long estimateSize() {
return Long.MAX_VALUE;
}
@Override
public int characteristics() {
return ORDERED | NONNULL;
}
}
7. Integrazione con parallel() — come farlo in modo sicuro
- Se il tuo Spliterator supporta la suddivisione parallela (trySplit non restituisce null) e le caratteristiche includono SIZED/SUBSIZED, allora lo Stream API potrà parallelizzare l'elaborazione in modo efficace.
- Per le sorgenti stream (file, socket) di solito la suddivisione non è supportata — usa stream sequenziali.
- Per collezioni e array — implementa una suddivisione bilanciata (ad esempio, dividi l'array a metà).
Esempio: Spliterator per un array
public class ArraySpliterator<T> implements Spliterator<T> {
private final T[] array;
private int start, end;
public ArraySpliterator(T[] array, int start, int end) {
this.array = array;
this.start = start;
this.end = end;
}
@Override
public boolean tryAdvance(Consumer<? super T> action) {
if (start < end) {
action.accept(array[start++]);
return true;
}
return false;
}
@Override
public Spliterator<T> trySplit() {
int mid = (start + end) >>> 1;
if (mid == start) return null;
ArraySpliterator<T> split = new ArraySpliterator<>(array, start, mid);
start = mid;
return split;
}
@Override
public long estimateSize() {
return end - start;
}
@Override
public int characteristics() {
return ORDERED | SIZED | SUBSIZED | IMMUTABLE;
}
}
Utilizzo:
String[] arr = {"a", "b", "c", "d"};
StreamSupport.stream(new ArraySpliterator<>(arr, 0, arr.length), true)
.forEach(System.out::println);
GO TO FULL VERSION