Autor: José C. Riquelme Santos

Catedrático de Universidad. Departamento de Lenguajes y Sistemas Informáticos. Universidad de Sevilla

Apuntes de Java 8



Abrimos una nueva página con material sobre Java 8. De momento ofrecemos un recorrido sobre la funcionalidad del tipo Stream.

Si no conoce el concepto de interfaz funcional puede leer el siguiente documento:
 

Introducción

1. filter(Predicate)

2. allMatch(Predicate)

3. anyMatch(Predicate)

4. min(Comparator)

5. max(Comparator)

6. map(Function)

7. reduce(T, BiFunction)

8. forEach(Consumer)

9. ordered (Comparator) 

10. collect(Collector)

        10.1 Reducción a List o Set

    10.2 Estadísticas

    10.3 Reducción a Map

         10.3.1 toMap(Function, Function)

         10.3.2 toMap(Function, Function, BinaryOperator)

         10.3.3 groupingBy(Function) y partitioningBy(Predicate)

              10.3.4 groupingBy(Function, Collector)
 
              10.3.5 groupingBy(Function, Supplier, Collector)

   10.4 Iteraciones sobre Maps

         10.4.1 Invertir un Map

         10.4.2 Otros problemas


       
Introducción

El avance que representa Java 8 con respecto a versiones anteriores es muy significativo y nos va a obligar a modificar la perspectiva de cómo enseñar a programar a nuestros estudiantes. Estos apuntes intentan ser un resumen de las principales características que hemos encontrado en un apresurado estudio de las nuevas funcionalidades del tipo Stream. Todo el código aquí presentado ha sido implementado usando Eclipse Versión: Luna (4.4) Build id: I20140501-0200.

Para ejemplificar los casos de uso partimos de un tipo Vuelo con la siguiente interfaz:
 











El tipo Duracion implementa el tiempo de un Vuelo y tiene la funcionalidad siguiente (se puede implementar a partir del tipo Duration)
 






El objetivo es implementar un tipo Aeropuerto que manipula como atributo una colección de objetos de tipo Vuelo que hemos denominado vuelos y requiere una serie de funcionalidades que se irán presentado en cada uno de los siguientes secciones en que se ha dividido el texto para facilitar su comprensión. La dificultad del código es creciente y en esta primera versión no se explican los objetos que se manipulan como Predicate, Function, Supplier etc que son tratados en apuntes aparte.

Nos centramos en este documento en la estructura Stream que es un iterable virtual sobre objetos. El método stream() aplicado a una Collection permite obtener un Stream a partir de los objetos de la Collection. Por eso siempre el código de las siguientes secciones comienza con una sentencia vuelos.stream(). A continuación se describen las principales funcionalidades del tipo Stream.

 
1. filter(Predicate) 

Sirve para devolver otro Stream con sólo aquellos elementos del que invoca que cumplen el predicado. Se suele componer con métodos como count para calcular el número de elementos de una colección que cumplen una determinada condición. Por ejemplo, cuántos vuelos hay en una fecha determinada:




 

O cuántos vuelos completos hay:



 


Si un objeto de tipo Predicate se va a usar a menudo, debe definirse previamente y después invocarse. Por ejemplo, la condición de si un vuelo es de una determinada fecha, se definiría:



 

Y después se invocaría:
 





También se puede combinar con métodos como findFirst que devuelve el primer objeto de un stream, limit(long) que devuelve un stream limitado al número de elementos que se recibe como argumento o distinct que devuelve el número de elementos distintos del stream.
 
2. allMatch(Predicate)

Nos devuelve un valor de cierto si todos los elementos de una colección cumplen una condición. Por ejemplo si nos preguntamos si todos los vuelos de una fecha están completos:

 
  



En este ejemplo el Predicate del filter se podía haber añadido al Predicate del método allMatch con un método and del tipo Predicate.
 
 





Hay que tener en cuenta que este segundo método todosCompletos2 no funciona exactamente igual al primero. La diferencia está en que todosCompletos primero realiza una operación de filtro, si el resultado es un stream vacío, es decir, ningún vuelo cumple la condición de fecha, entonces allMatch devolverá true. En el segundo método si ningún vuelo cumple el filtro de fecha el resultado será false.
 

