Java Stream Collectors
Reduce streams de Java a colecciones y otros resultados con java.util.stream.Collectors.
collect es el terminal que habíamos aplazado. Recibe un Collector<T, A, R> — una receta para acumular los elementos del stream en un resultado R a través de un contenedor intermedio A — y lo ejecuta. Las recetas viven en la clase fábrica java.util.stream.Collectors, y cubren la mayor parte de lo que de otro modo escribirías a mano con un bucle for, un Map y unas cuantas llamadas a compute*. Una vez que puedes leer groupingBy(..., counting()), la API deja de parecer críptica.
El capítulo recorre la caja de herramientas según el resultado que quieras obtener: una lista, un conjunto, un mapa, un único número, una cadena, o — mediante el patrón downstream — una combinación anidada de cualquiera de ellos.
Listas, conjuntos y colecciones específicas
Los dos básicos:
List<String> list = words.stream().collect(Collectors.toList());
Set<String> set = words.stream().collect(Collectors.toSet());Notas:
Collectors.toList()devuelve algúnList— normalmente mutable, pero sin garantía. Para la forma no modificable que usarás la mayor parte del tiempo, utilizastream.toList()(el terminal, no el collector).Collectors.toSet()no garantiza orden — típicamenteHashSet. Si necesitas un orden de iteración estable, pídelo explícitamente contoCollection(LinkedHashSet::new).Collectors.toUnmodifiableList()ytoUnmodifiableSet()(Java 10+) devuelven resultados inmutables — son los equivalentes en forma de collector destream.toList().
Para una implementación específica, usa toCollection:
ArrayDeque<String> queue = words.stream()
.collect(Collectors.toCollection(ArrayDeque::new));
TreeSet<String> sorted = words.stream()
.collect(Collectors.toCollection(TreeSet::new));El supplier es una referencia a constructor; el collector lo conecta, drena el stream en él y lo devuelve.
toMap — asocia cada elemento a una clave
toMap(keyMapper, valueMapper) convierte cada elemento en un Map.Entry y los acumula:
Map<String, Integer> nameAge = people.stream()
.collect(Collectors.toMap(Person::name, Person::age));Las claves duplicadas lanzan IllegalStateException. Esa es la regla que sorprende a todos la primera vez. Si dos Persons comparten nombre, el toMap por defecto falla. La solución es la sobrecarga con función de fusión:
Map<String, Integer> sumAgePerName = people.stream()
.collect(Collectors.toMap(
Person::name,
Person::age,
Integer::sum)); // merge: existingAge + newAgePara un tipo de mapa específico — LinkedHashMap para preservar el orden de inserción, TreeMap para mantener las claves ordenadas — pasa un supplier:
Map<String, Integer> ordered = people.stream()
.collect(Collectors.toMap(
Person::name, Person::age,
(a, b) -> a, // keep first on collision
LinkedHashMap::new));toUnmodifiableMap es la variante inmutable (Java 10+).
groupingBy — divide en cubos por clave
El collector al que todos recurren cuando se dan cuenta de que toMap no es la herramienta adecuada:
Map<String, List<Person>> byRole = people.stream()
.collect(Collectors.groupingBy(Person::role));Para cada elemento, el clasificador produce una clave, y el elemento se añade al cubo de esa clave (downstream por defecto: toList()). Comparado con toMap:
| Produce | Con clave duplicada | |
|---|---|---|
toMap | Map<K, V> (un V por K) | Lanza excepción salvo que pases un fusionador |
groupingBy | Map<K, List<V>> (un cubo por K) | Añade al cubo |
Usa toMap cuando por diseño hay a lo sumo un valor por clave (id → fila, código → etiqueta). Usa groupingBy cuando hay varios.
El verdadero poder de groupingBy proviene de su parámetro downstream, que indica qué hacer con los elementos que comparten una clave. El valor por defecto es toList; puedes sustituirlo por otro collector — y ese collector puede a su vez ser un groupingBy. La sección "downstream" del capítulo más abajo es donde la API realmente se abre.
partitioningBy — cubo por predicado
Un groupingBy especializado para predicados binarios. Devuelve un Map<Boolean, List<T>>:
Map<Boolean, List<Person>> adultsOrNot = people.stream()
.collect(Collectors.partitioningBy(p -> p.age() >= 18));
List<Person> adults = adultsOrNot.get(true);
List<Person> minors = adultsOrNot.get(false);partitioningBy siempre contiene las claves true y false, incluso si un cubo está vacío. Eso es lo que te ofrece frente a groupingBy(p -> p.age() >= 18) — que omitiría la clave si el cubo estuviese vacío.
Al igual que groupingBy, partitioningBy acepta un collector downstream.
counting, summingInt, averagingDouble, minBy, maxBy
Los collectors downstream que producen un único número por cubo:
Map<String, Long> headcount = people.stream()
.collect(Collectors.groupingBy(Person::role, Collectors.counting()));
Map<String, Integer> totalAgePerRole = people.stream()
.collect(Collectors.groupingBy(Person::role,
Collectors.summingInt(Person::age)));
Map<String, Double> avgAgePerRole = people.stream()
.collect(Collectors.groupingBy(Person::role,
Collectors.averagingDouble(Person::age)));
Map<String, Optional<Person>> oldestPerRole = people.stream()
.collect(Collectors.groupingBy(Person::role,
Collectors.maxBy(Comparator.comparingInt(Person::age))));counting()—Long, el tamaño del cubo.summingInt/Long/Double(toX)— suma del primitivo proyectado.averagingInt/Long/Double(toX)— promedioDouble.minBy(cmp)/maxBy(cmp)— extremoOptional<T>.summarizingInt/Long/Double(toX)—IntSummaryStatistics/ etc., el paquete completo count/sum/min/max/average.
joining — concatenar cadenas
Para streams de CharSequence:
String csv = words.stream().collect(Collectors.joining(","));
String pretty = words.stream().collect(Collectors.joining(", ", "[", "]"));Tres sobrecargas: sin argumentos (solo concatena), con un delimitador, con delimitador + prefijo + sufijo. Más rápido que reduce("", String::concat) porque usa un StringBuilder internamente y no asigna memoria de forma cuadrática. La herramienta correcta siempre que el resultado del pipeline sea una única cadena.
mapping — transformar y luego recopilar
Envuelve otro collector para que los elementos se transformen primero. El uso más común es dentro de groupingBy cuando quieres agrupar por un campo y recopilar una proyección de los elementos en lugar de los elementos completos:
Map<String, List<String>> namesByRole = people.stream()
.collect(Collectors.groupingBy(
Person::role,
Collectors.mapping(Person::name, Collectors.toList())));Sin mapping, el toList() downstream recopilaría Persons completos; con mapping(Person::name, ...), recopila solo los nombres. Úsalo siempre que de otro modo escribirías groupingBy(...).entrySet().stream().map(...).collect(...) en dos pasadas consecutivas.
filtering (Java 9+) es el envoltorio correspondiente para "descartar algunos antes de recopilar":
Map<String, List<Person>> adultsByRole = people.stream()
.collect(Collectors.groupingBy(
Person::role,
Collectors.filtering(p -> p.age() >= 18, Collectors.toList())));La diferencia respecto a stream.filter(...) antes del collector: filtering mantiene la clave en el mapa resultado incluso cuando ningún elemento pasa — su cubo simplemente queda vacío.
reducing — reducción general completa como collector
La forma de collector de reduce, usada como downstream cuando los estándar no se ajustan:
Map<String, Optional<Person>> oldestPerRole = people.stream()
.collect(Collectors.groupingBy(
Person::role,
Collectors.reducing(BinaryOperator.maxBy(Comparator.comparingInt(Person::age)))));Hay tres sobrecargas (de un argumento, de dos con identidad, de tres con identidad + mapeador + acumulador) que coinciden con las tres formas de reduce del capítulo anterior. La forma de dos argumentos es la más común como downstream porque devuelve un T simple en lugar de un Optional<T>.
Raramente escribes reducing en la parte superior de un pipeline — reduce es el terminal para eso. Lo escribes como downstream de groupingBy/partitioningBy cuando quieres reducción por cubo.
collectingAndThen — posprocesar el resultado
Envuelve un collector con una función de finalización. El uso estándar es hacer que un List/Map recopilado sea no modificable, o extraer un valor final de un resultado summarizing*:
List<String> immutableNames = people.stream()
.map(Person::name)
.collect(Collectors.collectingAndThen(
Collectors.toList(),
Collections::unmodifiableList));
Map<String, Long> immutableCounts = people.stream()
.collect(Collectors.collectingAndThen(
Collectors.groupingBy(Person::role, Collectors.counting()),
Collections::unmodifiableMap));También es la forma de convertir groupingBy(..., minBy(...)) en un valor simple en lugar de Optional<T> — el finalizador desenvuelve el Optional con un valor por defecto conocido.
teeing — ejecutar dos collectors en una sola pasada
(Java 12+) Alimenta cada elemento a dos collectors a la vez y combina sus resultados:
record Range(int min, int max) {}
Range range = nums.stream()
.collect(Collectors.teeing(
Collectors.minBy(Integer::compare),
Collectors.maxBy(Integer::compare),
(lo, hi) -> new Range(lo.orElseThrow(), hi.orElseThrow())));Los dos collectors hijos reciben cada elemento; el fusionador recibe sus dos resultados. Útil cuando de otro modo harías el stream dos veces — por ejemplo, calcular el promedio y detectar los valores atípicos.
Elegir el collector adecuado
| Quieres que el resultado sea | Usa |
|---|---|
List<T> (inmutable, caso común) | stream.toList() (terminal) |
List<T> (mutable) | Collectors.toList() o toCollection(ArrayList::new) |
Set<T> | Collectors.toSet() o toCollection(LinkedHashSet::new) |
| Colección específica | Collectors.toCollection(supplier) |
Map<K, V> uno a uno | Collectors.toMap(k, v) (+ fusionador si es necesario) |
Map<K, List<T>> cubos | Collectors.groupingBy(k) |
Map<Boolean, List<T>> | Collectors.partitioningBy(pred) |
| Cadena única | Collectors.joining(delim, pre, suf) |
| Count/sum/avg por cubo | groupingBy(k, counting() / summingInt(...) / ...) |
| Proyección por cubo | groupingBy(k, mapping(proj, toList())) |
| Extremo por cubo | groupingBy(k, minBy(cmp) / maxBy(cmp)) |
| Reducción personalizada por cubo | groupingBy(k, reducing(...)) |
| Dos resultados en una sola pasada | Collectors.teeing(c1, c2, merger) |
| Hacer el resultado no modificable | envolver en collectingAndThen(c, Collections::unmodifiableList) |
Un ejemplo completo: todos los collectors con un mismo conjunto de datos
El programa siguiente construye una lista de registros Person y ejecuta cada forma de collector sobre ella.
Lo que se puede observar al ejecutarlo:
- El
toMap(Person::name, Person::age)sin protección al final lanzóIllegalStateExceptionporque dosPersons comparten el nombre "Alice". La solución estándar es un tercer argumento: unBinaryOperator<V>que indica cómo fusionar valores cuando las claves colisionan. Elige el fusionador según tu semántica (mantener el primero, mantener el último, sumar, concatenar) — eso es lo que hizo la llamadaageByNameanterior con(a, b) -> a. groupingBy(Person::role)produjo unMap<String, List<Person>>sin esfuerzo. Cambiar eltoList()downstream por defecto acounting(),summingInt(...),averagingDouble(...)omaxBy(...)transformó el resultado por cubo de "una lista" en un único número — la misma forma de pipeline, una receta diferente en la ranura downstream.mapping(Person::name, toList())es la respuesta a "quiero agrupar por rol, pero mis cubos deben contener solo nombres, noPersons completos." Pre-proyectar en el downstream es casi siempre más limpio que recopilar registros completos y luego mapear los valores.partitioningBydevolvió las clavestrueyfalseincluso cuando una mitad podría haber estado vacía. Esa previsibilidad es su razón de ser frente agroupingBy(predicate).teeingrecopilóminymaxen una sola pasada, luego entregó ambosOptionals a un fusionador que construyó el registroRange. Siempre que de otro modo harías el stream dos veces para obtener dos resúmenes, recurre ateeing.collectingAndThen(toList(), Collections::unmodifiableList)es el truco clásico de finalizador; la misma forma desenvuelve ungroupingBy(..., maxBy(...))deMap<K, Optional<V>>aMap<K, V>cuando ya has demostrado que cada cubo no está vacío.
Qué viene a continuación
Cada collector e intermedio de la parte hasta ahora se ejecuta secuencialmente por defecto — un elemento a la vez, en orden de encuentro, en el hilo llamante. El siguiente capítulo, Java Parallel Streams, presenta la planificación alternativa — parallelStream() y stream().parallel() — qué es seguro poner dentro de un pipeline paralelo, qué no lo es (mutar estado compartido, forEach sensible al orden, reduce no asociativo), y cómo saber si el paralelismo realmente ayuda o hace el programa más lento.