Introducción a los Streams de Java
Introducción al Stream API de Java para procesar secuencias de elementos con operaciones de estilo funcional.
Un stream es un pipeline que lleva los elementos de una fuente a través de una secuencia de operaciones y produce un resultado. No es una estructura de datos — no almacena nada. Es una receta declarativa para procesar datos, evaluada de forma perezosa, ejecutada una sola vez. Los streams llegaron en Java 8 junto con las lambdas, y ambos fueron diseñados para encajar: cada operación de stream acepta una función, y el lenguaje te dio una forma clara de escribirla.
La forma que escribirás cientos de veces:
double avgAdultAge = people.stream()
.filter(p -> p.age() >= 18)
.mapToInt(Person::age)
.average()
.orElse(0.0);Tres cosas a notar. El pipeline se lee de arriba a abajo como pasos que describen qué quieres, no cómo iterar. Cada paso toma una función — un Predicate, un ToIntFunction — exactamente el vocabulario que los capítulos anteriores establecieron. Y el resultado sale de una única operación terminal; no hay bucle, no hay acumulador, no hay continue anticipado.
La forma del pipeline: fuente → intermedia → terminal
Todo pipeline de stream tiene tres partes:
- Una fuente. De dónde provienen los elementos. Generalmente una colección (
coll.stream()), ocasionalmente un literal (Stream.of(\"a\", \"b\")), un array (Arrays.stream(arr)), un rangoIntStream(IntStream.range(0, 100)), una fuente de I/O (Files.lines(path)), o un generador (Stream.iterate,Stream.generate). El siguiente capítulo está dedicado a todos ellos. - Cero o más operaciones intermedias. Cada una devuelve otro stream, por lo que se encadenan. Las más comunes:
filter,map,flatMap,distinct,sorted,limit,skip,peek. Son perezosas — llamar afilterno evalúa nada todavía; simplemente registra el predicado. - Exactamente una operación terminal. Activa el pipeline. Ejemplos:
forEach,collect,toList,count,sum,min,max,reduce,findFirst,anyMatch. La terminal produce un valor (o un efecto secundario en el caso deforEach) y consume el stream — no puedes reutilizarlo.
list.stream() // SOURCE
.filter(...) // intermediate
.map(...) // intermediate
.sorted() // intermediate
.toList(); // TERMINAL — runs the pipelineSin la terminal, no ocurre nada. Un stream que construyes y nunca terminas es peso muerto — no se realiza ningún trabajo, no se disparan efectos secundarios, las lambdas no se ejecutan.
Perezoso por diseño
Las operaciones intermedias son perezosas porque la JVM no sabe qué elementos necesitas realmente hasta que la terminal los solicita. Eso permite dos optimizaciones importantes:
Fusión. Los intermedios adyacentes se ejecutan juntos en un solo paso, no un paso por operación. stream.filter(p).map(f) no construye una lista filtrada intermedia y luego la mapea; evalúa un elemento, y si sobrevive, lo mapea, todo en un solo paso.
Cortocircuito. Una terminal como findFirst, anyMatch, o limit(n) detiene el pipeline tan pronto como tiene su respuesta. Combinado con la pereza, esto significa que puedes ejecutar un pipeline de "encuentra el primer cuadrado par mayor que 100" sobre un stream infinito y obtener una respuesta en microsegundos:
int answer = Stream.iterate(1, n -> n + 1) // 1, 2, 3, 4, ...
.map(n -> n * n) // 1, 4, 9, 16, ...
.filter(n -> n % 2 == 0 && n > 100) // first match wins
.findFirst()
.orElseThrow();
// answer = 144Stream.iterate(1, n -> n + 1) es infinito, pero findFirst solo solicitó elementos hasta que uno coincidió. El pipeline evaluó 12 cuadrados (1, 4, 9, ..., 144) y se detuvo.
De un solo uso, como un Iterator
Un Stream puede recorrerse una sola vez. La terminal lo consume, y después de eso el objeto stream está cerrado; llamar a otra terminal sobre él lanza IllegalStateException:
Stream<String> s = list.stream();
long c1 = s.count(); // ok
long c2 = s.count(); // throws IllegalStateException — stream has already been operated uponSi necesitas procesar los mismos datos dos veces, construye el stream dos veces:
long c1 = list.stream().count();
long c2 = list.stream().count();Esto coincide con la forma en que funciona Iterator. El objeto stream es el cursor en movimiento, no los datos. Los datos son la fuente — volver a crear el stream es gratuito.
Streams vs colecciones — trabajos diferentes
| Aspecto | Colección | Stream |
|---|---|---|
| ¿Almacena datos? | Sí | No |
| ¿Reutilizable? | Sí | No (una sola terminal) |
| ¿Ansioso o perezoso? | Ansioso | Perezoso hasta la terminal |
| ¿Modifica la fuente? | Sí (ej. list.add) | No — los pipelines son de solo lectura |
| ¿Itera explícitamente? | A menudo (for, iterator()) | No — el pipeline conduce la iteración |
| Modelo de coste | Gestión por elemento | Un solo paso a través de la fuente |
Una colección es un contenedor; un stream es una computación sobre un contenedor (u otra fuente). Se complementan: obtienes datos de una colección, ejecutas un pipeline de stream y recopilas el resultado en una colección (generalmente diferente).
Tres pequeños ejemplos que escribirás todo el tiempo
Contar elementos que coincidan con un predicado:
long adults = people.stream().filter(p -> p.age() >= 18).count();Construir una lista de valores transformados:
List<String> names = people.stream().map(Person::name).toList();Reducir a un único valor:
int totalAge = people.stream().mapToInt(Person::age).sum();Estos tres patrones — count, map-to-list, reduce-to-scalar — cubren la mayoría de los usos del API. El resto de la parte es un recorrido por las operaciones que completan el cómo para cada uno.
Tres cosas que los streams no son
- No son un reemplazo para los bucles
foren general. Un bucle que construye algo con flujo de control no trivial, que necesitabreakcon efectos secundarios, o que muta varias variables, sigue siendo más claro como bucle. Los streams brillan cuando el trabajo es un pipeline de operaciones puras. - No son una mejora de rendimiento en datos pequeños. Un pipeline de stream asigna algunos objetos pequeños; un bucle de 10 elementos lo superará. Las ganancias vienen de la claridad con cualquier dato y del paralelismo con datos grandes.
- No son un sustituto de
Iterator/Iterablecuando otro código los espera. Un stream produce valores; si necesitas intercalar el consumo (unformejorado, unaListdevuelta desde un método), usatoList()primero.
Secuencial por defecto, paralelo bajo petición
Cada stream que escribirás en este capítulo es secuencial — los elementos fluyen a través del pipeline de uno en uno, en orden. También existe coll.parallelStream() (y stream.parallel()) que programa el pipeline a través del ForkJoinPool común para trabajo multinúcleo. Los streams paralelos son un capítulo posterior — hacen varias suposiciones sobre el pipeline (debe ser asociativo, sin estado, sin efectos secundarios) que los pipelines de "introducción" de este capítulo cumplen de forma natural, por lo que la actualización suele ser un cambio de un solo token.
Un ejemplo trabajado: un pipeline completo, pereza y la regla de uso único
El programa de abajo construye una pequeña lista de registros Person, ejecuta la forma canónica del pipeline (filter → map → sorted → collect), demuestra la pereza con peek, muestra el cortocircuito en un Stream.iterate infinito, y muestra la IllegalStateException que obtienes al reutilizar un stream.
Lo que hay que sacar de la ejecución:
- El pipeline canónico de cuatro pasos —
stream→filter→map→toList— produjo una lista ordenada de nombres de adultos sin ningún bucle explícito, sin colección temporal y sin gestión de nulabilidad. peekimprimió una vez por cada elemento extraído.findFirstextrajo elementos hasta que uno satisfizon*n > 50(lo que ocurre enn = 8, cuadrado64) y luego se detuvo. Eso es la pereza y el cortocircuito trabajando juntos: las operaciones anteriores hicieron exactamente el trabajo necesario y nada más.- El pipeline "primer cuadrado par mayor que 100" se ejecutó sobre una fuente infinita. Sin cortocircuito eso sería un bucle infinito; con él el pipeline evaluó 12 valores y produjo
144. - El segundo
s.count()lanzóIllegalStateException. Los streams son de uso único; si necesitas un segundo paso, construye un stream fresco desde la fuente. - El pipeline "sin terminal" al final no imprimió nada desde dentro de su
peek. Sin una terminal, los intermedios no se ejecutan — el stream es simplemente una receta que nadie pidió ejecutar.
Qué viene a continuación
Ya conoces la forma del pipeline, la división fuente/intermedia/terminal, el contrato de pereza y la regla de uso único. El siguiente capítulo, Creación de Streams de Java, es el catálogo de fuentes — Collection.stream(), Stream.of, Arrays.stream, IntStream.range, Stream.iterate, Stream.generate, Files.lines, String.chars(), Stream.empty y el API Stream.Builder. Con el capítulo de fuentes completado tendrás todo lo que necesitas para empezar, y el resto de la parte completará las operaciones intermedias y terminales.