3. anyMatch(Predicate)

Nos devuelve un valor de cierto si algún elemento de una colección cumple una condición. Por ejemplo si nos preguntamos si hay algún vuelo a un destino en una fecha concreta:

 O si hay algún vuelo no completo un determinado día, definiendo previamente los objetos Predicate fechaIgual y vueloCompleto:

 


4. min(Comparator)

Devuelve el mínimo elemento de una colección según el orden establecido por un Comparator. Para definir el Comparator de un tipo por alguno de sus atributos es muy útil el método comparing de la clase Comparator. Por ejemplo, el vuelo más barato a un determinado destino se calcula comparando dos objetos de tipo Vuelo por su precio usando la Function Vuelo::getPrecio:

 





La clase Comparator permite especificar el tipo que se está comparando. Así en el ejemplo anterior se debería hacer usado el método comparingDouble ya que ese es el tipo devuelto por getPrecio. Igualmente existen los métodos comparingInt o comparingLong. También se puede usar el orden natural de Vuelo (dado por su compareTo) mediante la invocación:
        min(Comparator.naturalOrder());

Hay que tener en cuenta que el método min devuelve lo que en Java 8 se denomina un tipo opcional, ya que podría no existir, si por ejemplo, estuviéramos calculando el mínimo de una colección vacía que podría suceder si en el ejemplo anterior no hubiera ningún vuelo al destino que se pasa como argumento. Por eso se invoca después el método get, que devolvería el objeto menor si existe y sino lanza la excepción NoSuchElementException. Esta posibilidad puede ser controlada con otros métodos disponibles después de invocar a min. Como por ejemplo el método ifPresent que nos devuelve true si efectivamente existe mínimo o el métodorElse que permite crear y devolver un objeto si el mínimo no existe.

5. max(Comparator)

Similar al método anterior pero con el máximo en vez del mínimo. Por ejemplo, si quisiéramos devolver el Vuelo de mayor ocupación un determinado día, implementaríamos el método. Si el tipo Vuelo ya tuviera la funcionalidad getOcupacion podría invocarse como argumento del método comparingDouble.
 




 


Lógicamente si el tipo Vuelo no tuviera esa funcionalidad se podría definir una Function o una DoubleFunction para ello:
 

 
 


Quedando entonces el método de la siguiente forma. Nótese que aunque parece igual al primer método, no son lo mismo. En la primera versión se invoca a un método de Vuelo por eso se pone Vuelo::getOcupacion. En esta segunda versión se está invocando a las funciones anteriores:





 
6. map(Function)

Este método es realmente una familia de métodos con sus adaptaciones mapToInt (ToIntFunction), mapToLong (ToLongFunction) y mapToDouble (ToDoubleFunction)  devolviendo un Stream de objetos de otro tipo obtenidos a partir del tipo base aplicando una Function. Si el tipo devuelto es Integer, Long o Double existen los métodos específicos con las correspondientes funciones como argumentos.  Así si en el ejemplo anterior no nos interesara cuál es el Vuelo más completo sino cuál es la ocupación máxima, se podría implementar el método:

 






Los métodos map también nos permiten facilitar una serie de operaciones sobre los valores devueltos. Por ejemplo si quisiéramos calcular la recaudación total de los vuelos a un determinado destino escribiríamos:
 





Igual que se calcula la suma se podría devolver la media con el método average. Por ejemplo definiendo una ToDoubleFunction:



 


La recaudación media de un determinado día quedaría:
 


 



También el método map junto con distinct serviría para devolver el número de destinos diferentes:
 


 




7. reduce(T, BiFunction)

El método reduce produce una “reducción” de un stream, mediante la operación dada por la Bifunction usando como elemento neutro el primer argumento. Las operaciones sum o average vistas anteriormente son casos particulares de reduce para tipos numéricos.  Para otros tipos creados por el programador  como Duracion, con el método reduce se puede sumar las duraciones de los vuelos de un determinado día. Para ello el Tipo Duracion tiene definido un método suma capaz de devolver la suma de dos objetos Duracion en un nuevo objeto.
 
