En clases anteriores hablamos de dos tipos de operaciones: intermedias y finales. No se explicaron a profundidad, pero en esta lectura iremos más a fondo en las operaciones intermedias y trataremos de entender qué sucede por dentro de cada una.
¿Qué son las operaciones intermedias?
Se le dice operación intermedia a toda operación dentro de un Stream que como resultado devuelva un nuevo Stream. Es decir, tras invocar una operación intermedia con un cierto tipo de dato, obtendremos como resultado un nuevo Stream conteniendo los datos ya modificados.
El Stream que recibe la operación intermedia pasa a ser "consumido" posterior a la invocación de la operación, quedando inutilizable para posteriores operaciones. Si decidimos usar el Stream para algún otro tipo de operaciones tendremos un IllegalStateException.
Viéndolo en código con un ejemplo debe ser mas claro:
Stream<String> initialCourses =Stream.of("Java","Spring","Node.js");Stream<Integer> lettersOnCourses = initialCourses.map(course -> course.length());//De este punto en adelante, initialCourses ya no puede agregar mas operaciones.Stream<Integer> evenLengthCourses = lettersOnCourses.filter(courseLength -> courseLength %2==0);//lettersOnCourses se consume en este punto y ya no puede agregar mas operaciones. No es posible usar el Stream mas que como referencia.
El ejemplo anterior puede ser reescrito usando chaining. Sin embargo, la utilidad de este ejemplo es demostrar que las operaciones intermedias generan un nuevo Stream.
Operaciones disponibles
La interfaz Stream cuenta con un grupo de operaciones intermedias. A lo largo de esta lectura explicaremos cada una de ellas y trataremos de aproximar su funcionalidad. Cada operación tiene implementaciones distintas según la implementación de Stream, en nuestro caso, haremos solo aproximaciones de la lógica que sigue la operación.
Las operaciones que ya están definidas son:
filter(…)
map(…)
flatMap(…)
distinct(…)
limit(…)
peek(…)
skip(…)
sorted(…)
Analicemos qué hace cada una de ellas y hagamos código que se aproxime a lo que hacen internamente.
filter
La operación de filtrado de Stream tiene la siguiente forma:
Stream<T>filter(Predicate<?superT> predicate)
Algunas cosas que podemos deducir únicamente viendo los elementos de la operación son:
La operación trabaja sobre un Stream y nos devuelve un nuevo Stream del mismo tipo (T)
Sin embargo, el Predicate que recibe como parámetro trabaja con elementos de tipo T y cualquier elemento siempre que sea un subtipo de T. Esto quiere decir que si tenemos la clase PlatziStudent extends Student y tenemos un Stream<Student> donde también tenemos elementos de tipo PlatziStudent, podemos filtrarlos sin tener que revisar o aclarar el tipo
Predicate es una @FunctionalInterface (como lo viste en la clase 11), lo cual nos permite pasar como parámetro objetos que implementen esta interfaz o lambdas
Lo interesante radica en la condición que usamos en nuestra lambda, con ella determinamos si un elemento debe permanecer o no en el Stream resultante. En la lectura anterior hicimos una aproximación de la operación filter:
publicStream<T>filter(Predicate<T> predicate){List<T> filteredData =newLinkedList<>();for(T t :this.data){if(predicate.test(t)){ filteredData.add(t);}}return filteredData.stream();}
filter se encarga de iterar cada elemento del Stream y evaluar con el Predicate si el elemento debe estar o no en el Stream resultante. Si nuestro Predicate es sencillo y no incluye ningún ciclo o llamadas a otras funciones que puedan tener ciclos, la complejidad del tiempo es de O(n), lo cual hace que el filtrado sea bastante rápido.
Usos comunes de filter es limpiar un Stream de datos que no cumplan un cierto criterio. Como ejemplo podrías pensar en un Stream de transacciones bancarias, mantener el Stream solo aquellas que superen un cierto monto para mandarlas a auditoria, de un grupo de calificaciones de alumnos filtrar únicamente por aquellos que aprobaron con una calificación superior a 6, de un grupo de objetos JSON conservar aquellos que tengan una propiedad en especifico, etc.
Entre mas sencilla sea la condición de filtrado, más legible sera el código. Te recomiendo que, si tienes más de una condición de filtrado, no le temas a usar varias veces filter:
Tu código sera más legible y las razones de por qué estás aplicando cada filtro tendrán más sentido. Como algo adicional podrías mover esta lógica a funciones individuales en caso de que quieras hacer más legible el código, tener más facilidad de escribir pruebas o utilices en más de un lugar la misma lógica para algunas lambdas:
courses.filter(Predicates::isAJavaCourse).filter(Predicates::hasEnoughDuration).filter(Predicates::hasSinuheAsInstructor);// La lógica es la misma:publicfinalclassPredicates{publicstaticfinalbooleanisAJavaCourse(Course course){return course.getName().contains("Java");}}
map
La operación map puede parecer complicada en un principio e incluso confusa si estas acostumbrado a usar Map<K,V>, pero cabe resaltar que no hay relación entre la estructura y la operación. La operación es meramente una transformación de un tipo a otro.
Stream<R>map(Function<?superT,?extendsR> mapper)
Los detalles a resaltar son muy similares a los de filter, pero la diferencia clave está en T y R. Estos generics nos dicen que map va a tomar un tipo de dato T, cualquiera que sea, le aplicara la función mapper y generara un R.
Es algo similar a lo que hacías en la secundaria al convertir en una tabla datos, para cada x aplicabas una operación y obtenías una y (algunos llaman a esto tabular). map operará sobre cada elemento en el Stream inicial aplicando la Function que le pases como lambda para generar un nuevo elemento y hacerlo parte del Stream resultante:
Esto resulta bastante practico cuando queremos hacer alguna conversión de datos y realmente no nos interesa el dato completo (solo partes de él) o si queremos convertir a un dato complejo partiendo de un dato base.
Si quisiéramos replicar qué hace internamente map sería relativamente sencillo:
publicStream<R>map(Function<T,R> mapper){List<R> mappedData =newLinkedList<>();for(T t :this.data){R r = mapper.apply(t); mappedData.add(r);}return mappedData.stream();}
La operación map parece simple ya vista de esta manera. Sin embargo, por dentro de las diferentes implementaciones de Stream hace varias validaciones y optimizaciones para que la operación pueda ser invocada en paralelo, para prevenir algunos errores de conversión de tipos y hacer que sea mas rápida que nuestra versión con un for.
flatMap
En ocasiones no podremos evitar encontrarnos con streams del tipo Stream<List<Courses>>, donde tenemos datos con muchos datos…
Este tipo de streams es bastante común y puede pasarte por multiples motivos. Se puede tornar difícil operar el Stream inicial si queremos aplicar alguna operación a cada uno de los elementos en cada una de las listas.
Si mantener la estructura de las listas (o colecciones) no es importante para el procesamiento de los datos que contengan, entonces podemos usar flatMap para simplificar la estructura del Stream, pasándolo de Stream<List<Courses>> a Stream<Courses>.
Lo interesante es que el resultado de la función mapper debe ser un Stream<R>. Stream usará el resultado de mapper para "acumular" elementos en un Stream desde otro Stream. Puede sonar confuso, por lo que ejemplificarlo nos ayudará a entenderlo mejor:
//Tenemos esta clase:publicclassPlatziStudent{privateboolean emailSubscribed;privateList<Email> emails;publicbooleanisEmailSubscribed(){return emailSubscribed;}publicList<Email>getEmails(){returnnewLinkedList<>(emails);//Creamos una copia de la lista para mantener la clase inmutable por seguridad}}//Primero obtenemos objetos de tipo usuario registrados en Platzi:Stream<PlatziStudent> platziStudents =getPlatziUsers().stream();// Despues, queremos enviarle un correo a todos los usuarios pero… solo nos interesa obtener su correo para notificarlos:Stream<Email> allEmailsToNotify = platziStudents.filter(PlatziStudent::isEmailSubscribed)//Primero evitamos enviar correos a quienes no estén subscritos.flatMap(student -> student.getEmails().stream());// La lambda genera un nuevo Stream de la lista de emails de cada studiante.sendMonthlyEmails(allEmailsToNotify);//El Stream final solo es un Stream de emails, sin mas detalles ni información adicional.
flatMap es una manera en la que podemos depurar datos de información adicional que no sea necesaria.
distinct
Esta operación es simple:
Stream<T>distinct()
Lo que hace es comparar cada elemento del Stream contra el resto usando el método equals. De esta manera, evita que el Stream contenga elementos duplicados. La operación, al ser intermedia, retorna un nuevo Stream donde los elementos son únicos. Recuerda que para garantizar esto es recomendable que sobrescribas el método equals en tus clases que representen datos.
limit
La operación limit recibe un long que determina cuántos elementos del Stream original seran preservados. Si el número es mayor a la cantidad inicial de elementos en el Stream, básicamente, todos los elementos seguirán en el Stream. Un detalle interesante es que algunas implementaciones de Stream pueden estar ordenadas (sorted()) o explícitamente no ordenadas (unordered()), lo que puede cambiar drásticamente el resultado de la operación y el performance.
Stream<T>limit(long maxSize)
La operación asegura que los elementos en el Stream resultante serán los primeros en aparecer en el Stream. Es por ello que la operación es ligera cuando el Stream es secuencial o se usó la operación unordered() (no disponible en todos los Streams, ya que la operación pertenece a otra clase).
Como reto adicional, crea el código para representar lo que hace la operación limit.
peek
peek funciona como una lupa, como un momento de observación de lo que está pasando en el Stream. Lo que hace esta operación es tomar un Consumer, pasar los datos conforme van estando presentes en el Stream y generar un nuevo Stream idéntico para poder seguir operando.
La estructura de peek es simple:
Stream<T>peek(Consumer<?superT> consumer)
Usarlo puede ayudarnos a generar logs o registros de los datos del Stream, por ejemplo:
Esta operación es contraria a limit(). Mientras limit() reduce los elementos presentes en el Stream a un numero especifico, skip descarta los primeros n elementos y genera un Stream con los elementos restantes en el Stream.
Esto puede ser de utilidad si sabemos qué parte de la información puede ser descartable. Por ejemplo, descartar la primera línea de un XML (<? xml …>), descartar metadatos de una foto, usuarios de prueba al inicio de una base de datos, el intro de un video, etc.
sorted
La operación sorted() requiere que los elementos presentes en el Stream implementen la interfaz Comparable para poder hacer un ordenamiento de manera natural dentro del Stream. El Stream resultante contiene todos los elementos pero ya ordenados, hacer un ordenamiento tiene muchas ventajas, te recomiendo los cursos de algoritmos de Platzi para poder conocer a mas detalle estas ventajas.
Conclusiones
Las operaciones intermedias nos permiten tener control sobre los streams y manipular sus contenidos de manera sencilla sin preocuparnos realmente por cómo se realizan los cambios.
Recuerda que las operaciones intermedias tienen la funcionalidad de generar nuevos streams que podremos dar como resultado para que otras partes del código los puedan utilizar.
Aunque existen otras operaciones intermedias en diferentes implementaciones de Stream, las que aquí listamos están presentes en la interfaz base, por lo que entender estas operaciones te facilitara la vida en la mayoría de los usos de Stream.
Se nota un poco mejor que el Stream<Stream<Email>> se convierte en Stream<Email>, que finalmente se transforma en List<Email>.
Muy bien! Definitivamente encadenar las funciones hace que el flujo de datos se vea mas claro. Sin embargo, me parece que hay ciertas cosas que no están del todo bien con tu código (no soy experto en Java, me corrigen si estoy equivocado):
- Los métodos isEmailSubscribed y getEmails no son estáticos, por lo tanto no se pueden invocar desde la clase directamente sino que los tienes que llamar desde una instancia.
- Siguiendo la idea anterior, ambos métodos no esperan recibir ningún parámetro sino que devuelven información de la instancia a partir de sus atributos, sin embargo desde las operaciones "filter" y "map" les estás pasando parámetros a ambos métodos.
- Usar "map" está demás, ya que "flatMap" es básicamente un "map" pero para listas encadenadas. En tu ejemplo "map" devuelve una lista de listas de emails que después haces flat con "flatMap", lo cual se puede lograr únicamente con "flatMap" como hace Sinué en el ejemplo.
@ernestomejia El código es 100% funcional. Me dispongo a responderte punto a punto:
Los métodos isEmailSubscribed y getEmails no son estáticos, por lo tanto no se pueden invocar desde la clase directamente sino que los tienes que llamar desde una instancia.
Con Clase::méthodo se crea una referencia al método resuelta en runtime. Por lo tanto, map(PlatziStudent::getEmails) es equivalente a map((PlatziStudent student) -> student.getEmails()).
Siguiendo la idea anterior, ambos métodos no esperan recibir ningún parámetro sino que devuelven información de la instancia a partir de sus atributos, sin embargo desde las operaciones “filter” y “map” les estás pasando parámetros a ambos métodos.
El argumento que pasa filter y map a las referencias como PlatziStudent::getEmails es precisamente la instancia de la clase sobre la cual se operará.
Usar “map” está demás, ya que “flatMap” es básicamente un “map” pero para listas encadenadas. En tu ejemplo “map” devuelve una lista de listas de emails que después haces flat con “flatMap”, lo cual se puede lograr únicamente con “flatMap” como hace Sinué en el ejemplo.
FlatMap va de Stream<Stream<T>> a Stream<T>, por lo tanto, se debe transformar el List<T> que retorna PlatziStudent::getEmails a Stream<T> antes de aplicar el flatMap.
A futuro, sugiero primero ahondar en los temas antes de formular afirmaciones incorrectas. O por lo menos, preguntar lo que no se conoce en lugar de asumir saber de lo que se habla.
Los que tienen dudas aún sobre el uso de flatMap con este articulo pueden aclarar las dudas.
Les dejo la documentación oficial de Stream por si están interesados. Documentacion
Con respecto al hacer _chaining _de operaciones filter, si bien facilita su legibilidad pero me parece que afecta el performance. Ya que, si tenemos un stream de 1000 cursos por ejemplo, al hacer:
en el peor de los casos (cuando ninguna condición se cumpla) por cada .filter estaríamos haciendo 1000 iteraciones quedando en 3000 iteraciones en total. En cambio si hacemos algo como:
No hay una respuesta 100% definitiva para esta pregunta, pero lo que te puedo decir es que, si estás trabajando con un número enorme de datos donde el performance es muy importante, Java tiene muy bien optimizado el uso de múltiples filters con los procesamientos en paralelo.
No me quedo del todo claro el uso del flatMap, tal vez otro ejemplo me ayudaría.
Lo que se entiende en el flatMap es que te pasan como ejemplo un stream de objetos User, este objeto puede tener varios atributos, un id, un name, una lista de emails, etc. pero si de ese stream de objetos solo te interesa los emails de cada objeto, entonces usas el flatMap, para que te genere un nuevo stream solamente de ese dato que te interesa manipular. en ese caso un stream de los emails, este va acumulando los emails de cada User a un solo stream; está explicado detalladamente en el código que pusieron.
En otras palabras menos técnicas, tienes una lista de personas y solo te interesan los emails de cada persona, entonces creas una nueva lista solamente con los emails. (eso hace flatMap)
Puedes encontrar más información aquí.
Muchas gracias por la explicación de Operaciones Intermedias instructor Sinuhé. Por cierto, respecto al .limit tengo entendido que se usaría de esta manera para establecer el nuevo rango de los elementos en un stream:
Wow! Con skip() y limit() puedo hacer lo mismo que hago con PostgreSQL:
Esto me será muy útil para la paginación de mis APIs:
No me queda claro el flatMap. ¿cuándo debo usarlo?
Hola, no se si sea tarde, pero lo que hace flatmap es lo siguiente: Supon que tienes un Stream de un tipo de dato T, quieres hacer un map que ejecute un metodo x en cada instancia de T que contiene el stream, si ese metodo x retorna un Stream de un tipo de dato U entonces usando map normal tendrias como resultado un Stream de Streams que probablemente no es lo que quieres. Flatmap se encarga de que si el metodo x retorna un stream, agregar los valores de ese stream (las instancias de U) al stream original y entonces usando flatmap en lugar de obter un Stream<Stram<U>> obtendrias un Stream<U>. Espero haberme hecho entender
un ejemplo para entender mas facilmente el flatMap
Operación intermedia limit
publicStreamlimit(Long limit){List list =newLinkedList<>();for(int i =0; i < limit; i++){ list.add(this.data);}return list.stream();}
En que clase vimos que Predicate es una @FunctionalInterface?
publicStreamskip(long maxSize){List mappedData =newLinkedList(); long size =1;for(Tt:this.data){if(size++> maxSize){continue;} mappedData.add(this.data);}return mappedData.stream();}
List mappedData = new LinkedList();
long size = 1;
for(T t : this.data){
if (size++ > maxSize){
break;
}
mappedData.add(this.data);
}
return mappedData.stream();
}
forEach y peek son similares solo que peek genera un stream intermedio.
Utiliza forEach() cuando quieras realizar una acción simple y no necesites crear un nuevo stream.
Utiliza peek() cuando quieras inspeccionar los elementos del stream o realizar acciones condicionales sin modificar el flujo principal de datos.
En conclusión,peek() es una herramienta poderosa para depurar y entender mejor el comportamiento de los streams en Java.
Se le dice operación intermedia a toda operación dentro de un Stream que como resultado devuelva un nuevo Stream.
con Sorted debemos implemantar la interfaz Comparable<T> para tener un parametro de comparacion
List<String> emails =Arrays.asList("asdf@23","kjhgtre@hgfd","zadfsv@234"); emails.forEach(System.out::println);}publicclassEntityimplementsComparable<Entity>{String direccion; @Overridepublic int compareTo(Entity o){// TODO Auto-generated method stubreturnthis.direccion.compareTo(o.direccion);}
Limit and Skip
List<Integer> first3Numbers =Arrays.asList(0,1,2,3,4,5,6,7,8,9);Stream<Integer> last3Numbers =Stream.of(3,4,5,6,7,8,9);// limit trae los 3 primeros elementos de mi coleccion first3Numbers.stream().limit(3).forEach(x->System.out.println(x));// 0,1,2// skip omite los primeros 4 elementos de mi coleccion last3Numbers.skip(4).forEach(x->System.out.println(x));//7,8,9}
el ejemplo de flatMap
esta incompleto
creo que era map
publicStreamfilter(Function mapper){List mappedData =newLinkedList<>();for(Tt:this.data){R r = mapper.apply(t); mappedData.add(r);}return mappedData.stream();}
Implementación de limit:
classNothing{// Driver code publicstaticvoidmain(String[] args){// list to save stream of strings List<String> arr =newArrayList<>(); arr.add("geeks"); arr.add("for"); arr.add("geeks"); arr.add("computer"); arr.add("science");Stream<String> str = arr.stream();// calling function to limit the stream to range 3 Stream<String> lm = str.limit(3); lm.forEach(System.out::println);}}