Guía de recopiladores de Java 8: groupingBy()

En esta guía detallada, aprenda a usar el recopilador groupingBy() en Java 8 para recopilar y agrupar elementos de flujos, con recopiladores y proveedores posteriores, a través de ejemplos.

Introducción

Una secuencia representa una secuencia de elementos y admite diferentes tipos de operaciones que conducen al resultado deseado. La fuente de un flujo suele ser una Colección o un Array, desde el cual se transmiten los datos.

Los flujos se diferencian de las colecciones en varios aspectos; sobre todo porque los flujos no son una estructura de datos que almacena elementos. Son de naturaleza funcional, y vale la pena señalar que las operaciones en un flujo producen un resultado y, por lo general, devuelven otro flujo, pero no modifican su origen.

Para "solidificar" los cambios, reúne los elementos de un flujo en una Colección.

Coleccionistas y Stream.collect()

Los recopiladores representan implementaciones de la interfaz Collector, que implementa varias operaciones de reducción útiles, como acumular elementos en colecciones, resumir elementos en función de un parámetro específico, etc.

Todas las implementaciones predefinidas se pueden encontrar dentro de la clase Collectors.

Sin embargo, también puede implementar muy fácilmente su propio recopilador y usarlo en lugar de los predefinidos; puede llegar bastante lejos con los recopiladores integrados, ya que cubren la gran mayoría de los casos en los que es posible que desee usarlos.

Para poder usar la clase en nuestro código necesitamos importarla:

1
import static java.util.stream.Collectors.*;

Stream.collect() realiza una operación de reducción mutable en los elementos de la secuencia.

{.icon aria-hidden=“true”}

Una operación de reducción mutable recopila elementos de entrada en un contenedor mutable, como una Colección, mientras procesa los elementos de la secuencia.

Usaremos Stream.collect() bastante a menudo en esta guía, junto con el recopilador Collectors.groupingBy().

Collectors.groupingBy()

La clase Collectors es amplia y versátil, y uno de sus muchos métodos que también es el tema principal de este artículo es Collectors.groupingBy(). Este método nos brinda una funcionalidad similar a la declaración "GROUP BY" en SQL.

Usamos Collectors.groupingBy() para agrupar objetos por una propiedad específica dada y almacenar el resultado final en un mapa.

Definamos una clase simple con algunos campos y un constructor clásico y captadores/establecedores. Usaremos esta clase para agrupar instancias de Students por su tema, ciudad y edad:

1
2
3
4
5
6
7
8
9
public class Student {
    private String subject;
    private String name;
    private String surname;
    private String city;
    private int age;

   // Constructors, Getters, Setters, toString()
}

Vamos a instanciar una Lista de estudiantes que usaremos en los siguientes ejemplos:

1
2
3
4
5
6
7
List<Student> students = Arrays.asList(
    new Student("Math", "John", "Smith", "Miami", 19),
    new Student("Programming", "Mike", "Miles", "New York", 21),
    new Student("Math", "Michael", "Peterson", "New York", 20),
    new Student("Math", "James", "Robertson", "Miami", 20),
    new Student("Programming", "Kyle", "Miller", "Miami", 20)
);

El método Collectors.groupingBy() tiene tres sobrecargas dentro de la clase Collectors, cada una de las cuales se construye sobre la otra. Cubriremos cada uno de ellos en las secciones siguientes.

Collectors.groupingBy() con una función de clasificación

La primera variante del método Collectors.groupingBy() solo toma un parámetro: una función de clasificación. Su sintaxis es la siguiente:

1
2
public static <T,K> Collector<T,?,Map<K,List<T>>> 
    groupingBy(Function<? super T,? extends K> classifier)

Este método devuelve un Collector que agrupa los elementos de entrada de tipo T según la función de clasificación, y devuelve el resultado en un Map.

La función de clasificación asigna elementos a una clave de tipo K. Como mencionamos, el recolector hace un Map<K, List<T>>, cuyas claves son los valores resultantes de aplicar la función de clasificación sobre los elementos de entrada. Los valores de esas claves son Listas que contienen los elementos de entrada que se asignan a la clave asociada.

Esta es la variante más simple de las tres. No quiere decir que los demás sean más difíciles de entender, es solo que esta implementación específica requiere menos argumentos.

Agrupemos a nuestros estudiantes en grupos de estudiantes por sus materias:

1
2
3
4
5
Map<String, List<Student>> studentsBySubject = students
    .stream()
    .collect(
        Collectors.groupingBy(Student::getSubject)
    );

