1. Introduzione
Nella vita reale quasi sempre bisogna raggruppare i dati. Per esempio, dividere gli studenti per corso, suddividere i prodotti per categoria, separare i maggiorenni dai minori e così via.
Senza Stream API questi compiti si risolvono a mano: iterazione della collezione, verifica del criterio, aggiunta all’elenco giusto nella Map. Ecco un tipico codice «alla vecchia maniera»:
Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee employee : employees) {
String dept = employee.getDepartment();
byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(employee);
}
Funziona, ma sembra di smistare a mano la pasta nei barattoli. E se serve raggruppare per più criteri? O anche calcolare somme per gruppo? Il codice cresce e diventa illeggibile.
Stream API e i collector dedicati (groupingBy, partitioningBy) permettono di farlo in una o due righe — e di sembrare un vero mago di Java.
2. Collector groupingBy: raggruppamento per criterio
Idea principale
groupingBy — è un collector che trasforma uno stream di elementi in una Map, in cui la chiave è il risultato della funzione-criterio e il valore è la lista di elementi che soddisfano quel criterio.
Firma:
Collectors.groupingBy(Function<T, K>)
- T — tipo dell’elemento nello stream,
- K — tipo della chiave (gruppo) restituita dalla funzione.
Esempio semplice: raggruppare i dipendenti per reparto
Supponiamo di avere una classe:
public class Employee {
private final String name;
private final String department;
// ... costruttore e getter
public Employee(String name, String department) {
this.name = name;
this.department = department;
}
public String getName() { return name; }
public String getDepartment() { return department; }
}
E una collezione di dipendenti:
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")
);
Raggruppiamo per reparto:
Map<String, List<Employee>> byDepartment = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment));
Cosa abbiamo ottenuto?
- Chiave: nome del reparto (String).
- Valore: elenco dei dipendenti di quel reparto (List<Employee>).
Stampiamo il risultato:
byDepartment.forEach((dept, emps) -> {
System.out.println(dept + ": " +
emps.stream().map(Employee::getName).toList());
});
Output:
IT: [Alisa, Klara]
HR: [Bob, Eva]
Finance: [Denis]
Come funziona sotto il cofano?
Prima, per ogni elemento dello stream si calcola la chiave (ad esempio, getDepartment()). Se tale chiave è già presente nella Map, l’elemento viene aggiunto alla lista corrispondente. Se la chiave non c’è — si crea una nuova lista.
Analogia
Immaginate di smistare le lettere per cartelle: per ogni lettera verificate se esiste già una cartella con il nome giusto; se non c’è — ne create una nuova e ci mettete dentro la lettera.
3. Collector partitioningBy: suddivisione in due gruppi
A volte non serve tanto «raggruppare per valore», quanto dividere semplicemente la collezione in due parti in base a un criterio logico (true/false). Per esempio, dipendenti con stipendio superiore o inferiore a una certa soglia, studenti — in «promosso/non promosso».
Per questo esiste un collector specifico — partitioningBy.
Firma:
Collectors.partitioningBy(Predicate<T>)
Predicate — funzione che restituisce un valore booleano.
Esempio: suddivisione dei dipendenti per livello di stipendio
Supponiamo di avere:
public class Employee {
private final String name;
private final int salary;
// ... costruttore e getter
public Employee(String name, int salary) {
this.name = name;
this.salary = salary;
}
public String getName() { return name; }
public int getSalary() { return salary; }
}
E un elenco:
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)
);
Dividiamo in «benestanti» e «modesti»:
Map<Boolean, List<Employee>> partitioned = employees.stream()
.collect(Collectors.partitioningBy(e -> e.getSalary() > 100_000));
- true — dipendenti con stipendio superiore a 100_000.
- false — gli altri.
Stampiamo:
System.out.println("Benestanti: " +
partitioned.get(true).stream().map(Employee::getName).toList());
System.out.println("Modesti: " +
partitioned.get(false).stream().map(Employee::getName).toList());
Risultato:
Benestanti: [Alisa, Klara]
Modesti: [Bob, Denis]
Quando usare partitioningBy e quando groupingBy?
- Se i gruppi sono più di due — usa groupingBy.
- Se ci sono solo due gruppi basati su un booleano — usa partitioningBy: è più veloce e più chiaro.
4. Raggruppamenti annidati: raggruppare per più criteri
A volte si vuole raggruppare non solo per un criterio, ma anche «annidare» un raggruppamento dentro un altro. Per esempio, raggruppare i dipendenti prima per reparto e, all’interno del reparto, per posizione.
Esempio:
Supponiamo che nella nostra classe Employee ci sia anche il campo position:
public class Employee {
private final String name;
private final String department;
private final String position;
// ... costruttore e getter
}
Raggruppamento annidato:
Map<String, Map<String, List<Employee>>> byDeptAndPosition = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment,
Collectors.groupingBy(Employee::getPosition)));
- Chiave esterna — reparto.
- Chiave interna — posizione.
- Valore — elenco dei dipendenti.
Come accedere ai dipendenti del reparto IT con posizione "Developer"?
List<Employee> itDevs = byDeptAndPosition
.getOrDefault("IT", Map.of())
.getOrDefault("Developer", List.of());
Schema visivo
Map<Department, Map<Position, List<Employee>>>
└─ "IT"
├─ "Developer" -> [Alisa, Klara]
└─ "QA" -> [Boris]
└─ "HR"
└─ "Recruiter" -> [Denis]
5. Esempi pratici di raggruppamento
Esempio 1: Raggruppare stringhe per lunghezza
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));
Output:
3: [cat, dog, bee, ant]
8: [elephant, dolphin]
Esempio 2: Raggruppare numeri per parità
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]}
Esempio 3: Usare partitioningBy per le stringhe
Dividiamo le stringhe tra quelle che iniziano con "A" e tutte le altre:
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. Dettagli utili
Come usare il risultato del raggruppamento
Spesso dopo il raggruppamento non si vuole solo ottenere una Map, ma anche elaborarla:
- Scorrere tutti i gruppi e stampare informazioni.
- Trovare il gruppo con il numero massimo di elementi.
- Per ogni gruppo calcolare, ad esempio, somma o media — ne parleremo nella prossima lezione.
Esempio: stampare il numero di dipendenti in ogni reparto
byDepartment.forEach((dept, emps) ->
System.out.println(dept + ": " + emps.size() + " dipendenti"));
Confronto con un'implementazione manuale
Per fissare le idee: ecco come apparirebbe il raggruppamento per reparto «alla vecchia maniera»:
Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee e : employees) {
String dept = e.getDepartment();
byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(e);
}
E come con Stream API:
Map<String, List<Employee>> byDepartment = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment));
Conclusione: meno codice, meno errori, maggiore leggibilità.
Trucchi
Se serve raggruppare non in una List ma, ad esempio, in un Set — usa come secondo parametro Collectors.toSet():
Collectors.groupingBy(Employee::getDepartment, Collectors.toSet())
Si può aggregare subito: ad esempio ottenere il numero di dipendenti in ogni reparto:
Collectors.groupingBy(Employee::getDepartment, Collectors.counting())
Ma di questo — nella prossima lezione!
Dopo partitioningBy ci saranno sempre due chiavi: true e false. Anche se uno dei gruppi è vuoto.
Dopo raggruppamenti annidati la struttura diventa un «albero»: Map dentro Map e così via.
6. Errori tipici nel raggruppamento e nel partitioning
Errore n. 1: tipo di risultato errato. I principianti spesso si aspettano che il risultato di groupingBy sia semplicemente una List<T>, e non una Map<K, List<T>>. Di conseguenza provano a chiamare metodi di lista e ottengono un errore di compilazione. Ricordate: il raggruppamento restituisce sempre una Map!
Errore n. 2: NullPointerException quando si accede a un gruppo inesistente. Se provate a ottenere la lista per una chiave inesistente — otterrete null. Usate getOrDefault(key, List.of()) oppure verificate la presenza della chiave con containsKey.
Errore n. 3: usare partitioningBy per problemi con più gruppi. partitioningBy — solo per due gruppi (true/false). Se i gruppi sono di più — usate groupingBy.
Errore n. 4: modificare le collezioni all’interno dello stream. Non cercate di modificare le collezioni di origine o la Map all’interno dello stream — porterà a errori imprevisti. Eseguite tutta l’elaborazione tramite Stream API e i collector.
Errore n. 5: struttura non ovvia dei raggruppamenti annidati. Dopo un groupingBy annidato il risultato è una Map dentro una Map. Non dimenticate di estrarre correttamente i dati (ad esempio con getOrDefault), altrimenti otterrete una ClassCastException.
GO TO FULL VERSION