W3docs

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ún List — normalmente mutable, pero sin garantía. Para la forma no modificable que usarás la mayor parte del tiempo, utiliza stream.toList() (el terminal, no el collector).
  • Collectors.toSet() no garantiza orden — típicamente HashSet. Si necesitas un orden de iteración estable, pídelo explícitamente con toCollection(LinkedHashSet::new).
  • Collectors.toUnmodifiableList() y toUnmodifiableSet() (Java 10+) devuelven resultados inmutables — son los equivalentes en forma de collector de stream.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 + newAge

Para 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:

ProduceCon clave duplicada
toMapMap<K, V> (un V por K)Lanza excepción salvo que pases un fusionador
groupingByMap<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) — promedio Double.
  • minBy(cmp) / maxBy(cmp) — extremo Optional<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 seaUsa
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íficaCollectors.toCollection(supplier)
Map<K, V> uno a unoCollectors.toMap(k, v) (+ fusionador si es necesario)
Map<K, List<T>> cubosCollectors.groupingBy(k)
Map<Boolean, List<T>>Collectors.partitioningBy(pred)
Cadena únicaCollectors.joining(delim, pre, suf)
Count/sum/avg por cubogroupingBy(k, counting() / summingInt(...) / ...)
Proyección por cubogroupingBy(k, mapping(proj, toList()))
Extremo por cubogroupingBy(k, minBy(cmp) / maxBy(cmp))
Reducción personalizada por cubogroupingBy(k, reducing(...))
Dos resultados en una sola pasadaCollectors.teeing(c1, c2, merger)
Hacer el resultado no modificableenvolver 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.

java— editable, runs on the server

Lo que se puede observar al ejecutarlo:

  • El toMap(Person::name, Person::age) sin protección al final lanzó IllegalStateException porque dos Persons comparten el nombre "Alice". La solución estándar es un tercer argumento: un BinaryOperator<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 llamada ageByName anterior con (a, b) -> a.
  • groupingBy(Person::role) produjo un Map<String, List<Person>> sin esfuerzo. Cambiar el toList() downstream por defecto a counting(), summingInt(...), averagingDouble(...) o maxBy(...) 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, no Persons completos." Pre-proyectar en el downstream es casi siempre más limpio que recopilar registros completos y luego mapear los valores.
  • partitioningBy devolvió las claves true y false incluso cuando una mitad podría haber estado vacía. Esa previsibilidad es su razón de ser frente a groupingBy(predicate).
  • teeing recopiló min y max en una sola pasada, luego entregó ambos Optionals a un fusionador que construyó el registro Range. Siempre que de otro modo harías el stream dos veces para obtener dos resúmenes, recurre a teeing.
  • collectingAndThen(toList(), Collections::unmodifiableList) es el truco clásico de finalizador; la misma forma desenvuelve un groupingBy(..., maxBy(...)) de Map<K, Optional<V>> a Map<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.

Práctica

Práctica
`people.stream().collect(Collectors.toMap(Person::name, Person::age))` lanza `IllegalStateException` cuando dos `Person`s comparten nombre. ¿Qué solución corresponde a la intención de 'sumar sus edades cuando los nombres colisionan'?
`people.stream().collect(Collectors.toMap(Person::name, Person::age))` lanza `IllegalStateException` cuando dos `Person`s comparten nombre. ¿Qué solución corresponde a la intención de 'sumar sus edades cuando los nombres colisionan'?
Was this page helpful?