Después de ejecutar esta línea, tenemos un Map<K, V> donde en nuestro caso K sería Math o Programming, y V representa una Lista de objetos Student que se asignaron a la materia K que el estudiante está cursando actualmente. Ahora, si simplemente imprimiéramos nuestro mapa studentBySubject, veríamos dos grupos con un par de estudiantes cada uno:

1
2
3
4
{
Programming=[Student{name='Mike', surname='Miles'}, Student{name='Kyle', surname='Miller'}], 
Math=[Student{name='John', surname='Smith'}, Student{name='Michael', surname='Peterson'}, Student{name='James', surname='Robertson'}]
}

Podemos ver que esto se parece un poco a lo que esperaríamos en el resultado: actualmente hay 2 estudiantes tomando una clase de Programación y 3 tomando Matemáticas.

Collectors.groupingBy() con una función de clasificación y un recopilador descendente

Cuando solo agrupar no es suficiente, también puede proporcionar un recopilador descendente al método groupingBy ():

1
2
3
public static <T,K,A,D> Collector<T,?,Map<K,D>> 
    groupingBy(Function<? super T,? extends K> classifier, 
               Collector<? super T,A,D> downstream)

Este método devuelve un Collector que agrupa los elementos de entrada de tipo T de acuerdo con la función de clasificación, luego aplica una operación de reducción en los valores asociados con una clave dada usando el Collector aguas abajo especificado.

Como se mencionó anteriormente, la operación de reducción "reduce" los datos que hemos recopilado al aplicar una operación que es útil en una situación específica.

If you'd like to read more about reduction in Java in great detail - read our Java 8 Streams: Guía definitiva para reducir()!

En este ejemplo, queremos agrupar a los estudiantes por la ciudad de la que son, pero no todos los objetos Student. Digamos que nos gustaría recopilar sus nombres (reducirlos a un nombre).

Como downstream aquí, usaremos el método Collectors.mapping(), que toma 2 parámetros:

  • Un mapeador - una función que se aplicará a los elementos de entrada y
  • Un colector descendente – un colector que aceptará valores mapeados

Collectors.mapping() en sí mismo hace un trabajo bastante sencillo. Adapta un colector que acepta elementos de un tipo para aceptar un tipo diferente aplicando una función de mapeo a cada elemento de entrada antes de la acumulación. En nuestro caso, asignaremos cada Estudiante a su nombre y devolveremos esos nombres como una lista.

En aras de la simplicidad, como solo tenemos 5 estudiantes en nuestra ArrayList, solo tenemos Miami y Nueva York como ciudades. Para agrupar a los estudiantes de la manera mencionada anteriormente, necesitamos ejecutar el siguiente código:

1
2
3
4
5
6
Map<String, List<String>> studentsByCity = students.stream()
              .collect(Collectors.groupingBy(
                  Student::getCity, 
                  Collectors.mapping(Student::getName, Collectors.toList())));
    
System.out.println(studentsByCity);

{.icon aria-hidden=“true”}

Nota: en lugar de List<String>, podríamos haber usado Set<String>, por ejemplo. Si optamos por eso, también tendríamos que reemplazar la parte toList() de nuestro código por toSet().

Esta vez, tendremos un Mapa de ciudades, con una lista de nombres de estudiantes asociados con una ciudad. Estas son reducciones de estudiantes, donde los hemos reducido a un nombre, aunque también podría sustituir esto con cualquier otra operación de reducción:

1
{New York=[Mike, Michael], Miami=[John, James, Kyle]}
Collectors.groupingBy() with Collectors.counting()

Nuevamente, las operaciones de reducción son muy poderosas y se pueden usar para encontrar las sumas mínimas, máximas, promedio, así como para reducir las colecciones en todos cohesivos más pequeños.

Existe una amplia variedad de operaciones que puede realizar a través de la reducción, y si desea obtener más información sobre las posibilidades, de nuevo, lea nuestra [Java 8 Streams: Guía para reducir()](/java-8- guia-de-flujos-para-reducir/)!

En lugar de reducir los estudiantes a sus nombres, podemos reducir las listas de estudiantes a sus conteos, por ejemplo, lo que se puede lograr fácilmente a través de Collectors.counting() como contenedor para una operación de reducción:

1
2
3
4
Map<Integer, Long> countByAge = students.stream()
                .collect(Collectors.groupingBy(
                    Student::getAge, 
                    Collectors.counting()));

El mapa countByAge ahora contendrá grupos de estudiantes, agrupados por su edad, y los valores de estas claves serán el conteo de estudiantes en cada grupo:

1
{19=1, 20=3, 21=1}

Nuevamente, hay una gran variedad de cosas que puede hacer con las operaciones de reducción, y esta es solo una faceta de eso.

Múltiples recopiladores.groupingBy()

Una aplicación poderosa similar del colector descendente es que podemos hacer otro Collectors.groupingBy().

Digamos que primero queremos filtrar a todos nuestros estudiantes por su edad (aquellos mayores de 20) y luego agruparlos por su edad. Cada uno de estos grupos tendrá grupos adicionales de estudiantes, agrupados por sus ciudades:

1
2
3
4
{
20={New York=[Student{name='Michael', surname='Peterson'}], Miami=[Student{name='James', surname='Robertson'}, Student{name='Kyle', surname='Miller'}]}, 
21={New York=[Student{name='Mike', surname='Miles'}]}
}

If you'd like to read more about the filtering, read our Java 8 Streams: Guía para filtrar()!

Collectors.groupingBy() con una función de clasificación, colector descendente y proveedor

La tercera y última variante sobrecargada del método groupingBy() toma los mismos dos parámetros que antes, pero con la adición de uno más: un método de proveedor.

Este método proporciona la implementación específica de Map que queremos usar para contener nuestro resultado final:

1
2
3
4
public static <T,K,D,A,M extends Map<K,D>> Collector<T,?,M> 
    groupingBy(Function<? super T,? extends K> classifier,
               Supplier<M> mapFactory,
               Collector<? super T,A,D> downstream)

Esta implementación difiere ligeramente de la anterior, tanto en el código como en los trabajos. Devuelve un Collector que agrupa los elementos de entrada de tipo T de acuerdo con la función de clasificación, luego aplica una operación de reducción sobre los valores asociados con una clave dada usando el Collector aguas abajo especificado. Mientras tanto, el ‘Mapa’ se implementa utilizando el proveedor ‘mapFactory’ suministrado.

Para este ejemplo, también modificaremos el ejemplo anterior:

1
2
3
4
5
Map<String, List<String>> namesByCity = students.stream()
                .collect(Collectors.groupingBy(
                        Student::getCity,
                        TreeMap::new, 
                        Collectors.mapping(Student::getName, Collectors.toList())));

{.icon aria-hidden=“true”}

Nota: Podríamos haber usado cualquier otra implementación Map que ofrece Java, como HashMap o LinkedHashMap también.

Para recapitular, este código nos dará una lista agrupada de estudiantes por la ciudad de la que son, y dado que estamos usando un TreeMap aquí, los nombres de las ciudades estarán ordenados.

La única diferencia con respecto al anterior es que hemos agregado otro parámetro - TreeMap::new que especifica la implementación exacta de Map que queremos usar:

1
{Miami=[John, James, Kyle], New York=[Mike, Michael]}

Esto hace que el proceso de recopilar secuencias en mapas sea mucho más fácil que tener que transmitir de nuevo y volver a insertar elementos utilizando una implementación diferente, como:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Map<String, List<String>> namesByCity = students.stream().collect(Collectors.groupingBy(
                Student::getCity,
                Collectors.mapping(Student::getName, Collectors.toList())))
            .entrySet()
            .stream()
                    .sorted(comparing(e -> e.getKey()))
                    .collect(Collectors.toMap(
                            Map.Entry::getKey,
                            Map.Entry::getValue,
                            (a, b) -> {
                                throw new AssertionError();
                            },
                            LinkedHashMap::new
                    ));

El código largo, enrevesado y de transmisión múltiple como este se puede reemplazar por completo con una versión sobrecargada mucho más simple cuando usa un ‘Proveedor’.

Este fragmento de código también da como resultado el mismo resultado que antes:

1
{Miami=[John, James, Kyle], New York=[Mike, Michael]}

Conclusión

La clase Collectors es poderosa y nos permite recopilar flujos en colecciones de varias maneras.

Puede definir sus propios recopiladores, pero los recopiladores integrados pueden llevarlo muy lejos ya que son genéricos y se pueden generalizar a la gran mayoría de las tareas que se le ocurran.

En esta guía, hemos echado un vistazo al recopilador groupingBy(), que agrupa entidades en función de una función de clasificación (normalmente reduciéndose a un campo de un objeto), así como sus variantes sobrecargadas.

Ha aprendido a utilizar el formulario básico, así como formularios con recopiladores y proveedores posteriores para simplificar el código y ejecutar operaciones funcionales potentes pero sencillas en los flujos.