Collectors.groupingBy et partitioningBy par l'exemple

1. Vue d’ensemble

Lorsque vous travaillez avec Stream, vous avez souvent besoin de regrouper les éléments selon un critère. Par exemple : commandes par ville, total d’argent par client, ou séparer les grandes commandes des petites. Collectors.groupingBy et Collectors.partitioningBy ont été créés pour exactement cela.

Cet article passe en revue chaque cas d’usage courant, avec des résultats spécifiques. Vous apprendrez comment combiner les collectors en aval, choisir le type de Map renvoyé, et éviter quelques erreurs courantes.

2. Données d’exemple

Tous les exemples de cet article utilisent un ensemble commun de commandes :

import java.util.List;

enum OrderStatus { PENDING, PAID, CANCELLED }

record Order(String id, String customer, String city, OrderStatus status, long amount) {}

class OrderFixtures {
    static List<Order> sample() {
        return List.of(
            new Order("O1", "An",   "Hà Nội",      OrderStatus.PAID,      500_000),
            new Order("O2", "Bình", "Đà Nẵng",     OrderStatus.PAID,      1_200_000),
            new Order("O3", "An",   "Hà Nội",      OrderStatus.CANCELLED, 300_000),
            new Order("O4", "Chi",  "Hồ Chí Minh", OrderStatus.PAID,      2_000_000),
            new Order("O5", "Bình", "Đà Nẵng",     OrderStatus.PENDING,   150_000),
            new Order("O6", "Chi",  "Hồ Chí Minh", OrderStatus.PAID,      800_000)
        );
    }
}

amount est calculé en VND et utilise le type long pour la concision. Dans un système réel, vous devriez utiliser BigDecimal pour la monnaie.

Les exemples ci-dessous supposent que vous avez déjà import static java.util.stream.Collectors.*; et List<Order> orders = OrderFixtures.sample();.

3. groupingBy basique

La forme la plus simple prend seulement une fonction de classification :

Map<String, List<Order>> byCity = orders.stream()
        .collect(groupingBy(Order::city));

byCity.forEach((city, list) ->
        System.out.println(city + " -> " + list.stream().map(Order::id).toList()));
Hà Nội -> [O1, O3]
Đà Nẵng -> [O2, O5]
Hồ Chí Minh -> [O4, O6]

Order::city détermine la clé. Chaque clé mappe vers une List contenant les commandes ayant cette valeur. En interne, cette version est équivalente à groupingBy(Order::city, HashMap::new, toList()).

Remarque : La Map renvoyée est une HashMap, donc l’ordre des clés n’est pas garanti. La sortie sur votre machine peut être différente. Si vous avez besoin d’un ordre, consultez la section 5.

4. Collectors en aval

Le deuxième paramètre de groupingBy est un autre Collector, appelé downstream (en aval). Il détermine comment les éléments au sein du même groupe sont regroupés. C’est ce qui rend groupingBy réellement puissant.

4.1. Comptage et sommation

Map<OrderStatus, Long> countByStatus = orders.stream()
        .collect(groupingBy(Order::status, counting()));

Map<String, Long> totalByCustomer = orders.stream()
        .collect(groupingBy(Order::customer, summingLong(Order::amount)));

System.out.println(countByStatus);
System.out.println(totalByCustomer);
{PAID=4, PENDING=1, CANCELLED=1}
{An=800000, Bình=1350000, Chi=2800000}

counting() retourne toujours un Long, pas un Integer. Vous devez vous en souvenir au moment de déclarer le type de variable. Outre summingLong, il y a aussi summingInt, summingDouble, averagingLong et summarizingLong. Ce dernier collector retourne LongSummaryStatistics, contenant à la fois count, sum, min, max, average.

4.2. Transformation d’éléments avec mapping

Si vous n’avez besoin que d’un attribut au lieu de l’objet entier, utilisez mapping :

Map<String, Set<String>> customersByCity = orders.stream()
        .collect(groupingBy(Order::city, mapping(Order::customer, toSet())));

System.out.println(customersByCity);
{Hà Nội=[An], Đà Nẵng=[Bình], Hồ Chí Minh=[Chi]}

mapping(Order::customer, toSet()) récupère le nom du client de chaque commande, puis les rassemble dans un Set pour éviter les doublons. An a deux commandes à Hà Nội mais n’apparaît qu’une fois.

4.3. Récupérer l’élément maximum dans un groupe

maxBy retourne un Optional<Order>, car en théorie un groupe pourrait être vide. Dans groupingBy, un groupe n’existe que s’il y a au moins un élément. Vous pouvez donc dérouler l’Optional avec collectingAndThen :

Map<String, Order> biggestByCustomer = orders.stream()
        .collect(groupingBy(
                Order::customer,
                collectingAndThen(maxBy(Comparator.comparingLong(Order::amount)), Optional::get)
        ));

biggestByCustomer.forEach((customer, order) ->
        System.out.println(customer + " -> " + order.id() + " (" + order.amount() + ")"));
