Java Streams

De ChuWiki


¿Qué es un Java Stream?[editar]

Un Java Stream es un flujo de datos. Es decir, una serie de datos que nos van llegado y que podemos ir tratando. A partir de la versión 8 de Java se potenció el uso de Streams. Podemos ir encadenando operaciones para cada dato que nos llegue en un Stream, cosas como filtrar qué elementos siguen o no en el Stream, transformarlos, agruparlos, etc, etc.

Veamos ejemplos de las operaciones más habituales

Obtención de un Stream[editar]

Se puede obtener un Stream de datos de muchos sitios. Los más habituales son las lecturas de ficheros o sockets de red. Sin embargo, desde Java 8 existen forma de genera Stream de datos a partir de colecciones de datos. Hay también clases que permiten generar Stream de diversos tipos de datos.

En el siguiente código vemos varios ejemplos

// Generar un Stream de enteros (IntStream) de 20 números aleatorios entre 0 y 10, excluído el 10.
// La clase Random tiene el método ints() que genera el Stream.
Random random = new Random();
IntStream ints = random.ints(20, 0, 10);

// A partir de un array. La clase Arrays tienes el método stream() para generar un Stream a partir de un array.
Stream<String> strings = Arrays.stream(new String[]{"a", "b", "c"});

// La clase Collection y todas sus hijas, como List, tienen el método stream() para convertir la Collection
// en un Stream
List<Data> data = List.of(new Data(1, "Pedro"), new Data(2, "Juan"), new Data(3, "Ana"));
Stream<Data> stream = data.stream();

// Con la clase Files podemos obtener un Stream de lineas de texto de un fichero
try {
   Stream<String> lines = Files.lines(Path.of("fichero.txt"));
} catch (IOException e) {
   e.printStackTrace();
}

En los siguientes ejemplos de código usaremos los Stream obtenidos aquí.

Java Stream foreach()[editar]

Los Stream tienen el método foreach(). A este método como parámetro debemos pasarle un Consumer, para que haga algo con cada alemento. Una forma fácil de pasarle un Consumer es crearlo sobre la marcha con una expresión Lambda, como en el siguiente ejemplo

ints.forEach(element -> System.out.println(element));

En este ejemplo estamos sacando por pantalla cada uno de los elementos que el stream nos vaya pasando.

Podemos también pasar como parámetro una referencia a un método de una clase que admita un parámetro

ints.forEach(System.out::println);

Esto tiene el mismo efecto que el caso anterior, llamará a System.out.println() pasando como parámetro cada uno de los elementos del Stream.

Java Stream filter()[editar]

El método filter() permite filtrar los elementos del Stream. Nos devuelve otro Stream en el que sólo pasan aquellos elementos que pasan el filtro. Debemos devolver true o false para cada elemento que nos pasen. Con una expresión Lambda sería algo como esto

IntStream filteredInts = ints.filter(element -> element>5);

En el Stream resultante filteredStream solo estarán aquellos enteros del Stream original mayores que cinco.

Podemos encadenar la llamada, por ejemplo, para sacar estos enteros por pantalla.

ints.filter(element -> element>5).forEach(System.out::println);

Java Stream findFirst()[editar]

El método findFirst devuelve un Optional con el primer elemento del Stream. O un Optional vacío si el Stream no tiene elementos.

OptionalInt first = ints.findFirst();
if (first.isPresent()) {
   System.out.println(first.getAsInt());
}

En el ejemplo nos devuelve un OptionalInt porque ints es un IntStream.

Tenemos también el método findAny que nos devuelve un Optional con cualquier elemento del Stream. No hay garantía de qué elemento nos devuelve.

Java Stream map()[editar]

El método map() permite obtener un Stream de datos donde cada dato se obtiene transformando de alguna forma el dato del Stream original en otro. El siguiente ejemplo convierte un Stream de enteros en un Stream con el valor doble de cada entero original

IntStream ints = IntStream.range(0,10);
IntStream doubleValues = ints.map(element->element*2);

Java Stream reduce()[editar]

El método reduce() permite juntar todos los datos del Stream en un único dato. Admite como parámetro la función que premite ir agrupango elementos según llegan. En una expresión Lambda podría ser como lo siguiente

