Streams en Java: de filter a Collectors expertos
Parte 3 de 3 de la serie sobre colecciones en Java. Viene de la parte 1: List no es ArrayList y la parte 2: mutabilidad, orden e igualdad.
El bucle que escribiste mil veces
Hay un bucle que todos hemos escrito cientos de veces, con pequeñas variaciones: recorrer una lista, quedarse con algunos elementos, transformarlos y guardarlos en otra lista.
List<Integer> numeros = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
List<Integer> paresAlCuadrado = new ArrayList<>();
for (Integer n : numeros) {
if (n % 2 == 0) {
paresAlCuadrado.add(n * n);
}
}
System.out.println(paresAlCuadrado);[4, 16, 36, 64, 100]Funciona. Pero para entender qué hace hay que leerlo entero: la lista vacía, el bucle, la condición, la operación, el add. Y cuando el bucle tiene tres condiciones y dos listas intermedias, leerlo cuesta más que escribirlo.
Desde Java 8 existe otra forma de decir exactamente lo mismo:
List<Integer> conStream = numeros.stream()
.filter(n -> n % 2 == 0)
.map(n -> n * n)
.toList();
System.out.println(conStream);[4, 16, 36, 64, 100]Se lee de arriba abajo como una frase: "de los números, filtra los pares, transforma cada uno en su cuadrado, y dame una lista". No hay lista vacía que crear ni add que olvidar. Eso es un stream, y este artículo va desde esa primera línea hasta las partes que casi nadie usa.
Como en las partes anteriores: todo va dentro de un main, con import java.util.*;, y ahora además import java.util.stream.*; y import java.util.function.*;. Las salidas son reales, de Java 21. Usamos el mismo Persona de siempre, con un campo más para poder sumar y promediar:
record Persona(String nombre, int edad, String ciudad, double salario) {}
List<Persona> personas = List.of(
new Persona("Ana", 31, "San José", 1800),
new Persona("Luis", 45, "Heredia", 2500),
new Persona("Marta", 28, "Alajuela", 1500),
new Persona("Luis", 29, "Cartago", 1700),
new Persona("Rosa", 52, "San José", 3100),
new Persona("Diego", 35, "Heredia", 2100));1. Qué es un stream (y qué no es)
Qué es. Un stream es una secuencia de elementos que pasa por una cadena de operaciones. No es una colección: no guarda nada. Es más bien una descripción de "qué hacer con cada elemento" que se ejecuta cuando pides el resultado.
Toda cadena de stream tiene tres partes:
Cada elemento recorre la cadena completa antes de que entre el siguiente: el 1 pasa por filter y se descarta; el 2 pasa por filter, por map y llega a toList. Nada se ejecuta hasta que hay una operación terminal.
- La fuente: de dónde salen los elementos.
lista.stream(),Stream.of(...),IntStream.rangeClosed(1, 10). - Operaciones intermedias: transforman el stream en otro stream.
filter,map,sorted,distinct,limit... Puedes encadenar las que quieras. - La operación terminal: produce el resultado y cierra el stream.
toList(),count(),forEach,collect(...). Solo puede haber una.
Dos propiedades que hay que ver con los propios ojos, porque van contra la intuición.
Primera: un stream es perezoso. Las operaciones intermedias no hacen nada hasta que llega la terminal. Para verlo, imprimimos dentro de cada paso:
Stream<Integer> s = numeros.stream()
.filter(n -> { System.out.println("filter " + n); return n % 2 == 0; })
.map(n -> { System.out.println("map " + n); return n * n; });
System.out.println("-- todavía no pasó nada --");
List<Integer> r = s.limit(2).toList();
System.out.println(r);Qué pasó.
-- todavía no pasó nada --
filter 1
filter 2
map 2
filter 3
filter 4
map 4
[4, 16]Tres cosas que ver. Primero, la línea "todavía no pasó nada" salió antes de cualquier filter: definir el stream no ejecutó nada. Segundo, los elementos van de uno en uno por toda la cadena: el 2 pasó por filter y por map antes de que el 3 entrara a filter. No es "filtrar todos, luego mapear todos". Tercero, se detuvo en el 4: limit(2) solo necesitaba dos resultados y, cuando los tuvo, dejó de pedir elementos. Nunca miró del 5 al 10.
Segunda: un stream se consume una sola vez.
Stream<Integer> pares = numeros.stream().filter(n -> n % 2 == 0);
System.out.println(pares.count());
System.out.println(pares.count()); // otra vez el mismo stream5
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed"El stream ya fue operado o cerrado". Si necesitas recorrer dos veces, creas dos streams desde la colección (lista.stream() dos veces). La colección sigue ahí; el stream no.
Cuándo usarlo. Cuando la operación se describe como "de estos elementos, los que cumplen tal cosa, transformados así, reunidos en tal forma". Cuando el bucle necesita un índice, modifica varias cosas a la vez o tiene break complicados, el bucle sigue siendo mejor.
Lo que debes llevarte: un stream no guarda datos; describe un recorrido. Es perezoso, procesa de uno en uno, y se usa una sola vez.
2. Las operaciones intermedias con primos
Vamos a construir la lista de primos hasta 50 con un stream y a usarla para ver cada operación. IntStream.rangeClosed(2, 50) genera los enteros del 2 al 50; boxed() convierte el stream de int en un stream de Integer para poder guardarlos en una List. Art3::esPrimo es el mismo método de la parte 1, pasado como referencia (si tu clase se llama distinto, cambia el nombre).
List<Integer> primos = IntStream.rangeClosed(2, 50)
.filter(Art3::esPrimo)
.boxed()
.toList();
System.out.println(primos);[2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47]Y ahora, cada operación con una línea y su salida:
// map: transforma cada elemento (aquí, su cuadrado)
System.out.println(primos.stream().map(p -> p * p).toList());
// sorted: ordena (con un Comparator, en el orden que quieras)
System.out.println(primos.stream().sorted(Comparator.reverseOrder()).toList());
// limit: solo los primeros n
System.out.println(primos.stream().limit(5).toList());
// skip: salta los primeros n
System.out.println(primos.stream().skip(10).toList());
// distinct: quita repetidos (usa equals, como un Set)
System.out.println(Stream.of(3, 5, 3, 7, 5, 2).distinct().toList());
// varios filter encadenados: cada uno afina más
System.out.println(primos.stream().filter(p -> p > 10).filter(p -> p % 10 == 3).toList());[4, 9, 25, 49, 121, 169, 289, 361, 529, 841, 961, 1369, 1681, 1849, 2209]
[47, 43, 41, 37, 31, 29, 23, 19, 17, 13, 11, 7, 5, 3, 2]
[2, 3, 5, 7, 11]
[31, 37, 41, 43, 47]
[3, 5, 7, 2]
[13, 23, 43]filter recibe una función que devuelve true o false (un predicado) y deja pasar solo los true. map recibe una función que convierte cada elemento en otra cosa, del mismo tipo o de otro: map(Persona::nombre) convierte un stream de Persona en uno de String.
Lo que debes llevarte:
filterdecide quién pasa,mapdecide en qué se convierte. El resto (sorted,distinct,limit,skip) hacen lo que su nombre dice.
3. Las operaciones terminales y Optional
Qué es. La terminal es la que ejecuta todo y da un resultado. Las que más vas a usar, en tres grupos.
Reunir en una colección
List<Integer> inmutable = primos.stream().filter(p -> p < 10).toList();
inmutable.add(11);Exception in thread "main" java.lang.UnsupportedOperationExceptiontoList() (Java 16 en adelante) devuelve una lista inmutable, como List.of en la parte 2. Si necesitas modificar el resultado, la forma clásica es collect(Collectors.toList()), que devuelve una ArrayList:
List<Integer> mutable = primos.stream().filter(p -> p < 10).collect(Collectors.toList());
mutable.add(11);
System.out.println(mutable);[2, 3, 5, 7, 11]Collectors.toSet() hace lo mismo hacia un HashSet, y Collectors.joining(", ") pega textos:
System.out.println(personas.stream().map(Persona::nombre).collect(Collectors.joining(", ")));Ana, Luis, Marta, Luis, Rosa, DiegoPreguntar
System.out.println(primos.stream().filter(p -> p > 20).count());
System.out.println(primos.stream().anyMatch(p -> p % 2 == 0)); // ¿alguno es par?
System.out.println(primos.stream().allMatch(p -> p > 1)); // ¿todos son > 1?
System.out.println(primos.stream().noneMatch(p -> p > 50)); // ¿ninguno es > 50?7
true
true
trueanyMatch, allMatch y noneMatch se detienen en cuanto saben la respuesta (como limit en la sección 1): anyMatch para en el primer true.
Buscar uno: Optional
findFirst() devuelve el primer elemento que llega. Pero ¿qué devuelve si no llega ninguno? Antes de Java 8, null, y los NullPointerException que eso causa. Desde Java 8, devuelve un Optional: una caja que puede tener un valor o estar vacía, y que te obliga a decidir qué hacer en cada caso.
Optional<Integer> primero = primos.stream().filter(p -> p > 40).findFirst();
System.out.println(primero);
System.out.println(primero.isPresent() + " " + primero.get());
Optional<Integer> ninguno = primos.stream().filter(p -> p > 100).findFirst();
System.out.println(ninguno);
System.out.println(ninguno.orElse(-1));Optional[41]
true 41
Optional.empty
-1El error típico: get() sin preguntar
ninguno.get();Exception in thread "main" java.util.NoSuchElementException: No value presentget() sobre un Optional vacío lanza una excepción, igual que lo hubiera hecho el null. No ganaste nada. Las formas correctas de sacar el valor:
orElse(valorPorDefecto)cuando hay un valor razonable para "no encontrado".orElseThrow(() -> new TuExcepcion("mensaje claro"))cuando no encontrarlo es un error, y quieres decir cuál:
ninguno.orElseThrow(() -> new IllegalArgumentException("no hay primos mayores que 100"));Exception in thread "main" java.lang.IllegalArgumentException: no hay primos mayores que 100ifPresent(valor -> ...)cuando solo quieres hacer algo si está.map(...)para transformar el valor si está, sin sacarlo.
max, min y reduce sin valor inicial también devuelven Optional, por la misma razón: un stream vacío no tiene máximo.
System.out.println(personas.stream().max(Comparator.comparing(Persona::salario)));Optional[Persona[nombre=Rosa, edad=52, ciudad=San José, salario=3100.0]]Reducir a un solo valor
reduce combina todos los elementos en uno, aplicando una operación de dos en dos. Para sumar primos:
System.out.println(primos.stream().reduce(0, Integer::sum));
System.out.println(primos.stream().mapToInt(Integer::intValue).sum());328
328Las dos dan lo mismo. La segunda convierte a IntStream (un stream de int primitivos) y usa sum(), que existe solo ahí; también están average(), max(), min(). Para números, mapToInt/mapToDouble casi siempre es más claro que reduce.
Lo que debes llevarte:
toList()es inmutable;collect(toList())no.findFirst,maxymindevuelvenOptional, yget()sin preguntar es el mismo error que unnull.
4. Collectors: de la lista al Map
Aquí es donde los streams dejan de ser "un bucle más corto" y se vuelven una herramienta distinta. collect recibe un Collector: una receta de cómo reunir los elementos. Los que importan viven en la clase Collectors.
toMap: una entrada por elemento
Map<String, Integer> edadPorNombre = personas.stream()
.filter(p -> !p.nombre().equals("Luis"))
.collect(Collectors.toMap(Persona::nombre, Persona::edad));
System.out.println(edadPorNombre);{Marta=28, Ana=31, Diego=35, Rosa=52}toMap(cómoSacarLaClave, cómoSacarElValor). Filtré a Luis a propósito, porque hay dos. Mira qué pasa sin el filtro:
El error típico: claves repetidas en toMap
personas.stream().collect(Collectors.toMap(Persona::nombre, Persona::edad));Exception in thread "main" java.lang.IllegalStateException: Duplicate key Luis (attempted merging values 45 and 29)"Clave repetida Luis (intentando combinar los valores 45 y 29)". A diferencia de put en un Map, que reemplaza en silencio, toMap se niega a decidir por ti. Le dices cómo combinar con un tercer parámetro:
Map<String, Integer> mayorEdad = personas.stream()
.collect(Collectors.toMap(Persona::nombre, Persona::edad, Math::max));
System.out.println(mayorEdad);{Marta=28, Ana=31, Diego=35, Luis=45, Rosa=52}Y un cuarto parámetro elige la implementación del mapa, si necesitas orden:
Map<String, Persona> porNombre = personas.stream()
.collect(Collectors.toMap(Persona::nombre, Function.identity(), (a, b) -> a, TreeMap::new));
System.out.println(porNombre.keySet());[Ana, Diego, Luis, Marta, Rosa]Function.identity() significa "el elemento mismo" (el valor es la Persona completa). (a, b) -> a significa "si hay dos, quédate con el primero". TreeMap::new es la fábrica del mapa.
groupingBy: una lista por clave
El collector más útil de todos. Agrupa los elementos por lo que devuelva la función:
Map<String, List<Persona>> porCiudad = personas.stream()
.collect(Collectors.groupingBy(Persona::ciudad));
porCiudad.forEach((ciudad, lista) ->
System.out.println(ciudad + " -> " + lista.stream().map(Persona::nombre).toList()));San José -> [Ana, Rosa]
Alajuela -> [Marta]
Heredia -> [Luis, Diego]
Cartago -> [Luis]Es el computeIfAbsent de la parte 1, en una línea. Y el orden de las ciudades no se promete, porque el mapa es un HashMap.
Cada elemento pasa por la función de clave (Persona::ciudad) y cae en el grupo correspondiente. El segundo argumento, el collector "de abajo", decide qué se hace con cada grupo: guardar la lista, contarla, promediarla.
groupingBy con un segundo collector
Lo que hace a groupingBy tan potente es el segundo argumento: qué hacer con cada grupo en vez de guardar la lista.
// contar por ciudad
System.out.println(personas.stream()
.collect(Collectors.groupingBy(Persona::ciudad, Collectors.counting())));
// salario promedio por ciudad
System.out.println(personas.stream()
.collect(Collectors.groupingBy(Persona::ciudad, Collectors.averagingDouble(Persona::salario))));
// solo los nombres por ciudad
System.out.println(personas.stream()
.collect(Collectors.groupingBy(Persona::ciudad, Collectors.mapping(Persona::nombre, Collectors.toList()))));
// suma de salarios por ciudad
System.out.println(personas.stream()
.collect(Collectors.groupingBy(Persona::ciudad, Collectors.summingDouble(Persona::salario))));{San José=2, Alajuela=1, Heredia=2, Cartago=1}
{San José=2450.0, Alajuela=1500.0, Heredia=2300.0, Cartago=1700.0}
{San José=[Ana, Rosa], Alajuela=[Marta], Heredia=[Luis, Diego], Cartago=[Luis]}
{San José=4900.0, Alajuela=1500.0, Heredia=4600.0, Cartago=1700.0}counting() devuelve Long, no Integer: por eso un Map<String, Long>. mapping(función, collector) transforma cada elemento del grupo antes de reunirlo.
Con tres argumentos eliges el mapa (TreeMap::new para que salga ordenado), igual que en toMap. Un detalle del compilador: asigna el resultado a una variable con tipo explícito, porque dentro de un println a veces no logra inferir los tipos y falla con un error largo que no dice nada útil.
Map<String, String> nombresPorCiudad = personas.stream()
.collect(Collectors.groupingBy(Persona::ciudad, TreeMap::new,
Collectors.mapping(Persona::nombre, Collectors.joining("/"))));
System.out.println(nombresPorCiudad);
Map<Integer, Long> porDecada = personas.stream()
.collect(Collectors.groupingBy(p -> p.edad() / 10 * 10, TreeMap::new, Collectors.counting()));
System.out.println(porDecada);{Alajuela=Marta, Cartago=Luis, Heredia=Luis/Diego, San José=Ana/Rosa}
{20=2, 30=2, 40=1, 50=1}La función de clave puede ser cualquier cálculo: p.edad() / 10 * 10 convierte 31 en 30, 45 en 40 (división entera), y así agrupa por década.
partitioningBy: dos grupos, sí y no
Cuando la clave es un true/false, partitioningBy siempre devuelve las dos entradas, aunque una esté vacía:
Map<Boolean, List<String>> mayores = personas.stream()
.collect(Collectors.partitioningBy(p -> p.edad() >= 30,
Collectors.mapping(Persona::nombre, Collectors.toList())));
System.out.println(mayores);{false=[Marta, Luis], true=[Ana, Luis, Rosa, Diego]}Cuándo usar cada uno. toMap cuando cada elemento da una entrada. groupingBy cuando varios elementos comparten clave. partitioningBy cuando la pregunta es sí/no.
Lo que debes llevarte:
groupingBy(clave, collectorDeAbajo)es la operación que reemplaza a la mayoría de los bucles conMapque has escrito.
5. flatMap y los streams de primitivos
Qué es. map convierte cada elemento en uno. flatMap convierte cada elemento en un stream de varios, y los aplana todos en un solo stream. Es lo que necesitas cuando los elementos contienen listas.
Ejemplo. Pedidos con líneas:
record Linea(String producto, int cantidad, double precio) {
double total() { return cantidad * precio; }
}
record Pedido(String cliente, List<Linea> lineas) {}
List<Pedido> pedidos = List.of(
new Pedido("Ana", List.of(new Linea("Teclado", 1, 1200), new Linea("Mouse", 2, 800))),
new Pedido("Luis", List.of(new Linea("Monitor", 1, 4500))),
new Pedido("Marta", List.of(new Linea("Mouse", 1, 800), new Linea("Silla", 1, 3000), new Linea("Teclado", 1, 1200))));Con map obtienes un stream de listas (tres listas); con flatMap, un stream de líneas (seis líneas):
System.out.println(pedidos.stream().map(Pedido::lineas).toList().size());
List<Linea> todas = pedidos.stream().flatMap(p -> p.lineas().stream()).toList();
System.out.println(todas.size() + " " + todas.stream().map(Linea::producto).toList());3
6 [Teclado, Mouse, Monitor, Mouse, Silla, Teclado]Y a partir de las seis líneas, todas las preguntas del negocio son una línea cada una:
// productos distintos, ordenados
System.out.println(todas.stream().map(Linea::producto).distinct().sorted().toList());
// total vendido
System.out.println(todas.stream().mapToDouble(Linea::total).sum());
// total por pedido
pedidos.forEach(p -> System.out.println(p.cliente() + " -> " + p.lineas().stream().mapToDouble(Linea::total).sum()));
// unidades por producto
System.out.println(todas.stream().collect(Collectors.groupingBy(Linea::producto, Collectors.summingInt(Linea::cantidad))));[Monitor, Mouse, Silla, Teclado]
12300.0
Ana -> 2800.0
Luis -> 4500.0
Marta -> 5000.0
{Monitor=1, Mouse=3, Teclado=2, Silla=1}Streams de primitivos y estadísticas
IntStream, LongStream y DoubleStream son streams de números primitivos. Existen porque guardar cada número como objeto Integer cuesta memoria y tiempo, y porque tienen operaciones que un Stream<Integer> no tiene: sum, average, max, min, y summaryStatistics, que da todas de una:
DoubleSummaryStatistics stats = personas.stream().mapToDouble(Persona::salario).summaryStatistics();
System.out.println(stats);
System.out.println(stats.getAverage() + " " + stats.getMax());
System.out.println(IntStream.rangeClosed(1, 5).map(n -> n * n).sum());
System.out.println(IntStream.rangeClosed(1, 5).boxed().toList());DoubleSummaryStatistics{count=6, sum=12700.000000, min=1500.000000, average=2116.666667, max=3100.000000}
2116.6666666666665 3100.0
55
[1, 2, 3, 4, 5]Se pasa de uno a otro con mapToInt/mapToDouble (objeto → primitivo) y boxed() (primitivo → objeto).
Lo que debes llevarte: cuando cada elemento contiene una lista,
flatMap. Para números,mapToInt/mapToDoubleysummaryStatistics.
6. Nivel experto: lo que casi nadie usa y debería
Todo lo anterior cubre el 95 % del uso diario. Lo que sigue es el 5 % que distingue a quien conoce la herramienta de quien la usa.
teeing: dos collectors a la vez
Qué es. teeing(collectorA, collectorB, cómoCombinar) (Java 12) pasa cada elemento por dos collectors al mismo tiempo y combina los dos resultados. Sin él, calcular el mínimo y el máximo exigía dos recorridos.
String rango = primos.stream().collect(Collectors.teeing(
Collectors.minBy(Comparator.<Integer>naturalOrder()),
Collectors.maxBy(Comparator.<Integer>naturalOrder()),
(min, max) -> min.get() + ".." + max.get()));
System.out.println(rango);
var promedioYTotal = personas.stream().collect(Collectors.teeing(
Collectors.averagingDouble(Persona::salario),
Collectors.counting(),
(promedio, n) -> "promedio=" + promedio + " de " + n + " personas"));
System.out.println(promedioYTotal);2..47
promedio=2116.6666666666665 de 6 personas(Comparator.<Integer>naturalOrder() lleva el tipo explícito por la misma razón que antes: a veces el compilador no lo infiere dentro de teeing.)
Un Collector propio
Qué es. Un collector son cuatro funciones: cómo crear el contenedor, cómo agregar un elemento, cómo combinar dos contenedores (para el caso paralelo) y cómo terminar. Collector.of las recibe en ese orden. Uno que pega nombres entre corchetes usando StringJoiner:
Collector<String, StringJoiner, String> entreCorchetes = Collector.of(
() -> new StringJoiner(", ", "[", "]"), // 1. crear el contenedor
StringJoiner::add, // 2. agregar un elemento
StringJoiner::merge, // 3. combinar dos contenedores
StringJoiner::toString); // 4. producir el resultado
System.out.println(personas.stream().map(Persona::nombre).collect(entreCorchetes));[Ana, Luis, Marta, Luis, Rosa, Diego]Este en particular ya existe (Collectors.joining(", ", "[", "]")); lo escribí para mostrar las cuatro piezas. Cuando lo necesitas de verdad es para reunir en una estructura tuya, o para una acumulación que ningún Collectors cubre.
Fuentes infinitas: iterate y generate
// iterate con condición de parada (Java 9): potencias de 2 hasta 100
System.out.println(Stream.iterate(1, n -> n <= 100, n -> n * 2).toList());
// iterate infinito + limit: los primeros 10 de Fibonacci
System.out.println(Stream.iterate(new int[]{0, 1}, f -> new int[]{f[1], f[0] + f[1]})
.limit(10).map(f -> f[0]).toList());
// generate: llama a la función cada vez que necesita un elemento
Random azar = new Random(42);
System.out.println(Stream.generate(() -> azar.nextInt(10)).limit(5).toList());[1, 2, 4, 8, 16, 32, 64]
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
[0, 3, 8, 4, 0]Un stream infinito sin limit ni takeWhile nunca termina. Esto funciona solo porque el stream es perezoso (sección 1): limit(10) deja de pedir elementos después del décimo.
takeWhile y dropWhile
(Java 9.) takeWhile toma elementos mientras se cumpla la condición y para en el primero que no; dropWhile los descarta mientras se cumpla y se queda con el resto. La diferencia con filter importa:
System.out.println(primos.stream().takeWhile(p -> p < 20).toList());
System.out.println(primos.stream().dropWhile(p -> p < 20).toList());
System.out.println(Stream.of(5, 1, 30, 2, 40).takeWhile(n -> n < 20).toList());[2, 3, 5, 7, 11, 13, 17, 19]
[23, 29, 31, 37, 41, 43, 47]
[5, 1]La tercera línea es la que lo explica: filter(n -> n < 20) daría [5, 1, 2], pero takeWhile se detuvo en el 30 y nunca vio el 2. Es útil sobre datos ordenados o infinitos.
mapMulti: flatMap sin crear streams
(Java 16.) Cuando flatMap produce pocos elementos por cada entrada, crear un stream por elemento es desperdicio. mapMulti te da una función emitir y tú decides cuántos elementos sacar de cada uno, cero incluidos:
List<String> productos = pedidos.stream().<String>mapMulti((pedido, emitir) -> {
for (Linea l : pedido.lineas()) {
if (l.cantidad() > 1) {
emitir.accept(l.producto() + " x" + l.cantidad());
}
}
}).toList();
System.out.println(productos);[Mouse x2]Es flatMap y filter en un solo paso. El <String> antes de mapMulti le dice al compilador el tipo del resultado, que no puede adivinar.
Y lo que viene: Gatherers
Java 24 (marzo de 2025) agregó Stream.gather(...) con los Gatherers: operaciones intermedias que puedes escribir tú, como windowFixed(3) (agrupar de tres en tres) o fold. Está fuera de la base de Java 21 de esta serie, pero si trabajas con Java 24 o posterior, es el siguiente tema que leer.
Lo que debes llevarte:
teeingpara dos resultados en un recorrido,takeWhilesobre datos ordenados,mapMulticuandoflatMapproduce poco. YCollector.ofson cuatro funciones.
7. Las trampas
Tres errores que no fallan en compilación ni lanzan excepción, y por eso llegan a producción.
Efectos secundarios en el pipeline
Un efecto secundario es modificar algo fuera del stream desde adentro de una operación. Funciona en secuencial y se rompe en paralelo:
List<Integer> efectos = new ArrayList<>();
IntStream.rangeClosed(1, 1000).parallel().forEach(efectos::add);
System.out.println("con efecto secundario: " + efectos.size() + " (esperado 1000)");
List<Integer> bien = IntStream.rangeClosed(1, 1000).parallel().boxed().toList();
System.out.println("sin efecto secundario: " + bien.size());con efecto secundario: 874 (esperado 1000)
sin efecto secundario: 1000Es el ArrayList compartido de la parte 2, disfrazado de stream. Si el resultado tiene que salir de un stream, sale por la terminal (toList, collect), nunca agregando a una lista de afuera.
parallel() no es gratis ni es siempre más rápido
parallel() reparte el trabajo entre varios hilos. Con datos pequeños, coordinar los hilos cuesta más que el trabajo. Y el orden de forEach deja de estar garantizado:
StringBuilder sb = new StringBuilder();
IntStream.rangeClosed(1, 10).parallel().forEach(n -> sb.append(n).append(' '));
System.out.println("forEach paralelo: " + sb);
StringBuilder sb2 = new StringBuilder();
IntStream.rangeClosed(1, 10).parallel().forEachOrdered(n -> sb2.append(n).append(' '));
System.out.println("forEachOrdered: " + sb2);forEach paralelo: 7 6 3 8 5 10
forEachOrdered: 1 2 3 4 5 6 7 8 9 10Mira bien la primera línea: no solo está desordenada; faltan cuatro números. StringBuilder no es seguro entre hilos, y perdió escrituras. En tu máquina saldrá otra cosa. forEachOrdered respeta el orden (y de paso serializa el acceso), pero con eso perdiste la razón de ir en paralelo. En cambio, collect(Collectors.joining(" ")) en paralelo sí da 1 2 3 ... 10, porque los collectors están diseñados para combinar resultados parciales en orden.
Regla: usa parallel() solo con muchos elementos, trabajo pesado por elemento, sin efectos secundarios, y después de medir.
Modificar la colección mientras el stream la recorre
List<Integer> base = new ArrayList<>(List.of(1, 2, 3, 4));
base.stream().forEach(n -> { if (n == 2) base.add(99); });Exception in thread "main" java.util.ConcurrentModificationExceptionLa misma excepción del for-each de la parte 1, por la misma razón. El stream recorre la colección con su iterador; nadie puede modificarla mientras tanto.
peek es para mirar, no para hacer
peek ejecuta una acción con cada elemento y lo deja pasar sin cambiarlo. Sirve para depurar un pipeline:
List<Integer> conPeek = Stream.of(1, 2, 3)
.peek(n -> System.out.println("vi " + n))
.filter(n -> n > 1)
.toList();
System.out.println(conPeek);vi 1
vi 2
vi 3
[2, 3]El problema es cuando se usa para modificar los elementos o algo externo "porque queda en una línea". Java no garantiza que peek se ejecute para todos los elementos (si una terminal como count() puede calcular el resultado sin recorrer, no lo recorre). Para transformar, map; para actuar al final, forEach.
Lo que debes llevarte: nada de afuera se modifica desde adentro del pipeline.
parallel()solo con muchos datos y después de medir.peekes para ver.
Lo que debes llevarte de todo el artículo
- Un stream describe un recorrido; no guarda datos. Es perezoso, procesa de uno en uno y se usa una vez.
filterdecide quién pasa;mapdecide en qué se convierte;flatMapaplana listas de listas.toList()es inmutable;collect(Collectors.toList())no.findFirst,max,mindevuelvenOptional;orElseyorElseThrowson las formas correctas de abrirlo.toMapfalla con claves repetidas hasta que le dices cómo combinar;groupingBy(clave, collector)reemplaza casi todos los bucles conMap.- Para números,
mapToInt/mapToDoubleysummaryStatistics. teeing,takeWhile,mapMultiyCollector.ofson el 5 % que vale la pena conocer.- Sin efectos secundarios en el pipeline,
parallel()solo después de medir, ypeeksolo para mirar.
Lo que debes llevarte de toda la serie
Tres artículos, una idea de fondo: una colección es una promesa. List promete posiciones; Set, sin repetidos; Map, buscar por clave. Hash promete rapidez, Tree promete orden, Linked promete el orden de llegada. List.of promete que nadie la cambia. ConcurrentHashMap promete que merge es atómico. Un stream promete recorrer de uno en uno, sin tocar nada de afuera.
Elegir bien es leer esas promesas y quedarse con la que necesitas, ni una más. Y la línea con la que empezamos, List<String> nombres = new ArrayList<>(), ahora se lee entera: a la izquierda, la promesa; a la derecha, quien la cumple.
¿Cuál es el bucle con Map que todavía escribes a mano y que, después de leer esto, vas a cambiar por un groupingBy? Me interesa leerlo en los comentarios.
Referencias
- Oracle, Java 21 API: java.util.stream. Incluye la sección sobre efectos secundarios y paralelismo.
- Oracle, Java 21 API: Collectors.
- Oracle, Java 21 API: Optional.
- JEP 485, Stream Gatherers, Java 24.