1. Collectors personalizados: cuándo y cómo escribir los tuyos
En Java, la Stream API utiliza la interfaz Collector para transformar un stream en una colección o un agregado. Normalmente usas los colectores listos de la clase Collectors (toList(), toMap(), groupingBy() y otros), pero a veces necesitas algo especial — y entonces puedes escribir tu propio Collector.
Collector — es un objeto que describe cómo recolectar el resultado final a partir de los elementos del stream. Define cuatro (en realidad cinco) componentes clave:
- supplier — crea un nuevo contenedor para recolectar elementos (por ejemplo, una nueva lista o un mapa).
- accumulator — añade el siguiente elemento al contenedor.
- combiner — combina dos contenedores (¡importante para streams paralelos!).
- finisher — convierte el contenedor en el resultado final (por ejemplo, lo hace inmutable o lo transforma a otro tipo).
- characteristics — conjunto de flags que describen las propiedades del collector (p. ej., si admite paralelismo, si cambia el tipo de resultado, etc.).
Firma:
Collector<T, A, R>
- T — tipo de los elementos del stream,
- A — tipo del acumulador intermedio,
- R — tipo del resultado.
2. Ejemplo: Collector para MultiMap (Map<K, List<V>>)
Supongamos que quieres recolectar un stream de pares Pair<K, V> en Map<K, List<V>> (una multimapa), donde a cada clave le corresponde una lista de valores.
Ejemplo de implementación:
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
);
}
Uso:
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. Ejemplo: Collector para top N elementos
Supongamos que quieres recolectar un stream en una lista de los N elementos mayores (por ejemplo, top-5 en orden descendente).
Implementación:
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(); // eliminamos el menor
},
(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()); // en orden descendente
return result;
},
Collector.Characteristics.UNORDERED
);
}
Uso:
List<Integer> top3 = Stream.of(5, 1, 9, 3, 7, 2).collect(topN(3, Comparator.naturalOrder()));
// top3: [9, 7, 5]
4. Cuándo NO conviene escribir tu propio Collector
- Si puedes expresar la tarea mediante una combinación de colectores estándar y operaciones downstream (groupingBy, mapping, flatMapping, collectingAndThen y otros), es mejor usarlos.
- Un Collector propio solo es necesario para escenarios realmente no estándar (estructura de datos especial, agregación compleja, top N, multimapas, etc.).
- No escribas un Collector por el mero hecho de hacerlo — complica el mantenimiento y las pruebas.
Ejemplo:
// En lugar de tu propio Collector para Map<K, Set<V>>:
.collect(Collectors.groupingBy(
Pair::key,
Collectors.mapping(Pair::value, Collectors.toSet())
))
5. Spliterator personalizado: para qué y cómo
Spliterator — es una interfaz especial para el recorrido y la división de colecciones (u otras fuentes de datos) de forma eficiente, especialmente para el procesamiento paralelo. A diferencia de un iterador normal, un Spliterator puede «dividir» (split) la colección en partes independientes para su procesamiento en paralelo.
Métodos clave:
- tryAdvance(Consumer<? super T> action) — procesar el siguiente elemento.
- trySplit() — intentar dividir la colección en dos partes (devuelve un nuevo Spliterator para una de las partes).
- estimateSize() — estimación de la cantidad de elementos restantes.
- characteristics() — máscara de bits de características (ORDERED, SIZED, SUBSIZED y otros).
trySplit: estrategias de partición
Partición equilibrada — es importante para streams paralelos: trySplit debe devolver partes de tamaño aproximadamente igual para equilibrar la carga entre hilos.
Si no hay nada que dividir (por ejemplo, pocos elementos) — devuelve null.
Ejemplo: Spliterator para leer un archivo por porciones
Supongamos que tienes un archivo grande y quieres procesarlo de 1000 líneas cada vez (por porciones), para no mantenerlo todo en 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() {
// Para la lectura en streaming desde archivo la partición no tiene sentido — devolvemos null
return null;
}
@Override
public long estimateSize() {
return Long.MAX_VALUE; // desconocido de antemano
}
@Override
public int characteristics() {
return ORDERED | NONNULL;
}
}
Uso:
try (BufferedReader reader = Files.newBufferedReader(Path.of("big.txt"))) {
StreamSupport.stream(new ChunkedLineSpliterator(reader, 1000), false)
.forEach(chunk -> processChunk(chunk));
}
Características de Spliterator
- ORDERED — los elementos siguen un orden determinado (por ejemplo, una lista).
- SIZED — se conoce el número exacto de elementos.
- SUBSIZED — todos los Spliterator obtenidos mediante trySplit también son SIZED.
- IMMUTABLE — la fuente no cambia durante el recorrido.
- CONCURRENT — la fuente admite modificación paralela segura.
- DISTINCT, SORTED, NONNULL — propiedades adicionales.
Importante: especificar correctamente las características influye en la optimización de los streams.
6. Ejemplos
- Lectura del archivo por porciones (chunks) — permite procesar archivos grandes por partes, sin cargarlo todo en memoria.
- Parsing sin asignaciones innecesarias — si analizas un flujo de bytes/caracteres y quieres minimizar la creación de objetos temporales, puedes implementar un Spliterator que entregue «ventanas» o «segmentos» del array de origen.
Ejemplo: Spliterator para analizar CSV por líneas
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; // análisis secuencial
}
@Override
public long estimateSize() {
return Long.MAX_VALUE;
}
@Override
public int characteristics() {
return ORDERED | NONNULL;
}
}
7. Integración con parallel() — cómo hacerlo de forma segura
- Si tu Spliterator admite partición en paralelo (trySplit no devuelve null), y las características incluyen SIZED/SUBSIZED, entonces la Stream API podrá paralelizar el procesamiento de forma eficiente.
- Para fuentes de streaming (archivos, sockets) normalmente no se admite la partición — usa streams secuenciales.
- Para colecciones y arrays — implementa una partición equilibrada (por ejemplo, divide el array a la mitad).
Ejemplo: Spliterator para 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;
}
}
Uso:
String[] arr = {"a", "b", "c", "d"};
StreamSupport.stream(new ArraySpliterator<>(arr, 0, arr.length), true)
.forEach(System.out::println);
GO TO FULL VERSION