Stream API: map, filter, reduce et collect par l'exemple

1. Vue d’ensemble

L’API Stream fait son apparition à partir de Java 8 et devient un outil indispensable quand on manipule les collections. Cet article vous aide à maîtriser les 4 opérations les plus importantes : map, filter, reduce et collect grâce à des exemples concrets avec Order et Customer. À la fin, vous saurez quand utiliser quelle opération, comment les combiner, et vous éviterez quelques erreurs courantes en travaillant avec les streams.

2. Préparation des données d’exemple

Avant de rentrer dans le détail de chaque opération, vous avez besoin d’un jeu de données pour manipuler tout au long de l’article.

public record Order(String id, String product, int quantity, double price, String status) {
    public double total() {
        return quantity * price;
    }
}
import java.util.List;

public class OrderData {
    public static List<Order> sample() {
        return List.of(
            new Order("O1", "Laptop", 1, 15_000_000, "COMPLETED"),
            new Order("O2", "Mouse", 3, 150_000, "COMPLETED"),
            new Order("O3", "Keyboard", 2, 400_000, "CANCELLED"),
            new Order("O4", "Monitor", 1, 3_500_000, "COMPLETED"),
            new Order("O5", "Laptop", 1, 14_000_000, "PENDING")
        );
    }
}

Order est un record – compact, immuable, parfait pour démontrer stream. La méthode total() calcule le montant total de chaque commande, qui sera réutilisée plusieurs fois ci-dessous.

3. filter : filtrer les éléments selon une condition

filter accepte un Predicate<T>, conserve seulement les éléments qui satisfont la condition.

List<Order> completedOrders = OrderData.sample().stream()
    .filter(order -> order.status().equals("COMPLETED"))
    .toList();

completedOrders.forEach(o -> System.out.println(o.id() + " - " + o.product()));
  • stream() convertit List<Order> en un Stream<Order>.
  • filter conserve seulement les commandes ayant le status COMPLETED.
  • toList() (Java 16+) rassemble le résultat en une List immuable, à la place de collect(Collectors.toList()).
O1 - Laptop
O2 - Mouse
O4 - Monitor

Attention : filter ne modifie pas les données d’origine. Stream est lazy – si vous n’avez pas d’opération terminal (comme toList(), forEach()), les filter/map précédents ne seront pas exécutés.

3.1. Combiner plusieurs conditions filter

List<Order> expensiveCompleted = OrderData.sample().stream()
    .filter(o -> o.status().equals("COMPLETED"))
    .filter(o -> o.total() > 1_000_000)
    .toList();

Deux filter en succession équivalent à une condition AND. Vous pouvez écrire ça en un seul filter avec &&, mais les séparer aide à la lisibilité quand les conditions sont complexes.

4. map : transformer les éléments

map accepte une Function<T, R>, transforme chaque élément de type T en un élément de type R.

List<String> productNames = OrderData.sample().stream()
    .map(Order::product)
    .distinct()
    .toList();

System.out.println(productNames);
  • Order::product est une method reference, équivalent à order -> order.product().
  • distinct() élimine les éléments dupliqués (basé sur equals()).
[Laptop, Mouse, Keyboard, Monitor]

4.1. map retourne un type complètement différent

La force de map c’est de transformer les types de données, pas juste extraire un champ.

List<Double> totals = OrderData.sample().stream()
    .map(Order::total)
    .toList();

System.out.println(totals);
[15000000.0, 450000.0, 800000.0, 3500000.0, 14000000.0]

Attention : Ne pas confondre map avec forEach. map retourne un nouveau stream, sans side effect ; forEach est utilisé pour exécuter une action (afficher, sauvegarder en DB…) et ne retourne rien.

5. reduce : fusionner plusieurs éléments en une seule valeur

reduce agrège tout le stream en une seule valeur, basée sur une identity (valeur initiale) et un BinaryOperator.

double totalRevenue = OrderData.sample().stream()
    .filter(o -> o.status().equals("COMPLETED"))
    .map(Order::total)
    .reduce(0.0, Double::sum);

System.out.println(totalRevenue);
  • 0.0 est l’identity – la valeur initiale quand le stream est vide.
  • Double::sum est l’accumulateur, qui accumule chaque valeur dans le résultat.
1.8950000E7

5.1. reduce sans identity

Si vous ne voulez pas fournir une identity, reduce retourne Optional<T> parce que le stream peut être vide.

Optional<Order> mostExpensive = OrderData.sample().stream()
    .reduce((o1, o2) -> o1.total() > o2.total() ? o1 : o2);

mostExpensive.ifPresent(o -> System.out.println(o.id() + ": " + o.total()));
O1: 1.5E7

Attention : Pour trouver max/min, Java fournit déjà Stream.max(Comparator) et Stream.min(Comparator) – plus court et plus clair que reduce. Utilisez reduce seulement quand la logique d’agrégation est plus complexe qu’une simple comparaison.

6. collect : rassembler les résultats dans une structure de données

collect est plus flexible que toList(), permet de rassembler les résultats dans une Map, Set, ou de faire du groupBy/partition.

6.1. Rassembler dans une Map

import java.util.Map;
import java.util.stream.Collectors;

Map<String, Double> totalByOrderId = OrderData.sample().stream()
    .collect(Collectors.toMap(Order::id, Order::total));