An -> O1 (500000)
Bình -> O2 (1200000)
Chi -> O4 (2000000)

collectingAndThen exécute le collector à l’intérieur d’abord, puis applique la fonction de finition (Optional::get) au résultat.

Il y a une autre façon couramment utilisée : toMap(Order::customer, Function.identity(), BinaryOperator.maxBy(...)). Cette approche produit un résultat similaire et n’a pas besoin de passer par Optional.

4.4. Filtrer au sein d’un groupe avec filtering

Collectors.filtering existe depuis Java 9. C’est différent d’appeler .filter() avant le groupage :

Map<String, Long> cancelledPerCustomer = orders.stream()
        .collect(groupingBy(Order::customer,
                filtering(o -> o.status() == OrderStatus.CANCELLED, counting())));

Map<String, Long> filteredFirst = orders.stream()
        .filter(o -> o.status() == OrderStatus.CANCELLED)
        .collect(groupingBy(Order::customer, counting()));

System.out.println(cancelledPerCustomer);
System.out.println(filteredFirst);
{An=1, Bình=0, Chi=0}
{An=1}

Avec filtering, chaque client est toujours présent dans le résultat, même si le compte est 0. Quand vous .filter() avant, Bình et Chi disparaissent complètement. Choisissez selon vos besoins : si un rapport a besoin de la liste complète, utilisez alors filtering.

De même, flatMapping (Java 9) aide à aplatir une collection au sein de chaque groupe.

5. Choisir le type de Map renvoyé

La version à trois paramètres vous permet de passer un Supplier créant une Map :

Map<String, Long> sortedTotals = orders.stream()
        .collect(groupingBy(Order::customer, TreeMap::new, summingLong(Order::amount)));

Map<OrderStatus, Long> statusCounts = orders.stream()
        .collect(groupingBy(Order::status,
                () -> new EnumMap<>(OrderStatus.class), counting()));

System.out.println(sortedTotals);
System.out.println(statusCounts);
{An=800000, Bình=1350000, Chi=2800000}
{PENDING=1, PAID=4, CANCELLED=1}

TreeMap trie les clés selon l’ordre naturel. EnumMap conserve les clés dans l’ordre de déclaration de l’enum et est plus rapide que HashMap quand les clés sont des enums. Si vous voulez préserver l’ordre de première apparition dans le stream, utilisez LinkedHashMap::new.

Remarque : TreeMap avec String trie selon le code Unicode, pas selon les règles du vietnamien. Par exemple “Đà Nẵng” viendra après “Hồ Chí Minh”. Quand vous avez besoin d’un tri correct en vietnamien, transmettez () -> new TreeMap<>(Collator.getInstance(Locale.of("vi"))).

6. Groupement imbriqué

Comme le downstream est n’importe quel collector, vous pouvez placer un autre groupingBy à l’intérieur :

Map<String, Map<OrderStatus, Long>> cityThenStatus = orders.stream()
        .collect(groupingBy(Order::city, TreeMap::new,
                groupingBy(Order::status, counting())));

System.out.println(cityThenStatus);
{Hà Nội={PAID=1, CANCELLED=1}, Hồ Chí Minh={PAID=2}, Đà Nẵng={PAID=1, PENDING=1}}

Le résultat est une Map à deux niveaux. C’est très pratique pour les rapports de type pivot. Cependant, au-delà de deux niveaux, le type de données devient difficile à lire. Dans ce cas, envisagez d’utiliser une clé composée, par exemple record CityStatus(String city, OrderStatus status), puis groupingBy(o -> new CityStatus(o.city(), o.status())).

7. partitioningBy

partitioningBy est un cas spécial du groupage. Le classifier est un Predicate, et les clés sont toujours des Boolean :

Map<Boolean, List<String>> bigOrders = orders.stream()
        .collect(partitioningBy(o -> o.amount() >= 1_000_000,
                mapping(Order::id, toList())));

Map<Boolean, Long> bigOrderCount = orders.stream()
        .collect(partitioningBy(o -> o.amount() >= 1_000_000, counting()));

System.out.println(bigOrders);
System.out.println(bigOrderCount);
System.out.println("Commandes grandes : " + bigOrders.get(true));
{false=[O1, O3, O5, O6], true=[O2, O4]}
{false=4, true=2}
Commandes grandes : [O2, O4]

partitioningBy accepte aussi les collectors en aval exactement comme groupingBy. Mais il n’a pas de version acceptant un Supplier pour la Map, car la Map renvoyée est une implémentation interne qui n’a que deux clés.

7.1. Différence importante : les clés existent toujours

C’est ce qui fait que beaucoup de gens choisissent mal entre les deux collectors :

List<Order> smallOnly = orders.stream().filter(o -> o.amount() < 1_000_000).toList();

Map<Boolean, List<Order>> partitioned = smallOnly.stream()
        .collect(partitioningBy(o -> o.amount() >= 1_000_000));

Map<Boolean, List<Order>> grouped = smallOnly.stream()
        .collect(groupingBy(o -> o.amount() >= 1_000_000));

