Operaciones Intermedias de Streams en Java
Transforma streams de Java de forma perezosa con filter, map, flatMap, sorted, distinct, peek, limit y skip.
Una operación intermedia recibe un stream y devuelve otro stream. Registra qué debe ocurrirle a cada elemento cuando el pipeline finalmente se ejecute; por sí sola no ejecuta nada. Puedes encadenarlas; la cadena permanece inactiva hasta que un terminal extrae el primer elemento. Esa pereza es lo que hace que un pipeline de 30 líneas cueste menos que la suma de sus partes, lo que hace tractables las fuentes infinitas, y lo que convierte la elección de operación en una cuestión de claridad más que de evitar trabajo — las operaciones intermedias adyacentes se fusionan en un único paso.
Este capítulo recorre todas las operaciones intermedias que escribirás. Cada entrada tiene la misma estructura: qué hace, cuál es el tipo de su callback, si es con estado o sin estado, y uno o dos problemas que determinan si el pipeline es correcto.
filter — conservar lo que coincide
Descarta los elementos que no cumplen un Predicate<T>:
List<Integer> evens = nums.stream()
.filter(n -> n % 2 == 0)
.toList();Sin estado, perezoso, preserva el orden. El predicado debe estar libre de efectos secundarios — si muta algo visible, los pipelines paralelos te sorprenderán e incluso los secuenciales se vuelven difíciles de leer.
filter no cambia el tipo del elemento. Para mantener un subconjunto y cambiar el tipo, usa filter seguido de map, o mapMulti (Java 16+) para el caso excepcional en que una entrada se convierte en cero o una salida de tipo diferente.
map — transformar cada elemento
Aplica una Function<T, R> a cada elemento, produciendo un stream de R:
List<Integer> lengths = words.stream()
.map(String::length)
.toList();Sin estado, perezoso, preserva el orden, uno-entra uno-sale. Usa las especializaciones primitivas cuando el resultado es numérico:
mapToInt,mapToLong,mapToDouble→ stream primitivo (sin boxing,sum()disponible).mapToObjen un stream primitivo → de vuelta aStream<R>.
int totalLength = words.stream().mapToInt(String::length).sum();flatMap — reemplazar cada elemento con un stream de otros
Una Function<T, Stream<R>> que "desempaqueta" cada elemento en múltiples salidas (o ninguna, o una):
List<List<String>> grouped = List.of(List.of("a", "b"), List.of("c"));
List<String> flat = grouped.stream()
.flatMap(List::stream)
.toList(); // [a, b, c]El modelo mental: "cada elemento se convierte en un sub-stream, y flatMap los concatena". Es la forma de pasar de un stream de contenedores (Stream<List<T>>) a un stream de contenidos (Stream<T>), de expandir cada texto en sus palabras, y de convertir un stream de Optional<T> en un stream de valores presentes (mediante Optional::stream).
También existen especializaciones primitivas — flatMapToInt, flatMapToLong, flatMapToDouble — para la expansión hacia un stream primitivo.
Una confusión frecuente: map(s -> s.split(" ")) produce Stream<String[]> — un stream de arrays, no un stream plano de palabras. Para aplanarlo: flatMap(s -> Arrays.stream(s.split(" "))).
mapMulti — emitir cero, uno o varios elementos por entrada
mapMulti (Java 16+) es un flatMap más eficiente para los casos en que cada entrada produce un número pequeño y variable de salidas y construir un Stream por elemento sería excesivo:
people.stream()
.<String>mapMulti((p, downstream) -> {
if (p.age() >= 18) downstream.accept(p.name());
if (p.email() != null) downstream.accept(p.email());
})
.forEach(System.out::println);Usa flatMap cuando naturalmente ya tienes un stream/lista que emitir; usa mapMulti cuando de otro modo construirías un stream diminuto de uno o dos elementos por entrada solo para satisfacer la firma de flatMap.
distinct — eliminar duplicados
Elimina elementos iguales usando equals / hashCode:
List<String> unique = words.stream().distinct().toList();Con estado — para saber si un elemento es duplicado, distinct debe recordar los que ya ha emitido. En un stream ordenado conserva la primera aparición. En un stream no ordenado la JVM puede ser más inteligente respecto al trabajo paralelo. En un stream infinito casi nunca querrás distinct sin un limit antes.
sorted — ordenar los elementos
Dos formas — orden natural y un Comparator<T>:
List<String> az = words.stream().sorted().toList();
List<String> byLen = words.stream().sorted(Comparator.comparingInt(String::length)).toList();Con estado y bloqueante en terminal: sorted debe almacenar en búfer todos los elementos antes de poder emitir uno. Eso lo convierte en la operación intermedia más costosa y en una que debe usarse deliberadamente. Colocarlo antes de un limit(n) no ahorra trabajo — la JVM aún tiene que ver cada entrada para saber qué n conservar. (Para un pipeline de "top N", es preferible usar un PriorityQueue acotado o Collectors.toList() seguido de subList después de un sorted, dependiendo de N respecto al total).
Además: no llames a sorted en un stream de una fuente infinita — nunca retorna.
peek — observar sin cambiar
Un Consumer<T> que se dispara por cada elemento extraído. Devuelve el stream sin cambios:
words.stream()
.peek(s -> System.out.println("seen: " + s))
.filter(s -> s.length() > 3)
.toList();Solo para depuración. peek se ejecuta de forma perezosa y exactamente una vez por elemento extraído, por lo que es una ventana útil sobre la pereza y el cortocircuito:
Stream.iterate(1, n -> n + 1)
.peek(n -> System.out.println("considered " + n))
.filter(n -> n > 100)
.findFirst(); // pulls 1..101 -- peek fires 101 times, then stopsNo pongas lógica real en un peek. La JVM puede fusionar, reordenar u omitir llamadas a peek bajo ciertas condiciones en streams no modificados, y en streams paralelos el orden no está definido.
limit(n) — conservar como máximo n elementos
Detiene el pipeline después de que n elementos lo hayan atravesado:
List<Integer> firstFive = Stream.iterate(1, i -> i + 1).limit(5).toList();Con estado (cuenta) y de cortocircuito (el downstream se detiene una vez alcanzado n). En un stream ordenado conserva los primeros n. En un stream paralelo no ordenado conserva algunos n — el orden no está garantizado, y un limit paralelo sobre un stream ordenado paga el coste del ordenamiento. Si no te importa qué n obtienes, stream.unordered().limit(n) es más rápido en paralelo.
El patrón estándar para domar cualquier fuente infinita: todo Stream.iterate / Stream.generate termina en un limit, un iterate de 3 argumentos acotado, o un terminal de cortocircuito como findFirst.
skip(n) — descartar los primeros n
El complemento de limit. Descarta los primeros n elementos y luego emite el resto:
List<Integer> rest = nums.stream().skip(2).toList(); // drops nums[0], nums[1]Con estado (cuenta hacia atrás). En un stream ordenado el significado es exacto; en un stream paralelo ordenado paga un coste de ordenamiento. Junto con limit, proporciona acceso "paginado":
list.stream().skip(page * pageSize).limit(pageSize).toList();Funciona, pero para un skip grande sobre una List sigue siendo O(skip + limit). Un list.subList(...) directo es más barato si tienes la List en mano.
takeWhile / dropWhile — ventana basada en prefijo
Dos operaciones intermedias de cortocircuito (Java 9+) que actúan sobre un prefijo del stream:
// take elements while predicate holds, stop at the first miss
List<Integer> small = Stream.of(1, 2, 3, 10, 4, 5)
.takeWhile(n -> n < 5)
.toList(); // [1, 2, 3]
// drop elements while predicate holds, then emit the rest
List<Integer> rest = Stream.of(1, 2, 3, 10, 4, 5)
.dropWhile(n -> n < 5)
.toList(); // [10, 4, 5]Estas operaciones no son filter. filter prueba cada elemento. takeWhile se detiene en el primer fallo (incluyendo elementos que pasarían filter más adelante). En un stream ordenado son la forma de expresar "todo hasta el umbral" de manera económica.
boxed / asLongStream / asDoubleStream — moverse entre mundos primitivos
Los streams primitivos tienen algunas operaciones intermedias propias para cruzar de vuelta al mundo de objetos:
IntStream.range(0, 5).boxed().toList(); // Stream<Integer> [0, 1, 2, 3, 4]
IntStream.range(0, 3).asLongStream().sum(); // 0L + 1L + 2L
IntStream.range(0, 3).asDoubleStream().average();boxed es el puente del primitivo a Stream<Integer/Long/Double>. La dirección inversa es mapToInt/mapToLong/mapToDouble.
Sin estado vs. con estado — por qué importa
| Sin estado | Con estado |
|---|---|
filter | distinct |
map / mapToX | sorted |
flatMap / mapMulti | limit |
peek | skip |
boxed / asLongStream / asDoubleStream | takeWhile / dropWhile |
Las operaciones intermedias con estado necesitan recordar algo entre elementos. sorted tiene que almacenar todo en búfer. distinct tiene que recordar cada elemento ya emitido. limit y skip necesitan un contador. Eso las hace más costosas (especialmente en paralelo) y vale la pena usarlas deliberadamente.
El orden importa — fusiona, filtra pronto, transforma tarde
Dado que las operaciones intermedias adyacentes se fusionan en un único paso elemento a elemento, el orden en que las escribes determina cuánto trabajo hace el pipeline:
// Good: filter first, then the expensive map runs only on survivors.
people.stream()
.filter(p -> p.age() >= 18)
.map(this::expensiveLookup)
.toList();
// Bad: every element pays for the map, then most are thrown away.
people.stream()
.map(this::expensiveLookup)
.filter(r -> r.score() > 0.5)
.toList();La regla general: filtra pronto, transforma tarde, ordena una vez, aplica distinct una vez. La JVM no reordena tus operaciones intermedias — lo haces tú.
Un ejemplo elaborado: todo el vocabulario en un solo pipeline
El programa siguiente construye un stream a partir de una pequeña lista, recorre todas las operaciones que hemos visto, imprime el resultado de cada una, y demuestra la pereza y el cortocircuito con peek más un iterate infinito.
Lo que podemos extraer de la ejecución:
filterymapson los caballos de batalla; las demás operaciones uno-entra uno-sale (mapToInt,mapToObj,boxed) son las conversiones baratas entre streams de objetos y streams primitivos.flatMapymapMultison la forma en que una entrada se convierte en varias salidas. El patrónStream.of("a b") -> Arrays.stream(split(...))es el patrón canónico de "tokenización";mapMulties la opción más barata cuando de otro modo construirías un stream diminuto por elemento.distinctysortedson con estado —distincttuvo que recordar cadaPersonemitida previamente para descartar el "Alice" duplicado, ysortedtuvo que almacenar toda la entrada en búfer. Por eso ambas se colocan deliberadamente, normalmente una vez y normalmente tarde.peekse disparó una vez por elemento extraído en eliterateinfinito — había exactamente tantas líneas "considered N" como elementos quefindFirsttuvo que examinar. Sin cortocircuito ese pipeline nunca terminaría.- Los dos bloques de conteo de
lookupal final hicieron concreta la regla del orden. Filtrar primero ejecutó la transformación costosa sobre muchos menos elementos que mapear primero. Esa decisión es tuya.
Qué viene después
Las operaciones intermedias registran la forma del trabajo; nada se ejecuta hasta que un terminal extrae. El siguiente capítulo, Operaciones Terminales de Streams en Java, es el vocabulario completo de terminales — forEach, count, min/max, findFirst/findAny, anyMatch/allMatch/noneMatch, reduce, toArray, toList, y la puerta de entrada al capítulo siguiente — collect.