CodeGym /Cursos /JAVA 25 SELF /Métodos groupingBy y partitioningBy (Collectors)

Métodos groupingBy y partitioningBy (Collectors)

JAVA 25 SELF
Nivel 31 , Lección 2
Disponible

1. Introducción

En la vida real casi siempre toca agrupar datos. Por ejemplo, dividir a los estudiantes por cursos, separar productos por categorías, distinguir a los mayores de edad de los menores, etc.

Sin Stream API estas tareas se resolvían a mano: recorrer la colección, comprobar el criterio y añadir al listado adecuado en el Map. Este es un código «old school» típico:

Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee employee : employees) {
    String dept = employee.getDepartment();
    byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(employee);
}

Funciona, pero es como si clasificaras pasta en tarros a mano. ¿Y si necesitas agrupar por varios criterios? ¿O además sumar importes por grupo? El código crece y se vuelve ilegible.

Stream API y los colectores especiales (groupingBy, partitioningBy) permiten hacerlo en una o dos líneas, y parecer un verdadero mago de Java.

2. Colector groupingBy: agrupación por criterio

Idea principal

groupingBy es un colector que convierte un flujo de elementos en un Map, donde la clave es el resultado de la función-criterio y el valor es la lista de elementos que cumplen ese criterio.

Firma:

Collectors.groupingBy(Function<T, K>)
  • T — tipo del elemento en el stream,
  • K — tipo de la clave (grupo) que devuelve la función.

Ejemplo simple: agrupar empleados por departamento

Supongamos que tenemos la clase:

public class Employee {
    private final String name;
    private final String department;
    // ... constructor y getters
    public Employee(String name, String department) {
        this.name = name;
        this.department = department;
    }
    public String getName() { return name; }
    public String getDepartment() { return department; }
}

Y una colección de empleados:

List<Employee> employees = List.of(
    new Employee("Alisa", "IT"),
    new Employee("Bob", "HR"),
    new Employee("Klara", "IT"),
    new Employee("Denis", "Finance"),
    new Employee("Eva", "HR")
);

Agrupamos por departamento:

Map<String, List<Employee>> byDepartment = employees.stream()
    .collect(Collectors.groupingBy(Employee::getDepartment));

¿Qué obtuvimos?

  • Clave: nombre del departamento (String).
  • Valor: lista de empleados de ese departamento (List<Employee>).

Imprimimos el resultado:

byDepartment.forEach((dept, emps) -> {
    System.out.println(dept + ": " +
        emps.stream().map(Employee::getName).toList());
});

Salida:

IT: [Alisa, Klara]
HR: [Bob, Eva]
Finance: [Denis]

¿Cómo funciona por dentro?

Primero se calcula la clave para cada elemento del stream (por ejemplo, getDepartment()). Si esa clave ya existe en el Map, el elemento se añade a la lista correspondiente. Si no existe, se crea una nueva lista.

Analogía

Imagina que ordenas cartas por carpetas: para cada carta miras si ya hay una carpeta con el nombre necesario; si no la hay, creas una nueva y pones allí la carta.

3. Colector partitioningBy: separación en dos grupos

A veces no hace falta «agrupar por valor», sino simplemente partir la colección en dos partes según un criterio lógico (true/false). Por ejemplo, empleados con salario por encima y por debajo de un umbral, estudiantes — en «aprobado/suspendido».

Para esto existe el colector especial partitioningBy.

Firma:

Collectors.partitioningBy(Predicate<T>)

Predicate — una función que devuelve un valor booleano.

Ejemplo: particionar empleados por nivel salarial

Supongamos que tenemos:

public class Employee {
    private final String name;
    private final int salary;
    // ... constructor y getters
    public Employee(String name, int salary) {
        this.name = name;
        this.salary = salary;
    }
    public String getName() { return name; }
    public int getSalary() { return salary; }
}

Y la lista:

List<Employee> employees = List.of(
    new Employee("Alisa", 120_000),
    new Employee("Bob", 80_000),
    new Employee("Klara", 150_000),
    new Employee("Denis", 95_000)
);

Dividamos en «ricos» y «modestos»:

Map<Boolean, List<Employee>> partitioned = employees.stream()
    .collect(Collectors.partitioningBy(e -> e.getSalary() > 100_000));
  • true — empleados con salario mayor que 100_000.
  • false — el resto.

Imprimimos:

System.out.println("Ricos: " +
    partitioned.get(true).stream().map(Employee::getName).toList());
System.out.println("Modestos: " +
    partitioned.get(false).stream().map(Employee::getName).toList());

Resultado:

Ricos: [Alisa, Klara]
Modestos: [Bob, Denis]

¿Cuándo usar partitioningBy y cuándo groupingBy?

  • Si hay más de dos grupos — usa groupingBy.
  • Si solo hay dos grupos por un criterio booleano — usa partitioningBy: es más rápido y claro.

4. Agrupaciones anidadas: agrupar por varios criterios

A veces queremos agrupar no solo por un criterio, sino «anidar» una agrupación dentro de otra. Por ejemplo, agrupar empleados primero por departamento y dentro del departamento por puesto.

Ejemplo:

Supongamos que en nuestra clase Employee hay también el campo position:

public class Employee {
    private final String name;
    private final String department;
    private final String position;
    // ... constructor y getters
}

Agrupación anidada:

Map<String, Map<String, List<Employee>>> byDeptAndPosition = employees.stream()
    .collect(Collectors.groupingBy(Employee::getDepartment,
        Collectors.groupingBy(Employee::getPosition)));
  • Clave externa — departamento.
  • Clave interna — puesto.
  • Valor — lista de empleados.

¿Cómo obtener a los empleados del departamento de IT con puesto "Developer"?

List<Employee> itDevs = byDeptAndPosition
    .getOrDefault("IT", Map.of())
    .getOrDefault("Developer", List.of());

Esquema visual

Map<Department, Map<Position, List<Employee>>>
└─ "IT"
    ├─ "Developer" -> [Alisa, Klara]
    └─ "QA"        -> [Boris]
└─ "HR"
    └─ "Recruiter" -> [Denis]

5. Ejemplos prácticos de agrupación

Ejemplo 1: Agrupar cadenas por longitud

List<String> words = List.of("cat", "dog", "elephant", "bee", "ant", "dolphin");

Map<Integer, List<String>> byLength = words.stream()
    .collect(Collectors.groupingBy(String::length));

byLength.forEach((len, ws) -> System.out.println(len + ": " + ws));

Salida:

3: [cat, dog, bee, ant]
8: [elephant, dolphin]

Ejemplo 2: Agrupar números por paridad

List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);

Map<String, List<Integer>> byParity = numbers.stream()
    .collect(Collectors.groupingBy(n -> n % 2 == 0 ? "even" : "odd"));

System.out.println(byParity);
// {odd=[1, 3, 5], even=[2, 4, 6]}

Ejemplo 3: Uso de partitioningBy para cadenas

Dividamos las cadenas entre las que empiezan por "A" y las demás:

List<String> names = List.of("Alice", "Bob", "Anna", "Charlie");

Map<Boolean, List<String>> byA = names.stream()
    .collect(Collectors.partitioningBy(s -> s.startsWith("A")));

System.out.println("A-names: " + byA.get(true));  // [Alice, Anna]
System.out.println("Other: " + byA.get(false));   // [Bob, Charlie]

5. Detalles útiles

Cómo usar el resultado de la agrupación

A menudo, después de agrupar, no solo queremos obtener un Map, sino procesarlo de alguna manera:

  • Recorrer todos los grupos e imprimir información.
  • Encontrar el grupo con la mayor cantidad de elementos.
  • Para cada grupo calcular, por ejemplo, una suma o una media — de esto hablaremos con más detalle en la siguiente lección.

Ejemplo: mostrar el número de empleados en cada departamento

byDepartment.forEach((dept, emps) ->
    System.out.println(dept + ": " + emps.size() + " empleados"));

Comparación con la implementación manual

Para afianzar: así se vería la agrupación por departamento «a la antigua»:

Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee e : employees) {
    String dept = e.getDepartment();
    byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(e);
}

Y así con Stream API:

Map<String, List<Employee>> byDepartment = employees.stream()
    .collect(Collectors.groupingBy(Employee::getDepartment));

Conclusión: menos código, menos errores, mayor legibilidad.

Trucos

Si necesitas agrupar no en un List, sino, por ejemplo, en un Set, utiliza como segundo parámetro Collectors.toSet():

Collectors.groupingBy(Employee::getDepartment, Collectors.toSet())

Puedes agregar directamente: por ejemplo, obtener la cantidad de empleados en cada departamento:

Collectors.groupingBy(Employee::getDepartment, Collectors.counting())

Pero de esto hablaremos en la siguiente lección.

Tras partitioningBy siempre habrá dos claves: true y false. Incluso si uno de los grupos está vacío.

Después de agrupaciones anidadas la estructura se convierte en un «árbol»: Map dentro de Map y así sucesivamente.

6. Errores típicos al agrupar y particionar

Error n.º 1: Tipo de resultado incorrecto. Los principiantes a menudo esperan que el resultado de groupingBy sea simplemente List<T>, y no Map<K, List<T>>. Como resultado, intentan llamar a métodos de lista y obtienen un error de compilación. Recuerda: ¡la agrupación siempre es un Map!

Error n.º 2: NullPointerException al acceder a un grupo inexistente. Si intentas obtener la lista por una clave inexistente, obtendrás null. Usa getOrDefault(key, List.of()) o comprueba la presencia de la clave con containsKey.

Error n.º 3: Usar partitioningBy para tareas con varios grupos. partitioningBy — solo para dos grupos (true/false). Si hay más grupos, usa groupingBy.

Error n.º 4: Modificar colecciones dentro del stream. No intentes modificar colecciones de origen o el Map dentro del stream — esto lleva a errores inesperados. Realiza todo el procesamiento a través de Stream API y colectores.

Error n.º 5: Estructura no evidente en agrupaciones anidadas. Tras un groupingBy anidado el resultado es un Map dentro de otro Map. No olvides extraer los datos correctamente (por ejemplo, con getOrDefault), o aparecerá un ClassCastException.

Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION