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()convertitList<Order>en unStream<Order>.filterconserve seulement les commandes ayant lestatusCOMPLETED.toList()(Java 16+) rassemble le résultat en uneListimmuable, à la place decollect(Collectors.toList()).
O1 - Laptop
O2 - Mouse
O4 - Monitor
Attention :
filterne modifie pas les données d’origine. Stream est lazy – si vous n’avez pas d’opération terminal (commetoList(),forEach()), lesfilter/mappré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::productest une method reference, équivalent àorder -> order.product().distinct()élimine les éléments dupliqués (basé surequals()).
[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
mapavecforEach.mapretourne un nouveau stream, sans side effect ;forEachest 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.0est l’identity – la valeur initiale quand le stream est vide.Double::sumest 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)etStream.min(Comparator)– plus court et plus clair quereduce. Utilisezreduceseulement 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.toMapaccepte deux fonctions : l’extracteur de clé et l’extracteur de valeur.- S’il y a des clés en doublon,
toMaplancera uneIllegalStateException– 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
fortraditionnelle 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()ouforEach()deux fois sur le même stream lancera uneIllegalStateException: stream has already been operated upon or closed. - Oublier l’identity appropriée dans reduce : utiliser la mauvaise identity (par exemple
1au lieu de0quand 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.