Saltar a contenido

Streams

Los Streams fueron introducidos en Java 8 para abrir la puerta a la programación funcional al igual que con las expresiones lambda.

La API Stream permite manipular las colecciones como nunca antes. Nos permite realizar operaciones sobre la colección, como por ejemplo, buscar, filtrar, reordenar, etc.

Con Streams podemos utilizar cualquier clase que implemente la interfaz Collection como si fuese un Stream con la ventaja que nos ofrecen las expresiones lambda.

Con streams hay que tener el cuenta que la fuente o colección que utilicemos no se puede modificar y no debe afectar al estado de la misma. Cada operación dentro del stream debe verse como una operación independiente que opera sobre el argumento (colección).

A través del API Stream podemos trabajar sobre colecciones como si estuviéramos realizando sentencias SQL pero de una manera limpia y clara, evitando bucles y algoritmos que ralentizan los programas e incluso hacen que el código se torne inmanejable.

Cada operación del stream debe verse como un paso independiente, es decir, no se puede usar variables intermedias.

Partes de un Stream

Streams

De forma genérica existen 3 partes que componen un Stream:

  1. Un Stream funciona a partir de una lista o colección, que también se la conoce como la fuente de donde obtienen información.
  2. Operaciones intermedias como por ejemplo el método filter, que permite hacer una selección a partir de un predicado.
  3. Operaciones terminales, como por ejemplo los métodos max, min, forEach, findFirst etc.

Streams

La fuente proporciona los elementos a la tubería.

Funciones de Stream

Streams

Como ejemplo, vamos a trabajar sobre una lista de objetos de tipo Persona

public class Persona {

    String nombre;
    String apellido;
    String pais;
    String genero;
    Integer edad;

    public Persona(String nombre, String apellido, String pais, String genero, Integer edad) {
        this.nombre = nombre;
        this.apellido = apellido;
        this.pais = pais;
        this.genero = genero;
        this.edad = edad;
    }
    //getter y setter
    @Override
    public String toString() {
        return  nombre + " "+ apellido ;
    }
}

creamos una lista

 public static void main(String[] args) {
        // Crear un ArrayList para almacenar objetos Persona
        List<Persona> personas = new ArrayList<>();

        // Agregar 5 personas al ArrayList
        personas.add(new Persona("Juan", "Perez", "España", "Masculino", 30));
        personas.add(new Persona("María", "González", "México", "Femenino", 25));
        personas.add(new Persona("Carlos", "López", "Argentina", "Masculino", 40));
        personas.add(new Persona("Laura", "Martínez", "España", "Femenino", 35));
        personas.add(new Persona("Pedro", "Sánchez", "Chile", "Masculino", 28));

    }

Operaciones intermedias

Las operaciones intermedias obtienen elementos uno por uno y los procesan. Todas las operaciones intermedias son perezosas (lazy) y, como resultado, ninguna operación tendrá ningún efecto hasta que la tubería comience a funcionar.

Filter()

Como su nombre indica lo que hacemos es filtrar de todos los elementos del stream solo aquellos que cumplan una determinada condición. Recibe como parámetro una expresión lambda Predicate la cual debe devolver true solo en aquellos elementos que se quedarán en el stream y false para aquellos elementos que se deben eliminar.

Obtener las personas de España e imprimir el nombre por pantalla

personas.stream()
          .filter(persona->persona.pais.equals("España"))
          //operación terminal
          .forEach(persona -> System.out.println(persona.nombre));

Obtener las personas menores de 30 años y guardarlas en una lista

List<Persona> menores30=personas.stream()
                                  .filter(persona -> persona.edad<30)
                                  //operación terminar que convierte los elemento en una lista
                                  .collect(Collectors.toList());
System.out.println(menores30);

Sorted()

Se utiliza para ordenar los elementos del stream. Recibe como parámetro una expresión lambda de tipo Comparator para que podamos indicar la lógica de la ordenación.

Ordenar por apellido y mostrar por pantalla

 personas.stream()
            .sorted((p1,p2)->p1.apellido.compareTo(p2.apellido))
            .forEach(System.out::println);
            //también : forEach(p->System.out.println(p))

Comparator.comparing()

Podemos utilizar Comparator.comparing() que es un método estático de la interfaz Comparator en Java que permite crear un comparador basado en una función de extracción de claves. Esta función extrae una clave de los elementos que se van a comparar, y el comparador se construye utilizando estas claves para determinar el orden.

Para nuestro ejemplo

