En este post vamos a ver como podemos dividir un stream en otros dos de forma eficiente utilizando java 17.
Para el ejemplo, vamos a usar un record para representar nuestra entidad libro que tiene un título y una fecha de publicación y vamos a realizar dos operaciones de filtrado sobre el stream:
-
Obtener la lista de todos los libros que contienen la palabra "java".
-
Obtener la lista de todos los años de los libros publicados después de 2002.
record Libro(String titulo, Year publicacion) {}
Stream<Libro> libros = Stream.of(
new Libro("Effective java", Year.of(2001)),
new Libro("Core Java", Year.of(2007)),
new Libro("Java Concurrency In Practice", Year.of(2006)),
new Libro("Head First Design Patterns", Year.of(2004)));
Resolver cada caso de uso separado
Antes de intentar resolver de forma conjunta los dos problemas vamos a resolver cada uno de forma individual.
Obtener la lista de todos los libros que contienen la palabra "java"
En este caso vamos a filtrar ignorando mayúsculas y minúsculas y en lugar de pasar todos los títulos a minúsculas y usar el método contains de la clase String lo cual genera una gran cantidad de strings temporales, vamos a utilizar una regex.
Pattern busqueda = Pattern.compile("java", Pattern.CASE_INSENSITIVE | Pattern.LITERAL); // 1
List<Libro> librosConJava = libros
.filter(libro -> busqueda.matcher(libro.titulo()).find()) // 2
.toList(); // 3
librosConJava.forEach(System.out::println);
-
Generamos una expresión regular con 2 flags, CASE_INSENSITIVE para indicar que queremos buscar tanto en minúsculas como en mayúsculas y LITERAL que trata la regex como un literal y escapa cualquier posible valor especial.
-
Para cada título aplicamos el patrón y llamamos al método find para ver si el título contiene nuestra búsqueda.
-
Guardamos el resultado usando el método toList, en versiones anteriores de java podríamos usar Collections.toList() en su lugar pero toList tiene la ventaja de devolver una lista inmutable así como mejoras de rendimiento al implementarse encima de toArray en lugar de Collect.
Obtener la lista de todos los años de los libros publicados después de 2002.
Podemos obtener la lista de todas las publicaciones estableciendo un año objetivo para filtrar, y luego almacenando todos los años en un conjunto para eliminar posibles duplicados.
Year publicacionBusqueda = Year.of(2002); // 1
Set<Year> publicaciones = libros
.filter(libro -> libro.publicacion().isAfter(publicacionBusqueda)) // 2
.map(Libro::publicacion) // 3
.collect(Collectors.toSet()); // 4
System.out.println(publicaciones);
-
Creamos el año a buscar.
-
Utilizamos el métodos de utilizad isAfter para dejar claro el filtrado que queremos.
-
Objetenemos todos los años de las publicaciones y gracias a los records podemos hacerlo muy claro usando una referencia de métodos.
-
Desgraciadamente no tenemos un método toSet por lo que usamos el collector.
Resolver los dos casos de forma conjunta
Crear una variable y realizar las dos operaciones
La primera opción que se nos ocurre es el colocar el stream en una variable y colocar nuestras 2 piezas de código una debajo de otra.
Pattern busqueda = Pattern.compile("java", Pattern.CASE_INSENSITIVE | Pattern.LITERAL);
List<Libro> librosConJava = libros
.filter(libro -> busqueda.matcher(libro.titulo()).find())
.toList();
librosConJava.forEach(System.out::println);
Year publicacionBusqueda = Year.of(2002);
Set<Year> publicaciones = libros
.filter(libro -> libro.publicacion().isAfter(publicacionBusqueda))
.map(Libro::publicacion)
.collect(Collectors.toSet());
System.out.println(publicaciones);
Compilamos sin problema pero en cuanto intentamos ejecutar el código obtenemos:
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
El problema es que no podemos consumir un stream 2 veces así que si queremos hacer esta operación tenemos que buscar una alternativa.
Almacenar en una variable list intermedia
Una solución trivial es el crear un stream para cada operación, esto lo podemos realizar almacenando en una lista temporal el stream y luego llamara al método .stream() de la lista dos veces.
List<Libro> listaLibros = libros.toList(); // 1
Pattern busqueda = Pattern.compile("java", Pattern.CASE_INSENSITIVE | Pattern.LITERAL);
List<Libro> librosConJava = listaLibros.stream() // 2
.filter(libro -> busqueda.matcher(libro.titulo()).find())
.toList();
librosConJava.forEach(System.out::println);
Year publicacionBusqueda = Year.of(2002);
Set<Year> years = listaLibros.stream() // 3
.filter(libro -> libro.publicacion().isAfter(publicacionBusqueda))
.map(Libro::publicacion)
.collect(Collectors.toSet());
System.out.println(years);
-
Creamos la variable list intermadia con todos los libros del stream.
-
Creamos un nuevo stream para la primera operación.
-
Creamos un nuevo stream para la segunda operación.
Emplear collectors teeing para poder dividir el stream en 2 partes
Si bien el método anterior es directo y adecuado para mas del 99% de las ocasiones, existe una ineficiencia en el mismo y es que estamos almacenando todo el stream al invocar al toList(). Si el volumen de datos es muy elevado esto puede llegar a causar un OutOfMemoryError si el stream por ejemplo está leyendo desde una base de datos. Sería ideal si pudieramos consumir el stream dos veces pero sin necesidad de crear la lista intermedia. Para estos casos desde java 12 Collectors.teeing justamente nos permite hacer eso; Collectors.teeing() es un collector que nos permite introducir 2 collectors para tratar el stream de dos formas distintas y nos devuelve un objeto resultado de las dos operaciones.
record Result(List<Libro> librosConJava, Set<Year> publicaciones) {} // 1
Pattern busqueda = Pattern.compile("java", Pattern.CASE_INSENSITIVE | Pattern.LITERAL);
Year publicacionBusqueda = Year.of(2002);
Result resultado = libros.collect(Collectors.teeing( // 2
Collectors.filtering( // 3
libro -> busqueda.matcher(libro.titulo()).find(), // 3.1
Collectors.toList()), // 3.2
Collectors.filtering( // 4
libro -> libro.publicacion().isAfter(publicacionBusqueda), // 4.1
Collectors.mapping(
Libro::publicacion, // 4.2
Collectors.toSet())),
Result::new // 5
));
// 6
resultado.librosConJava().forEach(System.out::println);
System.out.println(resultado.publicaciones());
-
Definimos el record resultado esperado, podríamos usar como objeto resultado Map.entry pero queda más claro el resultado de cada operación si le damos un nombre.
-
Comenzamos el teeing.
-
Usamos Collectors.filtering para filtrar el resultado de la primera división del stream ..1. Especificamos la condición de búsqueda -. Especificamos como queremos almacenar el resultado, en este caso no podemos el método toList porque ha de ser un collector.
-
Usamos Collectors.filtering de nuevo filtrar el resultado de la segunda división del stream
-
Misma condición de búsuqeda
-
Ahora necesitamos obtener el año, para eso usamos Collectors.mapping especificando el mapper y cómo queremos almacenar el resultado, en este caso un set.
-
-
Almacenamos el resultado en el record que definimos antes.
-
Hacemos uso de los resultados.
Notas finales
Cómo podéis ver, es posible dividir un stream en dos si hacemos uso de Collectors.teeing(). Es evidente que la mayor desventaja es que una vez que empleamos este método nos vemos obligados a utilizar collectors encadenados, para ello disponemos de Collectors.filtering, Collectors.mapping y Collectors.flatMapping para hacernos la vida más fácil. Sin embargo, aún haciendo uso de esos métodos de utilizad anteriores, el código resultado resulta mucho menos lejible que la aproximación inicial y mi recomendación es emplearlo solo cuando verdaderamente esté justificado y aporte valor a nuestro código.