El método reduce se invoca con dos argumentos: el primero es el elemento neutro que inicializa el acumulador y el segundo la operación que acumula:
 


 




Otra forma de hacerlo es definir la BiFunction en el código. En este caso como los tres tipos que intervienen son de tipo Duracion es un caso particular de BinaryOperator que se podría definir:

  BinaryOperator<Duracion> sumadur = (x,y)-> {return x.suma(y);};

De esta manera la llamada al método reduce quedaría:
 
  reduce(new DuracionImpl(0,0), sumadur);

 

8. forEach(Consumer)

El método forEach sirve para llevar a cabo una acción definida por el objeto Consumer que se pasa como argumento sobre todos los elementos del stream. Por ejemplo para cambiar las duraciones de los vuelos a un determinado destino en un número determinado de minutos, se construiría un método como el siguiente:



 


Para incrementar los precios de los vuelos un 10% a partir de un día determinado:






También definiendo previamente un Consumer:

 






El método foreach se puede usar para hacer un método estático que escriba en un fichero de texto los elementos de un stream uno por línea:






 


9. sorted(Comparator)

El método sorted devuelve el stream ordenado por el Comparator que se pasa como argumento. Sin el argumento el orden sería el natural. Por ejemplo, reutilizando el método estático anterior para escribir un Stream en un fichero, podríamos implementar el siguiente método que escribe un Stream ordenado por Fecha y Duracion.

 
 




10. collect(Collector)
La funcionalidad collect es un potente mecanismo para reducir un stream en otra colección, estructura Map o dato. Alguna de estas funcionalidades puede ser realizada por métodos vistos anteriormente, como reduce. Como las posibilidades son muy numerosas vamos a dividirlas según el objetivo.

10.1 Reducción a List o Set. El método collect proporciona un mecanismo muy potente para transformar un stream en distinto tipo de colecciones de datos como List y Set. Por ejemplo, si se quisiera devolver una lista con las duraciones de los vuelos a un determinado destino, se escribiría:




 

La clase Collectors proporciona dos métodos toList y toSet de forma que el Stream de objetos Duracion devuelto por la operación map puede ser convertida en una lista o un conjunto respectivamente. Por ejemplo, el conjunto de los destinos posibles desde un aeropuerto viene dado por el método:





 

Los métodos toList y toSet son casos particulares del método toCollection que recibe un argumento de tipo Supplier con, por ejemplo, una invocación al constructor del tipo. De esta manera podemos devolver un SortedSet con los destinos de un determinado día:




 


También se pueden transformar los objetos antes de su recolección. Por ejemplo, si quisiéramos obtener una lista con las duraciones de los vuelos a un determinado destino incrementadas en m minutos, se implementaría el siguiente método: 
 


 
 


10.2 Estadísticas. Si solo quisiéramos contar el número de elementos del stream se puede usar también Collectors.counting(). Igualmente si el stream es de datos numéricos se pueden usar los colectores Collectors.summingDouble (summingInt o summingLong) para devolver la suma del stream en el mismo tipo y Collectors.averagingDouble (averagingInt o averagingLong) que devuelven la media aritmética siempre en tipo Double.

También existe el tipo DoubleSummaryStatistics a partir del cual es posible obtener los valores de la suma, máximo, mínimo y media de una serie de valores numéricos. Un objeto de este tipo es devuelto por Collectors.summarizingDouble que recibe una ToDoubleFunction (también existen igualmente summarizingInt y summarizinLong). Por ejemplo, para obtener la media de los precios de los vuelos a un determinado destino pondríamos:




 

Teniendo en cuenta que la varianza de una muestra es la media de los valores al cuadrado menos la media aritmética al cuadrado, se podría calcular la varianza de todos los precios del aeropuerto con el siguiente método:




 

 
Cuando no podemos emplear las operaciones aritméticas habituales de suma o media porque el tipo base no es un tipo numérico (Double, Integer, etc), se debe emplear la funcionalidad reducing de Collectors, para implementar nuevas operaciones sobre este tipo base. Por ejemplo si quisiéramos devolver la suma de las duraciones de todos los vuelos a un destino incrementadas en m minutos:





 
10.3 Reducción a Map. La clase Collectors proporciona los métodos partitioningBy, groupingBy y toMap para organizar la información de un stream en un Map. Las posibilidades son numerosas ya que tanto groupingBy como toMap puede recibir distintos argumentos.

