Operaciones Terminales de Java Stream
Activa la evaluación de streams en Java con operaciones terminales: collect, forEach, reduce, count, min, max, anyMatch.
Una operación terminal es lo que hace que un pipeline de stream se ejecute realmente. Las operaciones intermedias (filter, map, sorted, …) solo registran el trabajo y permanecen diferidas; una terminal extrae los elementos, evalúa todo el pipeline y produce un resultado (o un efecto secundario). Cada pipeline termina en exactamente una terminal; llámala y el stream queda consumido; llama otra terminal sobre el mismo stream y obtendrás IllegalStateException.
Este capítulo cubre todas las terminales que escribirás, cuándo hace cortocircuito cada una y los casos límite de streams vacíos que silenciosamente confunden a la gente.
Las terminales tienen tres formas. Los agregadores devuelven un único valor (count, sum, min, max, reduce). Los buscadores buscan un elemento y se detienen (findFirst, findAny, anyMatch, allMatch, noneMatch). Los constructores materializan el stream en un contenedor (toList, toArray, collect, forEach para efectos secundarios). Este capítulo recorre todas las terminales que escribirás fuera de collect, que es lo suficientemente grande como para necesitar su propio capítulo a continuación.
forEach / forEachOrdered — efectos secundarios
La terminal más simple. Ejecuta un Consumer<T> para cada elemento y no devuelve nada:
names.stream().forEach(System.out::println);El orden no está garantizado — en un stream secuencial normalmente sí lo está; en un stream paralelo no. Si necesitas el orden de la fuente incluso en paralelo, usa forEachOrdered:
names.parallelStream().forEachOrdered(System.out::println);forEach es para los efectos secundarios que realmente deseas — registro de eventos, mutación de un destino, llamadas a una API fuera de streams. No es la forma correcta de construir una colección (eso es toList / collect) ni de acumular un valor (eso es reduce). Un forEach que muta una lista externa es un olor a código incluso cuando funciona, porque abandona todo lo que hacía declarativo al pipeline en primer lugar.
count — cuántos elementos
Devuelve un long:
long adults = people.stream().filter(p -> p.age() >= 18).count();count hace cortocircuito en fuentes dimensionadas donde la JVM puede calcular la respuesta a partir del tamaño de la fuente (así que IntStream.range(0, 1_000_000).count() devuelve 1000000 sin iterar). En un stream con un filter o flatMap activos, tiene que recorrer todos los elementos.
Una trampa común: stream.count() en una cadena .peek(...) puede no ejecutar el peek si la JVM puede probar la cuenta desde la fuente, porque no hay diferencia de comportamiento observable. No uses peek para "ver cuántos fueron filtrados" — usa mapToInt(x -> 1).sum() o reestructura.
min / max — elementos extremos
Ambos toman un Comparator<T> y devuelven Optional<T> (porque el stream podría estar vacío):
Optional<Person> oldest = people.stream().max(Comparator.comparingInt(Person::age));
Optional<String> shortest = words.stream().min(Comparator.comparingInt(String::length));Las especializaciones primitivas son más simples — IntStream.max() devuelve OptionalInt, sin comparador necesario:
OptionalInt highest = nums.stream().mapToInt(Integer::intValue).max();
int hi = highest.orElse(Integer.MIN_VALUE);min/max hacen cortocircuito solo en fuentes acotadas. En un stream infinito, max nunca termina.
findFirst / findAny — obtener un elemento
Ambos devuelven Optional<T>, ambos hacen cortocircuito. La diferencia es lo que prometen sobre qué elemento obtienes:
Optional<Person> first = people.stream().filter(p -> p.age() >= 30).findFirst();
Optional<Person> any = people.stream().filter(p -> p.age() >= 30).findAny();findFirstdevuelve el primer elemento en orden de encuentro. En un stream secuencial es literalmente el primero. En un stream paralelo cuesta más quefindAnyporque la JVM tiene que coordinarse.findAnydevuelve algún elemento coincidente — el primero que encuentre cualquier trabajador. En paralelo es más barato. En secuencial, ambos devuelven lo mismo.
Usa findAny cuando qué coincidencia obtienes genuinamente no importa (es una comprobación de existencia simple que necesita el valor, no solo un boolean). Usa findFirst cuando quieres decir "el primero."
anyMatch / allMatch / noneMatch — cuantificadores de existencia
Toman un Predicate<T> y devuelven boolean. Los tres hacen cortocircuito:
boolean hasAdult = people.stream().anyMatch(p -> p.age() >= 18);
boolean allAdult = people.stream().allMatch(p -> p.age() >= 18);
boolean noChildren = people.stream().noneMatch(p -> p.age() < 13);anyMatchse detiene en cuanto uno pasa.allMatchse detiene en cuanto uno falla.noneMatches!anyMatch(p)— se detiene al primer paso y devuelvefalse.
Semántica de stream vacío (la regla que confunde a todos una vez): anyMatch sobre vacío es false. allMatch y noneMatch sobre vacío son ambos true — vacuamente, porque no hay contraejemplos. Eso puede ser exactamente lo que quieres o exactamente lo que no quieres, según la pregunta. Si "vacío" es una posibilidad que vale la pena manejar, comprueba primero isEmpty (o count() == 0).
reduce — plegar a un único valor
El agregador más general. Tres sobrecargas, cada una para una forma ligeramente diferente:
reduce(identity, accumulator) de dos argumentos — plegar con un valor inicial, devuelve T (sin Optional, porque identity es la respuesta para un stream vacío):
int sum = nums.stream().reduce(0, Integer::sum);
String all = words.stream().reduce(\"\", String::concat);reduce(accumulator) de un argumento — sin identity; devuelve Optional<T> para el caso de stream vacío:
Optional<Integer> sum = nums.stream().reduce(Integer::sum);
Optional<String> longest = words.stream()
.reduce((a, b) -> a.length() >= b.length() ? a : b);reduce(identity, accumulator, combiner) de tres argumentos — usado cuando el acumulador produce un tipo diferente al de los elementos (y requerido en paralelo). El combiner fusiona dos resultados parciales:
int totalLength = words.stream()
.reduce(0,
(acc, w) -> acc + w.length(), // BiFunction<Integer, String, Integer>
Integer::sum); // BinaryOperator<Integer>Tres reglas para reduce que evitan que el pipeline se comporte de forma sutil incorrecta:
- El acumulador debe ser asociativo:
f(f(a, b), c) == f(a, f(b, c)). Las sumas y la concatenación de strings satisfacen esto; la resta no. - Identity debe ser una identidad verdadera:
f(id, x) == xpara todox.0para+,1para*,\"\"paraconcat. - El acumulador y el combiner deben ser sin estado y libres de efectos secundarios.
Viola cualquiera de estas y un pipeline secuencial aún da la respuesta correcta la mayor parte del tiempo — uno paralelo te sorprenderá. (Este es el mismo contrato en el que se apoyan Collectors.reducing y reduce paralelo.)
sum / average — agregadores primitivos
Solo en streams primitivos. sum devuelve el primitivo; average devuelve un OptionalDouble:
int total = IntStream.rangeClosed(1, 100).sum();
OptionalDouble avg = nums.stream().mapToInt(Integer::intValue).average();
double mean = avg.orElse(0.0);Para resúmenes numéricos más ricos — count, sum, min, max, average en un solo paso — consulta IntSummaryStatistics:
IntSummaryStatistics stats = nums.stream().mapToInt(Integer::intValue).summaryStatistics();
System.out.println(stats); // {count=N, sum=..., min=..., average=..., max=...}Eso es un solo paso, una sola asignación, y mucho más barato que calcular cada uno por separado.
toArray y toList — materializar
Dos terminales de acceso directo "dame todo":
Object[] anyArr = stream.toArray(); // Object[]
String[] strArr = stream.toArray(String[]::new); // typed via constructor ref
List<String> immutable = stream.toList(); // Java 16+, unmodifiablestream.toList() (Java 16+) es la forma moderna de materializar un stream en una List y es la elección correcta el 95% del tiempo. Es no modificable y puede contener nulls; si necesitas una lista mutable, una implementación específica o un Set/Map, recurre a collect(Collectors.toCollection(ArrayList::new)) o sus equivalentes en el próximo capítulo.
toArray(T[]::new) es la única forma de obtener un array tipado de un stream de objetos — la forma IntFunction<T[]> le da al runtime el tipo de componente del array.
iterator y spliterator — escapes
Un stream puede convertirse en un Iterator<T> o Spliterator<T> para pasarlo a código que espera uno:
for (Iterator<String> it = stream.iterator(); it.hasNext(); ) {
use(it.next());
}Ambos son terminales — consumen el stream. Existen para interoperabilidad, no para "quiero un bucle for"; si quieres un bucle, úsalo sin crear un stream primero.
Cortocircuito vs. consumo — la tabla de seguridad
| Terminal | ¿Hace cortocircuito en fuente infinita? |
|---|---|
findFirst / findAny | sí |
anyMatch / allMatch / noneMatch | sí |
limit(n) (intermedia) luego cualquier cosa | sí |
forEach / forEachOrdered | no — consume todo |
count | no — consume todo |
min / max | no — consume todo |
reduce | no — consume todo |
sum / average / summaryStatistics | no — consume todo |
toList / toArray / collect | no — consume todo |
El patrón es claro: cualquier terminal que necesita considerar cada elemento para producir su respuesta no hace cortocircuito, y emparejarlo con una fuente infinita sin un limit anterior cuelga la JVM. Los buscadores y cuantificadores son las únicas terminales "seguras en infinito".
Un ejemplo elaborado: cada forma de terminal en un pipeline
El programa a continuación construye un pequeño stream, llama a cada terminal que hemos cubierto y muestra las respuestas de stream vacío para los tres matchers y para min / findFirst / reduce.
Lo que se extrae de la ejecución:
- Las terminales de "búsqueda" —
findFirst,findAny,anyMatch,allMatch,noneMatch— y las terminales de "consume todo" —count,min/max,reduce,sum,toList— dividen el capítulo claramente. Las terminales de búsqueda hacen cortocircuito; las de consume todo no. Empareja el segundo grupo con una fuente infinita solo detrás de unlimit. allMatchsobre un stream vacío devolviótrue. También lo hizononeMatch. Eso es verdad vacua — es la respuesta estándar, y es la razón más común por la que el código de producción "pasa incorrectamente" un caso límite de entrada vacía. Si el vacío es significativo, compruébalo primero.- Las tres sobrecargas de
reducecubren tres patrones. De dos argumentos con una identidad verdadera devuelveT. De un argumento devuelveOptional<T>porque no hay identidad. De tres argumentos deja que el tipo del acumulador difiera del tipo del elemento — y es la forma que es realmente segura en paralelo, porque el combiner le dice a la JVM cómo fusionar resultados parciales. summaryStatistics()hizo en un paso lo que llamar amin,max,sum,averageycountpor separado haría en cinco. En cualquier stream numérico no trivial, prefierelo.toList()devolvió una lista no modificable. Ese es el predeterminado de Java 16+ y casi siempre lo que quieres; el próximo capítulo muestra la formaCollectors.toCollection(...)cuando necesitas una mutable, una implementación específica o unSet/Map.
Qué sigue
collect es la única terminal que diferimos — y la puerta de entrada a la mitad de la API. El próximo capítulo, Java Stream Collectors, recorre la caja de herramientas Collectors: toList/toSet/toMap, groupingBy, partitioningBy, joining, counting, summingInt, averagingDouble, mapping, reducing, y el patrón downstream que los compone.