Introducción a flujos de Java 8

El tema principal de este artículo son los temas de procesamiento de datos avanzados utilizando una nueva funcionalidad agregada a Java 8: la API de flujo y la API de recopilador. Para conseguir...

Introducción

El tema principal de este artículo son los temas de procesamiento de datos avanzados utilizando una nueva funcionalidad agregada a Java 8: la API de flujo y la API de recopilador.

Para aprovechar al máximo este artículo, ya debería estar familiarizado con las principales API de Java, las clases Object y String, y la Collection API.

API de transmisión

El paquete java.util.stream consta de clases, interfaces y muchos tipos para permitir operaciones de estilo funcional sobre los elementos. Java 8 introduce un concepto de Stream que permite al programador procesar datos de forma descriptiva y basarse en una arquitectura multinúcleo sin necesidad de escribir ningún código especial.

¿Qué es una corriente?

Un ‘Stream’ representa una secuencia de objetos derivados de una fuente, sobre los cuales se pueden realizar operaciones agregadas.

Desde un punto de vista puramente técnico, un flujo es una interfaz tipeada: un flujo de T. Esto significa que un flujo se puede definir para cualquier tipo de objeto, un flujo de números, un flujo de caracteres, un flujo de personas o incluso un flujo de una ciudad.

Desde el punto de vista de un desarrollador, es un concepto nuevo que podría parecer una Colección, pero en realidad es muy diferente de una Colección.

Hay algunas definiciones clave que debemos analizar para comprender esta noción de Stream y por qué difiere de una Colección:

Una secuencia no contiene ningún dato

El concepto erróneo más común que me gustaría abordar primero: una transmisión no contiene ningún dato. Esto es muy importante tener eso en cuenta y entender.

No hay datos en un Stream, sin embargo, hay datos en una Colección.

Una ‘Colección’ es una estructura que contiene sus datos. Un Stream solo está ahí para procesar los datos y extraerlos de la fuente dada, o moverlos a un destino. El origen puede ser una colección, aunque también puede ser una matriz o un recurso de E/S. La transmisión se conectará a la fuente, consumirá los datos y procesará los elementos de alguna manera.