10.3.1 toMap(Function, Function). Este método permite crear un Map para relacionar las propiedades de un tipo si no existen claves repetidas. Así, por ejemplo, si queremos obtener un Map que relaciona cada Vuelo con su destino, escribimos el siguiente método:







O un Map que relacione cada código con su destino:






Si en el caso anterior, hubiera en el stream varios vuelos con el mismo código, el método lanzaría una excepción por clave duplicada. Para esos casos, debemos usar el método toMap con un tercer argumento de tipo BinaryOperator como se comenta en la siguiente sección.

10.3.2 toMap(Function, Function, BinaryOperator).

Este método construye un Map donde las claves son proporcionadas por el primer argumento de tipo Function y los valores por la segunda Function. En el caso de que haya claves duplicadas el argumento de tipo BinaryOperator nos proporciona la posibilidad de "unir" los valores correspondientes. Por ejemplo, si queremos construir un Map que asigne a cada destino el número de pasajeros. Como lógicamente habrá varios vuelos con el mismo destino, definimos que se sumen los pasajeros mediante el tercer argumento. 







Si los argumentos son de tipo String se pueden concatenar también con el operador +. Por ejemplo, si queremos construir un Map que relacione cada destino con una cadena que contenga todos los códigos de los vuelos que van a ese destino:







Se pueden implementar como BinaryOperation operaciones más complejas como un mínimo. En el siguiente ejemplo se construye un Map que relaciona cada destino con su vuelo más barato (mínimo precio) mediante un método privado:











Una versión diferente del mismo problema es usar los métodos  maxBy o minBy de BinaryOperator:







Se pueden construir Maps más complejos añadiendo código, por ejemplo para construir listas o conjuntos en los valores del Map. El siguiente método devuelve un Map que relaciona la lista de Vuelos con su fecha de partida:







  


Incluso se podría hacer que las listas estuvieran ordenadas, por ejemplo por precio:










10.3.3 groupingBy(Function) y partitioningBy(Predicate). En este primer caso, la funcionalidad groupingBy recibe una Function, mientras que partitioningBy es un caso particular en el que la Function es sustituida por un Predicate. En este caso, el conjunto final es un List con los objetos del stream. Veamos distintos ejemplos de uso, si se quisiera construir un Map<Boolean, List<Vuelo>> para clasificar los vuelos del aeropuerto en completos o no, escribiríamos usando el Predicate declarado en el punto 2:





Un poco más complejo es si quisiéramos obtener un Map<Boolean, List<Vuelo>> para clasificar los vuelos según hubieran pasado o no un determinado umbral de ocupación que se pasa como argumento:
 





Para obtener un Map con más de dos valores en el conjunto inicial hay que recurrir a groupingBy. De esta manera para obtener un Map que organize los vuelos por fecha, escribiríamos:

 




10.3.4 GroupingBy(Function, Collector). Por supuesto es posible que el tipo del conjunto final del Map no sea un List de objetos del stream sino que también podría ser un Set, pasando como argumentos a groupingBy además de la Function para el conjunto inicial un Collector para el conjunto final:
 
 





Si la información del conjunto final es una reducción u operación sobre los objetos del stream es necesario acudir a otros objetos de tipo Collector para pasarle como argumento a groupingBy. De esta forma, usando los métodos de Collectors counting, averaging o summing se puede obtener una reducción del tipo del conjunto final del Map. Así para obtener el número de vuelos por destino el método sería:



 


Igualmente si se quiere devolver un Map con los precios medios por destino:

 




O un Map con la recaudación por destino:
 




Que se podría escribir más sencillo usando la ToDoubleFunction getRecaudacion del punto 6, por ejemplo para calcular la recaudación por fecha:






Otras veces se requiere que el tipo del conjunto de valores del Map sea una transformación del tipo original del stream. En ese caso el Collector que se pasa como segundo argumento de groupingBy es un Collectors.mapping que debe recibir una Function para modificar el flujo de entrada y otro Collector para su recolección. Por ejemplo, para construir un Map con destinos en el conjunto de claves y una lista de los precios por destino en el conjunto de valores se escribiría:
 
 

 