OptionalInt reduce = ints.reduce((result, element) -> result + element);
if (reduce.isPresent()){
   System.out.println("Reduce = " + reduce.getAsInt());
}

La expresión Lambda recibe dos parámetros. En la primera llamada serán el primero y el segundo elemento del Stream. Debemos devolver el resultado de juntar estos dos elementos en uno solo. En nuestro ejemplo, hacemos la suma y devolvemos la suma.

En las siguientes llamadas, recibiremos como primer parámetro lo que hayamos devuelto en la llamada anterior, es decir, la suma. Como segundo parámetro recibiremos el siguiente elemento del Stream.

Con este ejemplo, estamos obteniendo la suma de todos los enteros del Stream. El resultado se devuelve como un Optional con la suma (porque es el cálculo que hemos hecho).

Para la suma y en el caso concreto de IntStream tenemos el método sum() que hace esto mismo. Así que lo que hemos hecho vale como ejemplo de cómo funciona el método reduce(), pero no es buen ejemplo usar reduce() si solo queremos hacer una suma. Tampoco otras operaciones estándar que IntStream ya tiene, como max(), min(), sort(), average(), count(), ...

El siguiente ejemplo de reduce() concatena los elementos de un Stream de cadenas.

Stream<String> strings = Arrays.stream(new String[]{"a", "b", "c"});

Optional<String> reduce1 = strings.reduce(String:concat);
if (reduce1.isPresent()){
   System.out.println(reduce1.get());
}

En vez de una expresión Lamba, hemos puesto una referencia al método concat() de la clase String.

Java Stream collect()[editar]

El método collect() de Java Stream permite meter los elementos del Stream en un contenedor. El método admite tres parámetros que se pueden poner como expresiones Lambda. Por ejemplo, para meter los elementos de Stream en un Map, podemos hacer lo siguiente

List<Data> data = List.of(new Data(1, "Pedro"), new Data(2, "Juan"), new Data(3, "Ana"));
Stream<Data> stream = data.stream();
Map<Integer, Data> collect = data.stream().collect(
   () -> new HashMap<>(),
   (c, e) -> c.put(e.id(), e),
   (c1, c2) -> c1.putAll(c2)
 
);

Data es un Record de Java que hemos creado con dos campos, un identificador entero y un nombre String. data.id() devuelve el identificador.

Los tres parámetros que pasamos a collect() sirven para lo siguiente:

  • () -> new HashMap<>(). Debe crear una instancia del contenedor en el que queremos meter los elementos del Stream. En nuestro ejemplo, vamos a meterlos en un HashMap.
  • (c, e) -> c.put(e.id(), e). collect() nos va a pasar aquí el contenedor c y un elemento del Stream e. Debemos meter el elemento en el contenedor. Usamos como clave del Map el identificador del elemento e.id() y como valor el elemento en sí. Recuerda que el elemento del Stream será un Data.
  • (c1, c2) -> c1.putAll(c2). Nos pasará dos contenedores c1 y c2 con elementos del Stream. Debemos añadir todos los elementos del segundo contenedor en el primero. Como son HashMap lo hacemos con el método putAll(). Este tercer parámetro sólo se usa si el Stream permite el procesamiento en paralelo, es decir, varios hilos tratando varios elementos del Stream en paralelo. Cada hilo obtendrá un contenedor HashMap con los elementos que él ha tratado y llamando a este método se juntarán todos esos HashMap en uno único. Hablaremos de Stream con procesamiento en paralelo un poco más adelante.

Java Stream a Map[editar]

Ya hemos visto como hacerlo en el punto anterior, pero Java ofrece formas algo más fáciles también usando Stream. Vamos a verlas en este apartado.

Usando la misma lista de Data del ejemplo anterior, el siguiente código también obtiene el Map

collect = data.stream().collect(Collectors.toMap(d->d.id(), d->d));

Aquí usamos el método collect() igual que antes, pero en vez de los tres parámetros, usamos un Collector. Un Collector es una clase útil para usar con el método collect() evitando meter los tres parámetros. Y la clase Collectors tiene métodos para obtener los Collector habituales.