Una transmisión no debe modificar la fuente {#una transmisión no debe modificar la fuente}

Una secuencia no debe modificar la fuente de los datos que procesa. El compilador de la JVM en sí mismo no impone esto, por lo que es simplemente un contrato. Si debo construir mi propia implementación de un flujo, no debo modificar la fuente de los datos que estoy procesando. Aunque está perfectamente bien modificar los datos en la transmisión.

¿Por qué es así? Porque si queremos procesar estos datos en paralelo, los vamos a distribuir entre todos los núcleos de nuestros procesadores y no queremos tener ningún tipo de problemas de visibilidad o sincronización que puedan derivar en malos rendimientos o errores. Evitar este tipo de interferencia significa que no debemos modificar la fuente de los datos mientras los estamos procesando.

Una fuente puede ser ilimitada

Probablemente el punto más poderoso de estos tres. Significa que la transmisión en sí misma puede procesar tantos datos como queramos. Ilimitado no significa que una fuente tenga que ser infinita. De hecho, una fuente puede ser finita, pero es posible que no tengamos acceso a los elementos contenidos en esa fuente.

Supongamos que la fuente es un archivo de texto simple. Un archivo de texto tiene un tamaño conocido aunque sea muy grande. Suponga también que los elementos de esa fuente son, de hecho, las líneas de este archivo de texto.

Ahora, podemos saber el tamaño exacto de este archivo de texto, pero si no lo abrimos y revisamos manualmente el contenido, nunca sabremos cuántas líneas tiene. Esto es lo que significa ilimitado: es posible que no siempre sepamos de antemano la cantidad de elementos que procesará una secuencia desde la fuente.

Esas son las tres definiciones de una corriente. Entonces podemos ver a partir de esas tres definiciones que un flujo realmente no tiene nada que ver con una colección. Una colección contiene sus datos. Una colección puede modificar los datos que contiene. Y, por supuesto, una colección contiene una cantidad conocida y finita de datos.

Características de la corriente {#características de la corriente}

  • Secuencia de elementos: los flujos proporcionan un conjunto de elementos de un tipo particular de manera secuencial. La secuencia obtiene un elemento a pedido y nunca almacena un elemento.
  • Fuente: los flujos toman una colección, matriz o recursos de E/S como fuente para sus datos.
  • Operaciones agregadas: las secuencias admiten operaciones agregadas como forEach, filter, map, sorted, match y otras.
  • Sustituir - La mayoría de las operaciones sobre un Stream devuelve un Stream, lo que significa que sus resultados se pueden encadenar. La función de estas operaciones es tomar datos de entrada, procesarlos y devolver la salida de destino. El método collect() es una operación de terminal que suele estar presente al final de las operaciones para indicar el final del procesamiento de Stream.
  • Iteraciones automatizadas - Las operaciones de transmisión realizan iteraciones internamente sobre el origen de los elementos, a diferencia de las colecciones donde se requiere una iteración explícita.

Creando una transmisión

Podemos generar una transmisión con la ayuda de algunos métodos:

corriente()

El método stream() devuelve el flujo secuencial con una colección como fuente. Puede utilizar cualquier colección de objetos como fuente:

1
2
private List<String> list = new Arrays.asList("Scott", "David", "Josh");
list.stream();
flujo paralelo()

El método parallelStream() devuelve un flujo paralelo con una colección como fuente:

1
2
private List<String> list = new Arrays.asList("Scott", "David", "Josh");
list.parallelStream().forEach(element -> method(element));

Lo que ocurre con los flujos paralelos es que, al ejecutar una operación de este tipo, el tiempo de ejecución de Java segrega el flujo en varios subflujos. Ejecuta las operaciones de agregado y luego combina el resultado. En nuestro caso, llama al método con cada elemento en el flujo en paralelo.

Aunque, esto puede ser una espada de doble filo, ya que ejecutar operaciones pesadas de esta manera podría bloquear otros flujos paralelos ya que bloquea los subprocesos en el grupo.

Flujo.de()

El método estático of() se puede usar para crear un Stream a partir de una matriz de objetos u objetos individuales:

1
Stream.of(new Employee("David"), new Employee("Scott"), new Employee("Josh"));
Stream.builder()

Y por último, puedes usar el método estático .builder() para crear un Stream de objetos:

1
2
3
4
5
6
7
Stream.builder<String> streamBuilder = Stream.builder();

streamBuilder.accept("David");
streamBuilder.accept("Scott");
streamBuilder.accept("Josh");

Stream<String> stream = streamBuilder.build();

Al llamar al método .build(), empaquetamos los objetos aceptados en un Stream normal.

Filtrado con un flujo

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class FilterExample {
    public static void main(String[] args) {
    List<String> fruits = Arrays.asList("Apple", "Banana", "Cherry", "Orange");

    // Traditional approach
    for (String fruit : fruits) {
        if (!fruit.equals("Orange")) {
            System.out.println(fruit + " ");
        }
    }

    // Stream approach
    fruits.stream() 
            .filter(fruit -> !fruit.equals("Orange"))
            .forEach(fruit -> System.out.println(fruit));
    }
}

Un enfoque tradicional para filtrar una sola fruta sería con un bucle clásico para-cada.

El segundo enfoque utiliza un Stream para filtrar los elementos del Stream que coinciden con el predicado dado, en un nuevo Stream que devuelve el método.

Además, este enfoque utiliza un método forEach(), que realiza una acción para cada elemento del flujo devuelto. Puede reemplazar esto con algo llamado referencia de método. En Java 8, una referencia de método es la sintaxis abreviada de una expresión lambda que ejecuta solo un método.

{.img-responsive}

La sintaxis de referencia del método es simple, e incluso puede reemplazar la expresión lambda anterior .filter(fruit -> !fruit.equals("Orange")) con ella:

1
Object::method;

Actualicemos el ejemplo y usemos referencias de métodos y veamos cómo se ve:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public class FilterExample {
    public static void main(String[] args) {
    List<String> fruits = Arrays.asList("Apple", "Banana", "Cherry", "Orange");

    fruits.stream()
            .filter(FilterExample::isNotOrange)
            .forEach(System.out::println);
    }
    
    private static boolean isNotOrange(String fruit) {
        return !fruit.equals("Orange");
    }
}

Los flujos son más fáciles y mejores de usar con expresiones Lambda y este ejemplo destaca cuán simple y limpia se ve la sintaxis en comparación con el enfoque tradicional.

Mapeo con una corriente

Un enfoque tradicional sería iterar a través de una lista con un bucle for mejorado:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
List<String> models = Arrays.asList("BMW", "Audi", "Peugeot", "Fiat");

System.out.print("Imperative style: " + "\n");

for (String car : models) {
    if (!car.equals("Fiat")) {
        Car model = new Car(car);
        System.out.println(model);
    }
}

Por otro lado, un enfoque más moderno es usar un Stream para mapear:

1
2
3
4
5
6
7
8
9
List<String> models = Arrays.asList("BMW", "Audi", "Peugeot", "Fiat");
        
System.out.print("Functional style: " + "\n");

models.stream()
        .filter(model -> !model.equals("Fiat"))
//      .map(Car::new)                 // Method reference approach
//      .map(model -> new Car(model))  // Lambda approach
        .forEach(System.out::println);

Para ilustrar el mapeo, considere esta clase:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
private String name;
    
public Car(String model) {
    this.name = model;
}

// getters and setters

@Override
public String toString() {
    return "name='" + name + "'";
}

Es importante tener en cuenta que la lista de modelos es una lista de Cadenas, no una lista de Automóvil. El método .map() espera un objeto de tipo T y devuelve un objeto de tipo R.

Básicamente, estamos convirtiendo String en un tipo de automóvil.

Si ejecuta este código, el estilo imperativo y el estilo funcional deberían devolver lo mismo.

Recolectando con una corriente

A veces, querrás convertir un Stream en una Colección o un Mapa. Usando la clase de utilidad Collectors y las funcionalidades que ofrece:

1
2
3
4
5
6
List<String> models = Arrays.asList("BMW", "Audi", "Peugeot", "Fiat");

List<Car> carList = models.stream()
        .filter(model -> !model.equals("Fiat"))
        .map(Car::new)
        .collect(Collectors.toList());

Coincidencia con un flujo

Una tarea clásica es categorizar objetos según ciertos criterios. Podemos hacer esto haciendo coincidir la información necesaria con la información del objeto y verificando si eso es lo que necesitamos:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
List<Car> models = Arrays.asList(new Car("BMW", 2011), new Car("Audi", 2018), new Car("Peugeot", 2015));

boolean all = models.stream().allMatch(model -> model.getYear() > 2010);
System.out.println("Are all of the models newer than 2010: " + all);

boolean any = models.stream().anyMatch(model -> model.getYear() > 2016);
System.out.println("Are there any models newer than 2016: " + any);

boolean none = models.stream().noneMatch(model -> model.getYear() < 2010);
System.out.println("Is there a car older than 2010: " + none);
  • allMatch() - Devuelve verdadero si todos los elementos de esta secuencia coinciden con el predicado proporcionado.
  • anyMatch() - Devuelve true si algún elemento de esta secuencia coincide con el predicado proporcionado.
  • noneMatch() - Devuelve verdadero si ningún elemento de esta secuencia coincide con el predicado proporcionado.

En el ejemplo de código anterior, todos los predicados dados se cumplen y todos devolverán verdadero.

Conclusión

La mayoría de las personas hoy en día usan Java 8. Aunque no todos usan Streams. El hecho de que representen un enfoque más nuevo para la programación y representen un toque con la programación de estilo funcional junto con expresiones lambda para Java, no significa necesariamente que sea un mejor enfoque. Simplemente ofrecen una nueva forma de hacer las cosas. Depende de los propios desarrolladores decidir si confiar en la programación de estilo funcional o imperativo. Con un nivel suficiente de ejercicio, la combinación de ambos principios puede ayudarlo a mejorar su software.

Como siempre, le recomendamos que consulte la documentación oficial para obtener información adicional.

Licensed under CC BY-NC-SA 4.0