System.out.println(partitioned.get(true));
System.out.println(grouped.get(true));
[]
null

partitioningBy a toujours à la fois true et false, même si un côté est vide. groupingBy crée une clé uniquement s’il y a au moins un élément. Appeler grouped.get(true).size() ici lèverait une NullPointerException.

Par conséquent, quand le critère est vrai/faux, utilisez partitioningBy. L’intention du code est plus claire, et vous n’avez pas à vous soucier des clés manquantes.

8. Écrire des tests pour la logique de groupage

Ajoutez JUnit 5 et AssertJ à votre pom.xml. Si votre projet utilise Spring Boot 3.x, spring-boot-starter-test contient déjà les deux :

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

Mettez la logique de rapport dans un service séparé pour qu’il soit facile à tester :

class OrderReportService {

    Map<String, Long> paidTotalByCustomer(List<Order> orders) {
        return orders.stream()
                .collect(groupingBy(Order::customer, TreeMap::new,
                        filtering(o -> o.status() == OrderStatus.PAID,
                                summingLong(Order::amount))));
    }

    Map<Boolean, Long> countBigOrders(List<Order> orders, long threshold) {
        return orders.stream()
                .collect(partitioningBy(o -> o.amount() >= threshold, counting()));
    }
}
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class OrderReportServiceTest {

    private final OrderReportService service = new OrderReportService();

    @Test
    void shouldSumOnlyPaidOrdersPerCustomer() {
        var result = service.paidTotalByCustomer(OrderFixtures.sample());

        assertThat(result)
                .containsExactly(
                        entry("An", 500_000L),
                        entry("Bình", 1_200_000L),
                        entry("Chi", 2_800_000L));
    }

    @Test
    void shouldAlwaysContainBothPartitions() {
        var result = service.countBigOrders(OrderFixtures.sample(), 10_000_000);

        assertThat(result)
                .containsEntry(true, 0L)
                .containsEntry(false, 6L);
    }
}

Le premier test utilise containsExactly car le résultat est une TreeMap, l’ordre est donc fixe. An n’est additionné que de 500 000, car la commande O3 a été annulée. Bình n’est additionné que de 1 200 000, car O5 est toujours en attente de paiement.

Le deuxième test confirme que même s’il n’y a aucune commande au-dessus du seuil, la clé true existe toujours avec la valeur 0.

9. Erreurs courantes

Clé null. groupingBy n’accepte pas un classifier renvoyant null :

var orders = List.of(new Order("O9", "Dung", null, OrderStatus.PAID, 100_000));
orders.stream().collect(groupingBy(Order::city));
java.lang.NullPointerException: element cannot be mapped to a null key

Vous avez deux façons de gérer cela. La première consiste à remplacer null par une valeur par défaut : groupingBy(o -> Objects.requireNonNullElse(o.city(), "Inconnue")). La deuxième consiste à filtrer les commandes manquant une ville avant le groupage.

Confondre le type renvoyé. counting() retourne un Long. averagingLong retourne un Double. maxBy retourne un Optional. Si vous déclarez le mauvais type, vous obtiendrez une erreur de compilation difficile à lire. En cas de doute, utilisez var ou laissez l’IDE déduire le type.

Utiliser groupingBy quand chaque clé n’a qu’une valeur. Si l’id est unique, groupingBy(Order::id) crée une Map<String, List<Order>> avec une liste contenant un seul élément. Dans ce cas, utilisez toMap(Order::id, Function.identity()).

Modifier la Map renvoyée. Javadoc ne garantit pas le type, la mutabilité ou la thread-safety de la Map renvoyée par la version par défaut. Si vous avez l’intention de modifier la Map par la suite, transmettez un Supplier explicite, par exemple HashMap::new.

Remarque : Avec un stream parallèle, groupingBy doit fusionner plusieurs sous-maps, ce qui pourrait être lent. groupingByConcurrent écrit directement dans une ConcurrentHashMap et est plus rapide. En revanche, elle ne préserve pas l’ordre des éléments au sein de chaque groupe. Avec un stream séquentiel, il suffit d’utiliser groupingBy.

10. Conclusion

groupingBy groupe les éléments selon une clé quelconque. partitioningBy divise en exactement deux côtés selon un Predicate et retourne toujours à la fois true et false. La véritable puissance réside dans le collector en aval : counting, summingLong, mapping, filtering, collectingAndThen ou un groupingBy imbriqué à l’intérieur.

Vous devriez vous souvenir de trois points lorsque vous utilisez ces deux collectors :

  • Choisir le type de Map approprié (TreeMap, EnumMap, LinkedHashMap) si vous avez besoin d’une commande.
  • Gérer les clés null avant le groupage.
  • Privilégier partitioningBy pour les critères vrai/faux.

Une fois que vous maîtrisez ces points, la plupart de votre logique de rapport ne sera que quelques lignes de Stream faciles à lire et à tester.

javastreamcollectorsgroupingByjava 21