Por ejemplo, para convertir a Map podemos obtener el Collector adecuado con Collectors.toMap(). Este método recibe dos parámetros, que pueden ser expresiones Lambda. El primero para obtener la clave del Map para un elemento d del stream y el segundo para obtener el valor que queremos meter en el Map.

El siguiente ejemplo hace lo mismo, pero reemplazando las Lambda por referencia al método Data.id() y por Function.identity(). Esta última es una función que devuelve lo que le pasan como parámetro.

collect = data.stream().collect(Collectors.toMap(Data::id, Function.identity()));

Java Stream Group By[editar]

Otra utilidad de collect() y Collectors es obtener un Map donde los elementos se clasifican siguiendo algún criterio. Por ejemplo, si teneos un Stream de enteros, podemos usar el siguiente código para separar pares de impares

Stream<Integer> stream1 = List.of(1, 2, 3, 4, 5).stream();
Map<String, List<Integer>> collect1 = stream1.collect(Collectors.groupingBy(e -> e % 2==0?"par":"impar"));

Usamos Collectors.groupingBy() para obtener el Collector adecuado. En el Lambda que admite de parámetro, nos pasará cada uno de los elementos del Stream y debemos devolver la key del Map donde queremos que se elmento se clasifique. En nuestro ejemplo devolvemos "par" si el número e es par e "impar" si el número es impar.

El resulado será un Map de claves "par" e "impar" y de valores listas de enteros. En una los pares, en la otra los impares.

Java Stream flatMap()[editar]

El método flatMap() devuelve un Stream en el que cada elemento del Stream original pueden ser varios elementos del Stream final. Por ejemplo, imagina que tienes un Stream de listas y cada lista tiene varios enteros. flatMap te daría un Stream de enteros a partir del Stream de listas. El siguiente ejemplo lo muestra

Stream<List<Integer>> listStream = List.of(List.of(1, 2, 3, 4), List.of(5, 6, 7, 8)).stream();
Stream<Integer> integerStream = listStream.flatMap(e -> e.stream());

En la primera línea obtenemos un Stream compuesto por dos listas. La primera tiene los enteros de 1 a 4. La segunda los enteros de 5 a 8.

flatMap() admite un parámetro, puede ser un Lambda, al que pasará un elemento del Stream original (una lista) y que debe devolver un Stream de datos que queremos que formen parte del Stream final. En nuestro ejemplo, nos basta convertir la lista e que nos pasan a stream con stream().

El resultado de todo esto será un Stream con los enteros del 1 al 8.

Java Stream parallel()[editar]

Cuando hacemos operaciones con un Stream, en principio se tratarán todos los elementos del Stream de forma secuencial y de uno en uno, en el mismo hilo.

Sin embargo, si tiene sentido en la operación que queramos hacer, podemos indicar que el Stream admite procesamiento en paralelo por varios hilos, de forma que varios hilos se repartirán los elementos para ir tratándolos en paralelo y recogrer finalmente el resultado.

Para ello, si tenemos el Stream podemos llamar a su método parallel() para obtener un Stream que admite procesamiento en paralelo. Si estamos usando el método stream() de alguna otra clase para obtener un Stream, seguramente también tiene método parallelStream() para obtener el Stream con procesamiento paralelo.

Stream<List<Integer>> listStream = List.of(List.of(1, 2, 3, 4), List.of(5, 6, 7, 8)).stream();
Stream<List<Integer>> parallelListStream = listStream.parallel();

// o bien directamente
Stream<List<Integer>> parallelListStream = List.of(List.of(1, 2, 3, 4), List.of(5, 6, 7, 8)).parallelStream();

Sin embargo, tenemos que tener en cuenta si tiene sentido el procesamiento en paralelo. Al no ejecutarse las operaciones secuencialmente para los elementos uno a uno, el orden del resultado puede ser inesperado. En el ejemplo anterior, si usamos flatMap() para obtener la lista de enteros, unas veces podoemos obtenerla en orden (1,2,3,4,5,6,7,8) y otras veces al revés (5,6,7,8,1,2,3,4).

Pero si lo usamos para sumar enteros, o para convertir a Map, el resultado sería el mismo independientemente de en qué orden se preocesen los elementos.