personas.stream()
  .sorted(Comparator.comparing(Persona::getApellido))
  .forEach(System.out::println);
Comparator.comparing() espera una función de referencia (o método de referencia) que devuelve la clave que se utilizará para comparar los elementos. En este caso, Persona::getApellido es una referencia al método getApellido de la clase Persona, que devuelve el apellido de una persona.

Comparator.comparing(Persona::getApellido) crea un comparador que compara objetos Persona basándose en sus apellidos.

Si necesitas ordenar en orden descendente, puedes usar Comparator.comparing() junto con el método reversed()

.sorted(Comparator.comparing(Persona::getApellido).reversed())
Por otro lado, nos permite anidar condiciones de orden. Si queremos ordenar primero por pais y después por apellido

personas.stream()
            .sorted(Comparator.comparing(Persona::getPais)
                    .thenComparing(Persona::getApellido))
            .forEach(persona->System.out.println(persona.getPais()+" "+persona));

Peek()

El método peek recibe como parámetro una expresión lambda de tipo Consumer para poder utilizar cada elemento del stream. Normalmente se utilizar para mostrar por consola el contenido del stream.

Este método existe principalmente para la depuración del programa, donde se desea ver los elementos a medida que pasan por un punto determinado en el pipeline.

peek() también se utiliza cuando queremos alterar el estado interno de un elemento (aunque esto no es muy común).

Obtener una lista con las personas menores de 40 ordenados por Pais

List<Persona> menores40=personas.stream()
          //antes de filtrar
          .peek(persona->System.out.println("Sin filtro: "+persona))
          .filter(persona -> persona.edad<40)
          .peek(persona->System.out.println("Despues de filtro: "+persona))
          .sorted(Comparator.comparing(Persona::getPais))
          .peek(persona->System.out.println("Ordenado: "+persona))
          //operación terminar que convierte los elemento en una lista
          .collect(Collectors.toList());
System.out.println(menores40);

Distinct()

Con distinct se seleccionan los elementos distintos dentro del stream eliminando los duplicados. Los elementos se comparan utilizando el método equals().

Map()

El método map recibe como parámetro una expresión lambda de tipo Function, por lo que debemos especificar una función que recibe como parámetro de entrada cada elemento del stream, y devuelve un objeto que puede ser un tipo de dato distinto o el mismo.

La función se aplica a cada uno de los elementos del stream para realizar alguna transformación sobre cada elemento y devuelve otro Stream sobre el cual puedes seguir trabajando. Se utiliza para modificar el contenido del stream. map() devuelve un stream nuevo que consta de los resultados de aplicar la función dada a los elementos del stream.

Obtener una lista de los paises ordenados

List <String> paises=personas.stream()
                //obtenemos los paises
                .map(persona-> persona.getPais())
                //tambien .map(Persona::getPais)
                //quitamos repeticiones
                .distinct()
                //ordenamos
                .sorted()
                //creamos la lista
                .collect(Collectors.toList());
System.out.println(paises);
Tenemos la siguiente clase

public class Person {
    String name;
    String country;

    public Person(String name, String country) {
        this.name = name;
        this.country = country;
    }
}

Crear una lista de Person a partir de la de Personas

 List<Person> people=personas.stream()
                .map(persona -> new Person(persona.getNombre()+" "+persona.getApellido(),persona.getPais()))
                .collect(Collectors.toList());

FlatMap

Cuando nos encontramos con estructuras más complejas, como por ejemplo una lista con otra lista, trabajar con map() no es suficiente, por ello, utilizamos flatMap() que lo que hace es "aplanar" listas anidadas y quedarnos con un stream plano.

Es una función que recibe una entrada y devuelve varias salidas para esa entrada. Esa es la diferencia con respecto a map() que recibe solo un parámetro de entrada y devuelve una salida.

flatMap() es una operación intermedia y devuelve un nuevo Stream. Devuelve un Stream que consiste en los resultados de reemplazar cada elemento del stream dado con el contenido de un stream mapeado producido al aplicar la función de mapeo provista a cada elemento. La función de mapeo utilizada para la transformación en flatMap() es una función sin estado y solo devuelve una secuencia de nuevos valores.

En el siguiente ejemplo el programa usa la operación flatMap() para convertir una lista de una lista List<List<Integer>> a una lista List<Integer>.

List<Integer> list1 = Arrays.asList(1,2,3);
List<Integer> list2 = Arrays.asList(4,5,6);
List<Integer> list3 = Arrays.asList(7,8,9);

List<List<Integer>> listOfLists = Arrays.asList(list1, list2, list3);