System.out.println(totalByOrderId);
  • Collectors.toMap accepte deux fonctions : l’extracteur de clé et l’extracteur de valeur.
  • S’il y a des clés en doublon, toMap lancera une IllegalStateException – vous devez fournir une fonction de fusion si les données risquent d’avoir des clés en doublon.
{O1=1.5E7, O2=450000.0, O3=800000.0, O4=3500000.0, O5=1.4E7}

6.2. groupingBy – grouper selon une condition

Map<String, List<Order>> ordersByStatus = OrderData.sample().stream()
    .collect(Collectors.groupingBy(Order::status));

ordersByStatus.forEach((status, orders) ->
    System.out.println(status + ": " + orders.size() + " order(s)"));
COMPLETED: 3 order(s)
CANCELLED: 1 order(s)
PENDING: 1 order(s)

6.3. groupingBy combiné avec un downstream collector

Vous pouvez calculer le revenu total pour chaque groupe de status, sans avoir besoin de grouper puis boucler à nouveau.

Map<String, Double> revenueByStatus = OrderData.sample().stream()
    .collect(Collectors.groupingBy(
        Order::status,
        Collectors.summingDouble(Order::total)
    ));

System.out.println(revenueByStatus);
{COMPLETED=18950000.0, CANCELLED=800000.0, PENDING=14000000.0}

groupingBy avec un downstream collector (summingDouble, counting, averagingDouble…) vous aide à éviter de devoir écrire reduce manuellement pour chaque groupe.

7. Combiner les quatre opérations dans un pipeline

Voici un exemple concret : récupérer la liste des noms de produits des commandes finalisées, d’une valeur > 1 million, triées en décroissant selon le montant total.

List<String> topProducts = OrderData.sample().stream()
    .filter(o -> o.status().equals("COMPLETED"))
    .filter(o -> o.total() > 1_000_000)
    .sorted(Comparator.comparingDouble(Order::total).reversed())
    .map(Order::product)
    .toList();

System.out.println(topProducts);
[Laptop, Monitor]

L’ordre des opérations ici est important : filter d’abord pour réduire le nombre d’éléments à traiter, sorted après filtrage pour éviter un tri superflu, map à la fin pour extraire seulement le champ dont on a besoin.

8. Écrire des tests pour le pipeline

Écrivez toujours des tests pour la logique métier qui se trouve dans le pipeline stream, ne la laissez pas « cachée » dans une longue ligne de code que personne ne teste.

<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;

class OrderAnalyticsTest {

    @Test
    void shouldCalculateTotalRevenueForCompletedOrders() {
        double totalRevenue = OrderData.sample().stream()
            .filter(o -> o.status().equals("COMPLETED"))
            .map(Order::total)
            .reduce(0.0, Double::sum);

        assertThat(totalRevenue).isEqualTo(18_950_000.0);
    }

    @Test
    void shouldGroupOrdersByStatus() {
        var grouped = OrderData.sample().stream()
            .collect(java.util.stream.Collectors.groupingBy(Order::status));

        assertThat(grouped)
            .containsKey("COMPLETED")
            .extractingByKey("COMPLETED", org.assertj.core.api.InstanceOfAssertFactories.list(Order.class))
            .hasSize(3);
    }
}

Le premier test vérifie que le résultat de reduce correspond bien au montant total. Le deuxième test vérifie que groupingBy crée bien le bon nombre d’éléments dans le groupe COMPLETED.

9. Erreurs courantes

  • Utiliser stream quand une boucle ordinaire est plus claire : si la logique a beaucoup de side effects (log, mise à jour de variable externe, throw exception avec condition complexe), une boucle for traditionnelle est plus lisible qu’un stream.
  • Réutiliser un Stream déjà consommé : un stream ne peut s’exécuter qu’une seule fois. Appeler collect() ou forEach() deux fois sur le même stream lancera une IllegalStateException: stream has already been operated upon or closed.
  • Oublier l’identity appropriée dans reduce : utiliser la mauvaise identity (par exemple 1 au lieu de 0 quand on calcule une somme) donnera un résultat incorrect mais sans aucun message d’erreur.
  • toMap avec des clés en doublon : comme mentionné à la section 6.1, vous devez ajouter une fonction de fusion : Collectors.toMap(keyFn, valueFn, (v1, v2) -> v1).
  • Side effect dans map : évitez de modifier l’état externe dans la fonction passée à map/filter. Les streams peuvent s’exécuter en parallèle, les side effects causeront des race conditions difficiles à déboguer.

Attention : Si votre pipeline a plus de 4-5 opérations chaînées et devient difficile à lire, séparez-le en méthodes distinctes avec des noms explicites (filterCompleted, calculateRevenue…) au lieu de tout mettre dans une longue chaîne.

10. Conclusion

filter, map, reduce et collect forment l’ensemble d’outils fondamentaux pour manipuler les données de manière fonctionnelle en Java. filter vous aide à sélectionner, map à transformer, reduce à agréger en une seule valeur, tandis que collect rassemble les résultats dans une structure plus complexe comme une Map ou un groupement selon une condition. En maîtrisant l’ordre des opérations et en évitant les erreurs courantes listées ci-dessus, vous serez capable d’écrire des pipelines de traitement de données concis, clairs et faciles à tester.

javastream-apifunctional-programmingjava21collections