CodeGym /Corsi /JAVA 25 SELF /Aggregazione avanzata: raggruppamenti annidati

Aggregazione avanzata: raggruppamenti annidati

JAVA 25 SELF
Livello 31 , Lezione 3
Disponibile

1. groupingBy annidato: sintassi e principio di funzionamento

Nella pratica è raro che basti raggruppare i dati per un solo criterio. Per esempio, se avete un elenco di dipendenti dell’azienda, spesso non basta sapere quanti sono in ogni reparto, ma quanti in ogni reparto per ciascuna posizione. Oppure, se avete un database di studenti – quanti studenti in ogni anno di corso per ciascuna specializzazione.

Le raggruppamenti annidati permettono di costruire queste “mappe dentro mappe” – la struttura “reparto → posizione → elenco di dipendenti”, oppure “anno di corso → specializzazione → elenco di studenti”.

Senza lo Stream API questi compiti richiederebbero più cicli annidati e la costruzione manuale di Map dentro Map. Con gli stream e i collector si fa in una o due righe.

Un raggruppamento annidato è quando, come secondo argomento del metodo Collectors.groupingBy, si passa un altro collector, ad esempio un altro groupingBy. Il risultato è una mappa in cui il valore per ogni chiave è un’altra mappa.

Schema generale

Map<Klyuch1, Map<Klyuch2, List<T>>> result = 
    stream.collect(Collectors.groupingBy(
        obekt -> klyuch1,
        Collectors.groupingBy(obekt -> klyuch2)
    ));

Esempio: dipendenti per reparto e posizione

Supponiamo di avere la classe:

class Employee {
    private String name;
    private String department;
    private String position;
    private int salary;
    // ... costruttori, getter, toString
}

Elenco dei dipendenti:

List<Employee> employees = List.of(
    new Employee("Ivan", "IT", "Sviluppatore", 120_000),
    new Employee("Mariya", "IT", "Tester", 90_000),
    new Employee("Pyotr", "HR", "Manager", 80_000),
    new Employee("Olga", "IT", "Sviluppatore", 130_000),
    new Employee("Svetlana", "HR", "Recruiter", 70_000)
);

Raggruppiamo per reparto e posizione:

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

Che cosa abbiamo ottenuto?
Una mappa in cui la chiave è il reparto e il valore è una mappa (chiave – posizione, valore – elenco di dipendenti).

Visualizzazione della struttura

IT:
  Sviluppatore: [Ivan, Olga]
  Tester: [Mariya]
HR:
  Manager: [Pyotr]
  Recruiter: [Svetlana]

2. Raggruppamento con aggregazione: combiniamo groupingBy e aggregatori

I raggruppamenti annidati non si limitano a raccogliere liste! Si può aggregare subito i dati all’interno di ciascun sottogruppo.

Esempio: stipendio massimo per reparto

Map<String, Optional<Employee>> maxSalaryByDept = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment,
        Collectors.maxBy(Comparator.comparingInt(Employee::getSalary))
    ));

Qui per ogni reparto otteniamo il dipendente con lo stipendio massimo (il risultato è incapsulato in Optional, perché un reparto potrebbe essere vuoto).

Raggruppamento annidato + aggregazione

Supponiamo di voler conoscere lo stipendio massimo per ciascuna posizione in ogni reparto:

Map<String, Map<String, Optional<Employee>>> maxSalaryByDeptAndPos = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment,
        Collectors.groupingBy(
            Employee::getPosition,
            Collectors.maxBy(Comparator.comparingInt(Employee::getSalary))
        )
    ));

Che cosa significa?

  • Per ogni reparto – una mappa delle posizioni.
  • Per ogni posizione – il dipendente con lo stipendio massimo (oppure un Optional vuoto, se non c’è nessuno).

3. Raggruppamento con trasformazione: mapping dentro groupingBy

A volte serve non solo raggruppare gli oggetti, ma ottenere solo determinati campi all’interno dei gruppi.

Esempio: nomi dei dipendenti per reparto

Map<String, List<String>> namesByDept = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment,
        Collectors.mapping(Employee::getName, Collectors.toList())
    ));

Risultato:

IT: [Ivan, Mariya, Olga]
HR: [Pyotr, Svetlana]

mapping annidato

Si può combinare mapping con un groupingBy annidato:

Map<String, Map<String, List<String>>> namesByDeptAndPos = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment,
        Collectors.groupingBy(
            Employee::getPosition,
            Collectors.mapping(Employee::getName, Collectors.toList())
        )
    ));

Risultato:

IT:
  Sviluppatore: [Ivan, Olga]
  Tester: [Mariya]
HR:
  Manager: [Pyotr]
  Recruiter: [Svetlana]

4. Raggruppamento + aggregazione di dati numerici

Spesso non basta raggruppare, ma bisogna calcolare somma, media o conteggio per gruppi.

Esempio: stipendio medio per reparto

Map<String, Double> avgSalaryByDept = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment,
        Collectors.averagingInt(Employee::getSalary)
    ));

