1. Introduction
Dans la vie réelle, on doit presque toujours regrouper des données. Par exemple, répartir des étudiants par année, classer des produits par catégories, séparer les majeurs des mineurs, etc.
Sans le Stream API, ces tâches se faisaient à la main : itération sur la collection, vérification d’un critère, ajout dans la bonne liste d’une Map. Voici un code typique « à l’ancienne » :
Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee employee : employees) {
String dept = employee.getDepartment();
byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(employee);
}
Ça fonctionne, mais c’est comme si vous triiez des pâtes à la main dans des bocaux. Et s’il faut regrouper selon plusieurs critères ? Ou encore calculer des sommes par groupe ? Le code enfle et devient illisible.
Le Stream API et des collecteurs dédiés (groupingBy, partitioningBy) permettent de le faire en une ou deux lignes — tout en ayant l’air d’un véritable magicien Java.
2. Collecteur groupingBy : regroupement par critère
Idée principale
groupingBy est un collecteur qui transforme un flux d’éléments en une Map où la clé est le résultat d’une fonction de classement, et la valeur est la liste des éléments correspondant à ce critère.
Signature :
Collectors.groupingBy(Function<T, K>)
- T — type de l’élément du flux,
- K — type de la clé (groupe) renvoyée par la fonction.
Exemple simple : regrouper des employés par département
Supposons que nous ayons une classe :
public class Employee {
private final String name;
private final String department;
// ... constructeur et getters
public Employee(String name, String department) {
this.name = name;
this.department = department;
}
public String getName() { return name; }
public String getDepartment() { return department; }
}
Et une collection d’employés :
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")
);
Regroupons par département :
Map<String, List<Employee>> byDepartment = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment));
Qu’obtient-on ?
- Clé : nom du département (String).
- Valeur : liste des employés de ce département (List<Employee>).
Afficher le résultat :
byDepartment.forEach((dept, emps) -> {
System.out.println(dept + ": " +
emps.stream().map(Employee::getName).toList());
});
Sortie :
IT: [Alisa, Klara]
HR: [Bob, Eva]
Finance: [Denis]
Comment cela fonctionne « sous le capot » ?
D’abord, pour chaque élément du flux, on calcule une clé (par exemple getDepartment()). Si cette clé existe déjà dans la Map, l’élément est ajouté à la liste correspondante. Sinon, une nouvelle liste est créée.
Analogie
Imaginez que vous triez des lettres par dossiers : pour chaque lettre, vous regardez s’il existe déjà un dossier du bon nom ; sinon, vous en créez un nouveau et vous y mettez la lettre.
3. Collecteur partitioningBy : séparation en deux groupes
Parfois, il ne s’agit pas tant de « regrouper par valeur » que de scinder une collection en deux parties selon un critère logique (true/false). Par exemple, des employés au-dessus ou en dessous d’un seuil de salaire, des étudiants — « admis/non admis ».
Pour cela, il existe un collecteur particulier — partitioningBy.
Signature :
Collectors.partitioningBy(Predicate<T>)
Predicate — une fonction qui renvoie une valeur booléenne.
Exemple : séparer des employés par niveau de salaire
Supposons que nous ayons :
public class Employee {
private final String name;
private final int salary;
// ... constructeur et getters
public Employee(String name, int salary) {
this.name = name;
this.salary = salary;
}
public String getName() { return name; }
public int getSalary() { return salary; }
}
Et une liste :
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)
);
Séparons en « aisés » et « modestes » :
Map<Boolean, List<Employee>> partitioned = employees.stream()
.collect(Collectors.partitioningBy(e -> e.getSalary() > 100_000));
- true — employés dont le salaire est supérieur à 100_000.
- false — les autres.
Affichage :
System.out.println("Aisés: " +
partitioned.get(true).stream().map(Employee::getName).toList());
System.out.println("Modestes: " +
partitioned.get(false).stream().map(Employee::getName).toList());
Résultat :
Aisés: [Alisa, Klara]
Modestes: [Bob, Denis]
Quand utiliser partitioningBy et quand groupingBy ?
- Si vous avez plus de deux groupes — utilisez groupingBy.
- Si vous n’avez que deux groupes selon un prédicat booléen — utilisez partitioningBy : c’est plus rapide et plus clair.
4. Regroupements imbriqués : regrouper selon plusieurs critères
Il arrive qu’on veuille regrouper non seulement selon un critère, mais aussi « imbriquer » un regroupement dans un autre. Par exemple, regrouper d’abord par département, puis à l’intérieur par poste.
Exemple :
Supposons que notre classe Employee possède aussi un champ position :
public class Employee {
private final String name;
private final String department;
private final String position;
// ... constructeur et getters
}
Regroupement imbriqué :
Map<String, Map<String, List<Employee>>> byDeptAndPosition = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment,
Collectors.groupingBy(Employee::getPosition)));
- Clé externe — département.
- Clé interne — poste.
- Valeur — liste d’employés.
Comment récupérer les employés du département IT ayant le poste "Developer" ?
List<Employee> itDevs = byDeptAndPosition
.getOrDefault("IT", Map.of())
.getOrDefault("Developer", List.of());
Schéma visuel
Map<Department, Map<Position, List<Employee>>>
└─ "IT"
├─ "Developer" -> [Alisa, Klara]
└─ "QA" -> [Boris]
└─ "HR"
└─ "Recruiter" -> [Denis]
5. Exemples pratiques de regroupement
Exemple 1 : regrouper des chaînes par longueur
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));
Sortie :
3: [cat, dog, bee, ant]
8: [elephant, dolphin]
Exemple 2 : regrouper des nombres par 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]}
Exemple 3 : utilisation de partitioningBy pour des chaînes
Séparons les chaînes entre celles qui commencent par "A" et les autres :
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. Points utiles
Comment utiliser le résultat d’un regroupement
Souvent, après un regroupement, on veut non seulement obtenir une Map, mais encore la traiter :
- Parcourir tous les groupes et afficher des informations.
- Trouver le groupe ayant le plus grand nombre d’éléments.
- Pour chaque groupe, calculer par exemple une somme ou une moyenne — nous verrons cela plus en détail dans la prochaine leçon.
Exemple : afficher le nombre d’employés par département
byDepartment.forEach((dept, emps) ->
System.out.println(dept + ": " + emps.size() + " employés"));
Comparaison avec une implémentation manuelle
Pour bien fixer les idées : voici à quoi ressemblerait le regroupement par département « à l’ancienne » :
Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee e : employees) {
String dept = e.getDepartment();
byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(e);
}
Et avec le Stream API :
Map<String, List<Employee>> byDepartment = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment));
Conclusion : moins de code, moins d’erreurs, meilleure lisibilité.
Astuces
Si vous voulez regrouper non pas dans une List, mais par exemple dans un Set — utilisez en second paramètre Collectors.toSet() :
Collectors.groupingBy(Employee::getDepartment, Collectors.toSet())
Vous pouvez agréger directement : par exemple, obtenir le nombre d’employés par département :
Collectors.groupingBy(Employee::getDepartment, Collectors.counting())
Mais nous verrons cela dans la prochaine leçon !
Après partitioningBy, il y aura toujours deux clés : true et false. Même si l’un des groupes est vide.
Après des regroupements imbriqués, la structure devient un « arbre » : une Map à l’intérieur d’une autre Map, etc.
6. Erreurs typiques lors du regroupement et de la partition
Erreur n° 1 : type de résultat incorrect. Les débutants s’attendent souvent à ce que le résultat de groupingBy soit simplement une List<T>, et non une Map<K, List<T>>. Ils tentent alors d’appeler des méthodes de liste et obtiennent une erreur de compilation. Rappelez-vous : un regroupement renvoie toujours une Map !
Erreur n° 2 : NullPointerException lors d’un accès à un groupe inexistant. Si vous essayez d’obtenir une liste avec une clé inexistante — vous obtiendrez null. Utilisez getOrDefault(key, List.of()) ou vérifiez la présence de la clé avec containsKey.
Erreur n° 3 : utiliser partitioningBy pour des tâches à plusieurs groupes. partitioningBy — uniquement pour deux groupes (true/false). S’il y a plus de groupes — utilisez groupingBy.
Erreur n° 4 : modification de collections à l’intérieur d’un stream. N’essayez pas de modifier les collections sources ou la Map à l’intérieur d’un stream — cela conduira à des erreurs inattendues. Faites tout le traitement via le Stream API et les collecteurs.
Erreur n° 5 : structure peu évidente des regroupements imbriqués. Après un groupingBy imbriqué, le résultat est une Map à l’intérieur d’une Map. N’oubliez pas d’extraire correctement les données (par exemple via getOrDefault), sinon vous obtiendrez une ClassCastException.
GO TO FULL VERSION