8 jun 2023

Java Streams - Parte I



Hace ya bastante tiempo que JDK 8 está en la calle, pero muchas veces surgen dudas de las nuevas funcionalidades que podemos encontrarnos en cada nueva versión.

Sobre todo para aquellos desarrolladores y compañeros de profesión que solo ven nuevas versiones, como nuevas opciones que hacen funcionar las cosas por arte de magia, pero nada más lejos de la realidad.

Por tanto, dentro de este artículo vamos a analizar la funcionalidad del Stream API en Java 8. También veremos cómo crear y utilizar streams en acción. Y además la diferencia de rendimiento que se produce con su uso, algo que cuando hablamos en un mundo gobernado por millones de datos, se hace más que interesante.

Vamos!

Entorno

Para la realización del ejercicio se ha utilizado el siguiente entorno:

- Hardware: Portatil MSI Prestige 15 (Intel Core 11th Gen 4 CPU 3 GHz, 32 GB Ram)

- SO: Windows 11 Pro

- Entorno de desarollo: VSCode

- Wsl2 con Ubuntu 20.04.6 LTS (Focal Fossa)

- Java 17

- Maven 3.9.x

¿Qué son los Streams?

Como hemos comentado el API de Stream en Java se agregó en JDK 8 para proporcionar un enfoque funcional para procesar una colección de objetos. El Stream de Java no almacena datos y no es una estructura de datos. Además, el origen de datos subyacente no se modifica.

El Stream de Java utiliza interfaces funcionales y admite operaciones en estilo funcional en flujos de elementos mediante el uso de expresiones lambda.

Los Streams de Java 8 son un envoltorío alrededor de un origen de datos (Array, List, etc.) que nos permite operar con la fuente de datos y realizar un procesamiento en bloque de manera rápida y conveniente.

Es un flujo de datos que procesa los datos desde la fuente original y envía los datos procesados a la fuente de datos de destino.

Definición de la API


Configuración del entorno

Creamos un proyecto maven.

Para este objetivo sencillo usamos el propio arquetipo que nos proporciona Maven, descrito aqui:

Maven 5 min


Gist Maven

$ mvn archetype:generate
-DgroupId=com.mrknight.streams1
-DartifactId=tutorial
-DarchetypeArtifactId=maven-archetype-quickstart
-DinteractiveMode=false
$ cd tutorial
$ mvn clean package
$ java -cp target/tutorial-1.0-SNAPSHOT.jar com.mrknight.streams1.App
view raw bash.sh hosted with ❤ by GitHub




Para facilitarnos la vida a la hora de ejecutarlo, vamos a instalar el plugin de codehaus.mojo y Lombok.


<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.mrknight.streams1</groupId>
<artifactId>tutorial</artifactId>
<packaging>jar</packaging>
<version>1.0-SNAPSHOT</version>
<name>tutorial</name>
<url>http://maven.apache.org</url>
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>3.8.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.28</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.1.0</version>
<configuration>
<mainClass>com.mrknight.streams1.App</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>
view raw pom.xml hosted with ❤ by GitHub



De esta forma podremos ejecutarlo:
$ mvn exec:java 



Manos la obra

Para no cansarnos antes de empezar, vamos a crear un Stream rápido y así podemos ver de qué estamos hablando.

public Stream<String> createStreamFromArray() {
String[] strArr = { "Val1", "Demo2", "Val3", "Demo4", "Val3", "Demo4" };
return Arrays.stream(strArr);
}


Como podemos ver es algo sencillo, no deja de ser una colección de objetos. No almacenan info, la reserva de memoria se hace en el Array strArr, y las operaciones que ejecutes sobre el Stream no afectarán al origen de los datos.

Espera, ¿Operaciones? De qué estamos hablando ....

Operaciones


Las operaciones que tenemos para trabajar con Streams, las vamos a dividir en 2:
  1. Intermediate operations. Aquellas que devuelven un new Stream. Son operaciones que son enviadas a la siguiente operacion normalmente. Ejemplos:
    • filter()
    • map()
    • flatMap()
    • distinct()
    • sorted()
    • peek()
    • limit()
    • skip()
  2. Terminal operations. Aquellas que NO devuelven un new Stream. Son operaciones que una vez que las llamamos, el Stream se consume y por tanto no pueden ser enviadas a la siguiente operacion. Ejemplos:
    1. toArray()
    2. collect()
    3. count()
    4. reduce()
    5. forEach()
    6. forEachOrdered()
    7. min()
    8. max()
    9. anyMatch()
    10. allMatch()
    11. noneMatch()
    12. findAny()
    13. findFirst()

Si lo vemos por código:

Creamos un Service para que nos devuelva 2 tipos de Streams diferentes:


public class DemoService1 {
public Stream<String> createStreamFromArray() {
String[] strArr = { "Val1", "Demo2", "Val3", "Demo4", "Val3", "Demo4" };
return Arrays.stream(strArr);
}
public Stream<Item> createStreamFromEntity() {
Item f1 = new Item("Item 1", 3000);
Item f2 = new Item("Item 2", 1000);
Item f3 = new Item("Item 3", 300);
Item f4 = new Item("Item 4", 2000);
List<Item> lItem = new ArrayList<Item>();
lItem.add(f1);
lItem.add(f2);
lItem.add(f3);
lItem.add(f4);
return lItem.stream();
}
}

Y ahora algunas operaciones para ver como se comporta:


DemoService1 svc1 = new DemoService1();
// Some Stream operations
System.out.println("\n **** Intermediate Operations ****\n");
// Filter stream
System.out.println("*** Filter ***");
svc1.createStreamFromArray().filter(s -> s.startsWith("V")).forEach(System.out::println);
// Map: new stream applying the function to each element
System.out.println("*** Map ***");
svc1.createStreamFromArray().map(s -> s.toUpperCase()).forEach(System.out::println);
// Distinct: Return stream with only unique elements
System.out.println("*** Distinct ***");
svc1.createStreamFromArray().distinct().forEach(System.out::println);
// Sorted: Return stream sorted according natural order
System.out.println("*** Sorted ***");
svc1.createStreamFromArray().sorted().forEach(System.out::println);
// Some Stream Terminals operations
System.out.println("\n **** Terminals Operations ****\n");
// FindFirst: Return a Optional for de FIRST entry in the Stream
System.out.println("*** FindFirst ***");
System.out.println("\n\nItems filtradas: " + svc1.createStreamFromEntity()
.filter(f -> f.getImporte() > 1000)
.findFirst()
.get()
.getImporte());
// FindAny: Return a Optional for ANY entry in the Stream
System.out.println("*** FindAny ***");
System.out.println("\n\nFind any: " + svc1.createStreamFromEntity()
.filter(f -> f.getImporte() > 100)
.findAny()
.get()
.getImporte());
// Peek: Perform the specified op on EACH elem an return NEW STREAM. Normally is
// used to debug.
System.out.println("*** Peek ***");
svc1.createStreamFromEntity()
.map(f -> f.getImporte() * 10)
.peek(System.out::println)
.collect(Collectors.toList());
System.out
.println("*** Stream not modify source ***:: " + svc1.createStreamFromArray().collect(Collectors.toList()));
view raw AppDemo_1.java hosted with ❤ by GitHub

Rendimiento


Hasta ahora nada nuevo que no hiciéramos antes con unas cuantas líneas más de código. Pero no creo que la gente que evoluciona un JDK piense solo en ahorrarnos 2 líneas de código, que también, sino que esto realmente ¿Será más óptimo?

Vamos a verlo

En un pensamiento clásico, podríamos creer que para filtrar un elemento, vamos a recorrer toda la lista, y desde ahí hacemos el filtrado y búsqueda.

// FindFirst: Return a Optional for de FIRST entry in the Stream
System.out.println("*** FindFirst ***");
System.out.println("\n\nItems filtradas: " + svc1.createStreamFromEntity()
.filter(f -> f.getImporte() > 100)
.peek(System.out::println)
.findFirst()
.get()
.getImporte());


Podemos ver, que usando PEEK, como operación que veíamos anteriormente, esto se ejecuta UNA sola vez. ¿Cómooooo? ¿No hemos recorrido la lista N veces? pues no, porque la primera iteración ya me cumple la condición.


Resumen


En una forma clásica, habríamos ejecutado la operación N veces. Al principio es difícil de entender pero básicamente los Streams diseñan un flujo de trabajo que se ejecuta de forma unitaria item a item, así que cuando cumplo la condición, simplemente paro, el flujo termina.

Como hemos podido repasar, las mejoras en las diferentes versiones, sean JDKs, Frameworks, etc ... debemos repasarlas, pero no por que sea más "moderno" sino porque traen mejoras, corrigen errores, mejoran el rendimiento etc.

Os dejo el repo en GitHub por si queréis ampliarlo:


Share This!



No hay comentarios:

Publicar un comentario

Nota: solo los miembros de este blog pueden publicar comentarios.