Aggregazione annidata

Stipendio medio per posizione in ogni reparto:

Map<String, Map<String, Double>> avgSalaryByDeptAndPos = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment,
        Collectors.groupingBy(
            Employee::getPosition,
            Collectors.averagingInt(Employee::getSalary)
        )
    ));

5. partitioningBy + aggregazione

A volte è comodo dividere la collezione in due gruppi secondo un predicato booleano e, all’interno, aggregare.

Esempio: quanti dipendenti con stipendio superiore a 100_000 in ogni reparto

Map<String, Map<Boolean, Long>> countByDeptAndSalary = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment,
        Collectors.partitioningBy(
            e -> e.getSalary() > 100_000,
            Collectors.counting()
        )
    ));

Risultato:
Per ogni reparto – una mappa: true/false → numero di dipendenti.

6. Esercizi pratici: applichiamo i raggruppamenti annidati

Esercizio 1. Studenti per anno di corso e specializzazione

class Student {
    private String name;
    private int course;
    private String speciality;
    private double grade;
    // ... getter, costruttore
}

List<Student> students = ... // supponiamo che esista già

Map<Integer, Map<String, List<Student>>> byCourseAndSpec = students.stream()
    .collect(Collectors.groupingBy(
        Student::getCourse,
        Collectors.groupingBy(Student::getSpeciality)
    ));

Esercizio 2. Media dei voti per anno di corso

Map<Integer, Double> avgGradeByCourse = students.stream()
    .collect(Collectors.groupingBy(
        Student::getCourse,
        Collectors.averagingDouble(Student::getGrade)
    ));

Esercizio 3. Solo i nomi per gruppi

Map<Integer, Map<String, List<String>>> namesByCourseAndSpec = students.stream()
    .collect(Collectors.groupingBy(
        Student::getCourse,
        Collectors.groupingBy(
            Student::getSpeciality,
            Collectors.mapping(Student::getName, Collectors.toList())
        )
    ));

7. Dettagli utili

Come leggere ed estrarre dati dalle Map annidate

Lavorare con mappe annidate può essere insolito all’inizio. Ecco un esempio di base:

for (var deptEntry : grouped.entrySet()) {
    String dept = deptEntry.getKey();
    Map<String, List<Employee>> byPosition = deptEntry.getValue();
    System.out.println("Reparto: " + dept);
    for (var posEntry : byPosition.entrySet()) {
        String pos = posEntry.getKey();
        List<Employee> emps = posEntry.getValue();
        System.out.println("  Posizione: " + pos + " -> " + emps);
    }
}

Schema del raggruppamento annidato

Map<Reparto, Map<Posizione, List<Employee>>>
      │
      ├── "IT"
      │      ├── "Sviluppatore" → [Ivan, Olga]
      │      └── "Tester" → [Mariya]
      └── "HR"
             ├── "Manager"   → [Pyotr]
             └── "Recruiter"   → [Svetlana]

Tabella: cosa si ottiene dopo diverse combinazioni

Collector Risultato
groupingBy(Employee::getDepartment)
Map<String, List<Employee>>
groupingBy(Employee::getDepartment, averagingInt(...))
Map<String, Double>
groupingBy(Employee::getDepartment, groupingBy(...))
Map<String, Map<String, List<Employee>>>
groupingBy(..., mapping(..., toList()))
Map<..., List<...>>
groupingBy(..., groupingBy(..., mapping(..., toList())))
Map<..., Map<..., List<...>>>

8. Errori tipici con i raggruppamenti annidati

Errore n. 1: comprensione errata della struttura delle Map annidate.
Dopo raggruppamenti annidati è facile confondersi su cosa ci sia nel valore di ogni mappa. Guarda sempre la firma del risultato – l’IDE suggerirà il tipo. Se non sei sicuro, stampa il risultato a schermo con System.out.println(grouped) oppure usa il debugger.

Errore n. 2: NullPointerException durante l’estrazione dei dati.
Se la chiave non esiste (ad esempio, in un reparto non ci sono dipendenti di una certa posizione), Map.get(key) restituirà null. Verifica la presenza della chiave con containsKey oppure, a partire da Java 8, puoi usare Map.getOrDefault, e da Java 9 – Map.ofNullable e i metodi di Optional.

Errore n. 3: annidamenti troppo complessi.
Se il raggruppamento diventa troppo profondo (3–4 livelli di annidamento), forse conviene rivedere la struttura dei dati o suddividere il compito in fasi più piccole.

Errore n. 4: aggregazione al livello sbagliato.
Talvolta si mette per errore un collector di aggregazione (averagingInt, counting) non dentro al groupingBy desiderato, ma all’esterno – e si ottiene un risultato inatteso. Fai sempre molta attenzione alle parentesi!

Errore n. 5: tentativo di modificare gli elementi durante collect.
Non modificare le collezioni o gli oggetti di origine durante il raggruppamento – può portare a bug difficili da individuare.

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