List<Integer> listOfAllIntegers = listOfLists.stream()
          .flatMap(x -> x.stream())
          .collect(Collectors.toList());

System.out.println(listOfAllIntegers);
//[1, 2, 3, 4, 5, 6, 7, 8, 9]
Obtener una lista con los caracteres que aparecen en los nombres sin repeticiones

List<Character> listaCaracteres=personas.stream()
        //obtenemos un stream con los nombres
        .map(Persona::getNombre)
        //convierte cada string en un IntStream de int y los conviente en char
        .flatMap(nombre->nombre.chars()
                //operación de stream que permite cambiar el tipo
                                .mapToObj(c -> (char) c))
        //obtenemos todos los caracteres sin repetición
        .distinct()
        .collect(Collectors.toList());
System.out.println(listaCaracteres);
//[J, u, a, n, M, r, í, C, l, o, s, L, P, e, d]

Operaciones terminales

Las operaciones terminales significan el final del ciclo de vida del steam. Lo más importante para nuestro escenario es que inician el trabajo en la tubería.

ForEach()

Recorremos cada elemento del stream para realizar alguna acción con él. Como bien sabemos recibe como parámetro una expresión lambda de tipo Consumer.

Collect()

Es una operación terminal, se utiliza para indicar el tipo de colección en la que se devolverá el resultado final de todas las operaciones realizadas en el stream.

List<String> lista = Arrays.asList("Texto1", "Texto2");
Set<String> set = lista.stream().collect(Collectors.toSet());

FindFirst()

Se utiliza para devolver el primer elemento encontrado del stream. Se suele utilizar en combinación con otras funciones cuando hay que seleccionar un único valor del stream que cumpla determinadas condiciones.

findFirst devuelve un objeto de tipo Optional para poder indicar un valor por defecto en caso de que no se pueda devovler ningún elemento del stream.

ToArray()

Con este método se puede convertir cualquier tipo de Collection en un array de forma sencilla.

Min()

Con min se obtiene el elemento del stream con el valor mínimo calculado a partir de una expresión lambda de tipo Comparator que indicamos como parámetro.

min devuelve un objeto de tipo Optional para poder indicar un valor por defecto en caso de que no se pueda devolver ningún elemento del stream.

Max()

Con max se obtiene el elemento del stream con el valor máximo calculado a partir de una expresión lambda de tipo Comparator que indicamos como parámetro de la expresión.

max devuelve un objeto de tipo Optional para poder indicar un valor por defecto en caso de que no se pueda devolver ningún elemento del stream.

// Encontrar la persona de mayor edad utilizando max()
Optional<Persona> personaMayorEdad = personas.stream()
        .max(Comparator.comparing(Persona::getEdad));
// Verificar si se encontró alguna persona y mostrar su informació
personaMayorEdad.ifPresent(System.out::println);
//Carlos López

count()

Permite obtener el total de elementos

Obtener el total de personas en "España"

// Contar el total de personas de España
  long totalPersonasEspaña = personas.stream()
          .filter(persona -> "España".equals(persona.getPais()))
          .count();

  System.out.println("Total de personas de España: " + totalPersonasEspaña);
  //Total de personas de España: 2

Otras funciones

Podemos consultar otras funciones de la clase Stream en la Api de Java

https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html

Stream vs SQL

La mayor similitud o manera de imaginarse los streams con otro término de la informática es con el lenguaje estructurado más utilizado en base de datos relacionales, el Structured Query Language (SQL)

Suponiendo una tabla de Personas

"Obtener los nombres de Personas con edad menor de 32 y que sean de España ordenado por nombre"

En SQL

SELECT nombre FROM Personas WHERE (edad<32) AND (pais="España") ORDER BY nombre

mediante Stream

 personas.stream()
                .filter(persona -> persona.getEdad()<32 && persona.getPais().equals("España"))
                .map(Persona::getNombre)
                .sorted()
                .forEach(System.out::println);
//Juan

Ventajas de Streams

  • Código conciso y más limpio.
  • Programación funcional: se programa escribiendo el "qué" en lugar del "cómo" para que sea comprensible de un vistazo.
  • Puede leer y comprender fácilmente el código que tiene una serie de operaciones complejas.
  • Ejecutar tan rápido como bucles for (o más rápido con operaciones paralelas)
  • Ideal para listas grandes.

Desventajas

  • Excesivo para pequeñas colecciones.
  • Difícil de aprender si está acostumbrado a la codificación de estilo imperativo tradicional