Un paso más allá es realizar una operación añadida sobre la colección. Por ejemplo, para responder a la pregunta de cuántas fechas distintas hay para cada destino, se puede construir un Map que relacione cada destino con el Set de fechas que les corresponde. Al ser un Set las fechas repetidas se han eliminado. Posteriormente cada conjunto de fechas es transformado en su cardinal.





 


10.3.5 GroupingBy(Function, Supplier, Collector). Proporcionar un Supplier permite, entre otras posibilidades devolver SortedMap como resultado de groupingBy. De esta manera, para devolver un SortedMap con una lista de vuelos por Fecha, escribiríamos:





 


10.4 Iteraciones sobre Maps.

Para iterar sobre los elementos de un Map hay que invocar el método entrySet que devuelve un Set de los elementos del Map en forma de conjunto de pares key-value.  La invocación posterior del método stream() permite iterar mediante las operaciones definidas para Stream los elementos del Map. A continuación se exponen algunos ejemplos.


10.4.1 Invertir un Map

Hay algunas preguntas que no son fáciles de responder en pocos pasos. Por ejemplo, ¿Cuáles son los destinos con mayor número de vuelos? ¿Cuál es la fecha con menor número de pasajeros? Si tenemos un Map que relaciona cada destino con su número de vuelos, habría que recorrer el conjunto de pares buscando cuál es la clave (key) del mayor valor (value). Pero qué responder si hay varios destinos con el mismo número de vuelos máximo. Entonces habría que devolver un List o Set de destinos.

Una forma de resolver este tipo de problemas es mediante la "inversión" de un Map. Es decir, si tenemos un Map<K,V> obtener un Map<V,List<K>> o Map<V,Set<K>> donde los elementos de los conjuntos inicial y final del Map original han intercambiado sus papeles en el Map inverso. Lógicamente como la aplicación original puede ser sobreyectiva es necesario asignar en el Map inverso a cada elemento del conjunto inicial varios del conjunto inicial.  Por ejemplo, hemos hallado anteriormente un Map<String, Long> con el número de vuelos por destino. Si quisiéramos saber el destino con más vuelos, una solución sería invertir el Map anterior en un SortedMap<Long,List<String>> de manera que la clave mayor nos proporciona la lista de destinos con mayor número de vuelos. En Java 7 esté método invierteMap se podía escribir como método estático así:

public static <T,K>SortedMap<T,List<K>>invierteMap(Map<K,T>  m){
       SortedMap<T,List<K>> res=new TreeMap<T,List<K>>();
       Set<K> sp = m.keySet();
       for(K elem: sp){
               T val =m.get(elem);
               if (res.containsKey(val)) {
                        res.get(val).add(elem);
               }
               else {
                     List<K>lista = new LinkedList<K>();
                     lista.add(elem);
                     res.put(val, lista);
               }
        }
        return res;
}     

En Java 8 se usaría un código más compacto, dejando al lector si más fácil o no  de entender. Se trata de convertir el Map original en un stream mediante el método entrySet que devuelve el conjunto de pares key-value. Una vez que tenemos el Map convertido en un stream de pares, se invoca a collect agrupando los pares por los values  (es decir, los valores pasan a ser las claves del nuevo Map) y haciendo que en el conjunto final estén List de las claves originales mediante el mapping correspondiente. El constructor TreeMap nos permite que el resultado sea un Sortedmap:              




 

Cambiando el toList() final por un ToSet() obtendríamos el método invierteMapASet. Una aplicación inmediata de esta estructura de código para el caso particular de obtener el número de vuelos relacionado con el conjunto de destinos es el siguiente método:



 
De esta manera responder a la pregunta cuáles son los destinos con mayor número de vuelos se resuelve de la siguiente manera:


 


O empleando el método estático invierteMapASet:


 

Una versión más simple de este problema se puede resolver si sabemos que sólo hay un máximo y por tanto no es necesario devolver un Set con los posibles valores donde se da. Por ejemplo, si sólo hubiera un destino con el máximo de vuelos una solución sería partir del Map que asigna a cada destino su número de vuelos, convertir a stream su conjunto de pares key-value, y después obtener el máximo mediante un comparator sólo con los values y devolver el par que le corresponde al máximo mediante get y la clave de ese par mediante getKey:






10.4.2 Otros problemas

Otros ejercicios que se pueden proponer a partir de un Map son los que responden a preguntas sobre el conjunto de values del Map. Por ejemplo, ¿cuál es el número medio de vuelos que hay por día? Para responder a esa pregunta partimos del Map que relaciona cada fecha (key) con el número de vuelos de esa fecha (value). A partir de este Map tenemos que calcular la media del conjunto de values:









Un poco más compleja es la siguiente pregunta: dado un número ¿Cuáles son los destinos que tienen como mínimo ese número de vuelos? Para responderla partimos del Map que relaciona cada destino con el número de vuelos. A partir de ese Map filtramos los pares key-value cuyo valor sea mayor que el dado, a partir de esos pares los transformamos en un stream de claves y los coleccionamos en una List:


8 comentarios:

  1. Hola, me gustó el documento, pero tiene algunos errores en la sintaxis de los ejemplos que generan cierta confucion, saludos

    ResponderEliminar
  2. Hola, gracias por tu comentario. Es bastante probable que al cortar y pegar trozos de código de Eclipse a html se hayan perdido caracteres. Te agradecería si pudieras ser un poco más preciso en dónde están los errores.

    ResponderEliminar
  3. Está interesante el ejercicio. ¿Sería tan gentil de compartir el código completo?

    ResponderEliminar
  4. Completando el comentario que habla de los errores que hay, estos son los que yo he detectado (se entiende lo que se quiere decir, pero siempre está bien corregir estos fallos):

    -En la línea 8 cuando se habla del mínimo esta escrito "comparintLong". En el mismo punto, la última línea puede resultar confusa cuando habla del "orElse".
    -En el punto 10.3.1 línea 3, hay escrito "conhunto".

    Es probable que haya otros que no he detectado.

    ResponderEliminar
    Respuestas
    1. Muchas gracias, este verano, le daré un repaso completo

      Eliminar
  5. Hola,me gustaria saber cuales la diferencia entre map y flatMap.

    ResponderEliminar
    Respuestas
    1. map es fácil de explicar, recibe un Stream, aplica a cada elemento del mismo una Function y devuelve un Stream con los elementos transformados.

      flatMap como su nombre indica primero aplana y después transforma. Por tanto flatMap está indicado cuando queremos hacer una transformación de los elementos de un Stream que tienen una estructura en "dos niveles", por ejemplo una List de List o similar

      Por ejemplo, si queremos trasnformar una List de String en una List con sus longitudes ponemos:

      List<String>lc1 = new ArrayList<>();
      lc1.add("ACDSDE");
      lc1.add("BCDS");
      lc1.add("CFDSF");
      List<Integer>ln1= lc1.stream().map(x->x.length()).collect(Collectors.toList());
      System.out.println(ln1);

      y la salida es [6, 4, 5]

      Sin embargo si tuviéramos esto:

      List<String>lc1 = new ArrayList<>();
      lc1.add("ACDSDE");
      lc1.add("BCDS");
      lc1.add("CFDSF");
      List<String> lc2 = new ArrayList<>();
      lc2.add("ZXYV");
      lc2.add("HGFTR");
      lc2.add("JKH");

      List<List<String>> lc3 = new ArrayList<>();
      lc3.add(lc1);lc3.add(lc2);

      Para sacar un Stream con las longitudes de los String de lc3 tendríamos que poner

      List<Integer>ln3= lc3.stream().flatMap(x->x.stream()).map(x->x.length()).collect(Collectors.toList());

      System.out.println(ln3);

      Mostraría en la consola [6, 4, 5, 4, 5, 3]

      Eliminar
  6. Muchísimas gracias por el aporte José.

    He creado un repositorio en GitHub con estos ejercicios realizados por mi simplemente para tenerlos accesible en un futuro.

    Lo indico por si cualquiera quiere acceder a este código o aportar mejoras.

    https://github.com/AlbertFX91/java8-exercises

    Muchas gracias por todo u

    ResponderEliminar