📝 Introduction : expressivité
Dans la première partie sur les fondamentaux de la programmation fonctionnelle, nous avions survolé l’utilisation et les bénéfices de l’API Stream.
Dans cette partie, nous allons approfondir son usage et découvrir la puissance de l'API Collector !
💪 Maîtriser l'API Stream - de la théorie à la pratique métier
Comprendre la philosophie stream
L'API Stream n'est pas juste une nouvelle façon d'écrire des boucles. C'est un pipeline de transformation de données qui sépare clairement :
- QUOI transformer (les opérations)
- COMMENT l'exécuter (séquentiel, parallèle, lazy)
- QUAND l'exécuter (évaluation paresseuse)
Anatomie d'un Stream : Source → Transformations → Résultat
public void demonstrateStreamPipeline() {
List<Order> orders = getOrders();
BigDecimal totalRevenue = orders.stream() // 🔵 SOURCE
.filter(order -> order.getStatus() == COMPLETED) // 🟡 INTERMÉDIAIRE
.filter(order -> order.getDate().getYear() == 2024) // 🟡 INTERMÉDIAIRE
.map(Order::getAmount) // 🟡 INTERMÉDIAIRE
.reduce(BigDecimal.ZERO, BigDecimal::add); // 🔴 TERMINALE
// ⚠️ IMPORTANT : Rien ne s'exécute avant l'opération terminale !
}API Stream, back to basic
Les opérations intermédiaires : construire le pipeline
1. filter() - La sélection intelligente
Traditionnellement, nous verrons ce genre de chose :
// ❌ Approche traditionnelle - logique dispersée
public List<Order> findProblematicOrders(List<Order> orders) {
List<Order> result = new ArrayList<>();
for (Order order : orders) {
if (order.getAmount().compareTo(new BigDecimal("1000")) > 0) {
if (order.getStatus() == OrderStatus.PENDING) {
if (order.getCreatedAt().isBefore(LocalDateTime.now().minusHours(24))) {
result.add(order);
}
}
}
}
return result;
}Filter objects in for-loops
La complexité cognitive est importante pour un si petit bout de code ! Nous avons besoin d'une concentration importante pour comprendre ce qui est effectué ici.
Dans un style déclaratif et fonctionnel :
// ✅ Approche Stream - intention claire
public List<Order> findProblematicOrders(List<Order> orders) {
return orders.stream()
.filter(this::isHighValue)
.filter(this::isPending)
.filter(this::isOld)
.toList();
}
// Prédicats métier réutilisables
private boolean isHighValue(Order order) {
return order.getAmount().compareTo(new BigDecimal("1000")) > 0;
}
private boolean isPending(Order order) {
return order.getStatus() == OrderStatus.PENDING;
}
private boolean isOld(Order order) {
return order.getCreatedAt()
.isBefore(LocalDateTime.now().minusHours(24));
}Filter a Stream
Ici, on lit réellement le métier, aucune complexité ni surcharge technique liée au langage.
Les fonctions sont explicites et faciles à lire, car leur périmètre fonctionnel est réduit au strict minimum.
2. map() - La transformation
De la même manière, nous pouvons transformer les objets d'une liste.
// Transformation simple : Order → OrderSummary
public List<OrderSummary> createOrderSummaries(List<Order> orders) {
return orders.stream()
.map(this::toOrderSummary)
.toList();
}
private OrderSummary toOrderSummary(Order order) {
return new OrderSummary(
order.getId(),
order.getCustomer().getName(),
order.getTotalAmount(),
order.getStatus()
);
}Map a stream
Il est possible de chaîner plusieurs opérations de transformations. Cela permet notamment de définir des fonctions plus simples dont le périmètre est plus limité :
// Transformation avec calcul : Order → Revenue avec taxes
public List<BigDecimal> calculateRevenueWithTax(List<Order> orders) {
return orders.stream()
.map(Order::getAmount)
.map(amount -> amount.multiply(new BigDecimal("1.20"))) // +20% TVA
.toList();
}Chained map function
3. flatMap() - aplatir les structures complexes
// Cas d'usage typique : Order → List<OrderItem>
public List<String> getAllProductNames(List<Order> orders) {
return orders.stream()
.flatMap(order -> order.getItems().stream()) // Order → Stream<OrderItem>
.map(OrderItem::getProductName) // OrderItem → String
.distinct() // Éliminer les doublons
.sorted() // Trier alphabétiquement
.toList();
}FlatMap streams
// Pattern utile : traitement des Optional
public List<String> getValidEmails(List<Customer> customers) {
return customers.stream()
.map(Customer::getEmail) // Customer → Optional<String>
.flatMap(Optional::stream) // Optional<String> → Stream<String>
.filter(email -> email.contains("@")) // Validation basique
.toList();
}FlatMap optionals
Les opérations terminales : matérialiser le résultat
1. collect() - le couteau suisse
La méthode collect() représente toute la puissance de l'API Stream au travers de l'utilisation de l'API Collector, API qui n'a d'existence que parce que l'API Stream existe.
Heureusement pour nous, le JDK embarque la classe utilitaire Collectors, fournissant de nombreuse factory d'implémentation de Collector.
Par exemple, si je souhaite simplement grouper une liste de commande par leur statut :
public Map<OrderStatus, List<Order>> groupByStatus(List<Order> orders) {
return orders.stream()
.collect(Collectors.groupingBy(
Order::getStatus,
Collectors.toUnmodifiableList()
));
}Simple groupingBy
Il est possible d'appliquer plusieurs opérations sur les valeurs de la map grâce aux méthodes filtering(), mapping(), flatMapping() et bien d'autres...
public Map<String, BigDecimal> getRevenueByCustomer(List<Order> orders) {
return orders.stream()
.collect(Collectors.groupingBy(
order -> order.getCustomer().getName(),
Collectors.mapping(
Order::getAmount,
Collectors.reducing(BigDecimal.ZERO, BigDecimal::add))
));
}Réduction des valeurs de la Map
Dans la classe utilitaire Collectors, de nombreuses spécialisations sont fournies, répondant à la plupart de nos besoins.
Par exemple pour partitionner une liste selon un prédicat :
public Map<Boolean, List<Order>> partitionByValue(List<Order> orders) {
return orders.stream()
.collect(Collectors.partitioningBy(
order -> order.getAmount()
.compareTo(new BigDecimal("500")) > 0
));
}partitioningBy factory
Ou encore extraire des statistiques :
public DoubleSummaryStatistics getOrderStatistics(List<Order> orders) {
return orders.stream()
.collect(Collectors.summarizingDouble(
order -> order.getAmount().doubleValue()
));
}summarizingDouble factory
Une que j'affectionne tout particulièrement, est la méthode collectingAndThen(). Elle permet d'effectuer une première réduction (premier paramètre) puis effectuer un traitement sur cette réduction. Très utile pour éviter des variables intermédiaires qui n'ont pas de sens métier.
Par exemple, retourner le prix moyen d'une commande dans une chaîne de caractère formatée :
public String getAveragePriceMessage(List<Order> orders) {
return orders.stream()
.collect(Collectors.collectingAndThen(
Collectors.averagingDouble(Order::price),
"Prix moyen de la commande: %.2f€"::formatted
));
}collectingAndThen factory
Pour des besoins plus spécifiques qui ne seraient pas couverts par les factory présentes dans la classe Collectors, vous pouvez toujours implémenter votre propre Collector grâce à la factory of() prévue à cet effet :
// Collector personnalisé : création d'un rapport
public OrderReport generateReport(List<Order> orders) {
return orders.stream()
.collect(Collector.of(
OrderReport::new, // Supplier
OrderReport::addOrder, // Accumulator
OrderReport::combine, // Combiner
Function.identity() // Finisher
));
}
// Classe pour le collector personnalisé
public class OrderReport {
private int totalOrders = 0;
private BigDecimal totalRevenue = BigDecimal.ZERO;
private BigDecimal maxOrderValue = BigDecimal.ZERO;
public void addOrder(Order order) {
totalOrders++;
totalRevenue = totalRevenue.add(order.getAmount());
if (order.getAmount().compareTo(maxOrderValue) > 0) {
maxOrderValue = order.getAmount();
}
}
public OrderReport combine(OrderReport other) {
OrderReport combined = new OrderReport();
combined.totalOrders = this.totalOrders + other.totalOrders;
combined.totalRevenue = this.totalRevenue.add(other.totalRevenue);
combined.maxOrderValue = this.maxOrderValue.max(other.maxOrderValue);
return combined;
}
// getters...
}
Custom Collector
2. reduce() - L'agrégation
La méthode reduce répond aux besoins qui ne sont pas couverts par les autres méthodes terminales ou par l'API Collector.
Généralement, nous n'en n'avons pas l'utilité car l'API Collector et les Collector fournis sont généralement suffisants.
Exemple pour calculer la somme d'une liste de commande (il y a aussi plus simplement la méthode terminale sum()) :
public BigDecimal calculateTotalRevenue(List<Order> orders) {
return orders.stream()
.map(Order::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}Basic reduce
Récupérer, si elle existe, la commande la plus récente :
public Optional<Order> findMostRecentOrder(List<Order> orders) {
return orders.stream()
.reduce((order1, order2) ->
order1.getCreatedAt().isAfter(order2.getCreatedAt()) ? order1 : order2
);
}Reduce to an Optional
Cette méthode permet aussi de réduire une source de données en un seul objet :
// Réduction complexe : construire un résumé
public String createOrderSummary(List<Order> orders) {
return orders.stream()
.map(order -> String.format("%s: %.2f€",
order.getId(), order.getAmount()))
.reduce("Commandes:\n",
(summary, orderLine) -> summary + "- " + orderLine + "\n");
}Reduce to an object
Ou de combiner des objets différents :
// Pattern avancé : réduction avec type différent
public CustomerStats calculateCustomerStats(List<Order> orders) {
return orders.stream()
.reduce(
new CustomerStats(), // Identité
(stats, order) -> stats.addOrder(order), // Accumulator
CustomerStats::combine // Combiner
);
}Reduce with different types
Bonnes pratiques et pièges à éviter
1. Éviter les effets de bord
Comme dirait José Paumard dans ses Shorts Youtube :
"Why would you do that ?!"
Je crois que je n'ai rien plus à ajouter, si ce n'est d'oublier la méthode forEach() pour l'utiliser autrement que pour du logging, de l'appel réseau ou tout autre side effect. Dans tous les cas : on ne mute pas un conteneur, ou tout autre objet !
// ❌ MAUVAIS : modification d'état externe
private List<String> processedItems = new ArrayList<>();
public void badExample(List<String> items) {
items.stream()
.filter(this::isValid)
.forEach(processedItems::add); // Effet de bord !
}
// ✅ BON : approche fonctionnelle pure
public List<String> goodExample(List<String> items) {
return items.stream()
.filter(this::isValid)
.toList(); // Pas d'effet de bord
}Avoid side effects
2. Gérer les exceptions dans les streams
Java et ces exceptions Checked, quelle horreur !
Ne vous en rajoutez pas, il y en a déjà bien assez... Utilisez plutôt les exceptions de type runtime, cela vous polluera moins votre code et tous les frameworks modernes proposent des handlers pour correctement les traiter.
Une solution simpliste lorsque vous souhaitez ignorer une Checked Exception consiste à retourner un Optional :
// ❌ PROBLÈME : les lambdas ne peuvent pas lancer d'exceptions checked
public List<URL> parseUrlsBad(List<String> urls) {
return urls.stream()
.map(URL::new) // ❌ Compilation error : MalformedURLException
.toList();
}
// ✅ SOLUTION POSSIBLE : wrapper avec try-catch
public List<URL> parseUrlsWithWrapper(List<String> urls) {
return urls.stream()
.map(this::safeParseUrl)
.<URL>mapMulti(Optional::ifPresent)
.toList();
}
private Optional<URL> safeParseUrl(String url) {
try {
return Optional.of(new URL(url));
} catch (MalformedURLException e) {
return Optional.empty();
}
}Functional Interface and Checked Exceptions
Sinon, vous pouvez aussi wrapper cette exception en levant une exception de type runtime.
3. Optimiser les performances
Avoir une approche déclarative ne résout en aucun cas les problèmes de performances liées à l'algorithme que vous aurez mis en œuvre.
On parle souvent de pattern map / filter / reduce mais on devrait dire filter / map / reduce :
// ✅ Ordre des opérations : filter avant map
public List<ExpensiveResult> optimizedPipeline(List<RawData> data) {
return data.stream()
.filter(this::isValid) // Réduire le dataset d'abord
.filter(this::isRelevant) // Filtres peu coûteux en premier
.map(this::expensiveTransform) // Transformation coûteuse à la fin
.toList();
}
// ❌ Ordre inefficace
public List<ExpensiveResult> inefficientPipeline(List<RawData> data) {
return data.stream()
.map(this::expensiveTransform) // Transformation sur tout le dataset
.filter(this::isValid) // Filtrage après transformation
.toList();
}Profitons aussi des évaluations paresseuses pour ne pas traiter toute la source de données lorsque cela n'est pas nécessaire :
// ✅ Utiliser des opérations de court-circuit
public boolean hasValidOrder(List<Order> orders) {
return orders.stream()
.anyMatch(order -> order.getAmount().compareTo(BigDecimal.ZERO) > 0);
// S'arrête dès le premier élément trouvé
}Cela s'applique aussi avec l'utilisation des méthodes takeWhile / takeUntil / dropWhile / limit etc.
Et enfin, bien explorer les capacités de l'API Stream et de l'API Collector pour traiter en un seul passage notre source de données :
// ✅ Éviter les opérations terminales multiples
// ❌ Inefficace : 3 parcours de la liste
// 1er stream : filtrer les seniors
List<Employee> seniors = employees.stream()
.filter(emp -> emp.age() > 30)
.toList();
// 2ème stream : extraire les salaires
List<Double> salaries = seniors.stream()
.map(Employee::salary)
.toList();
// 3ème stream : trier par ordre décroissant
List<Double> sortedSalaries = salaries.stream()
.sorted(Collections.reverseOrder())
.toList();
// ✅ Efficace : 1 seul parcours
List<Double> result = employees.stream()
.filter(emp -> emp.age() > 30) // Filtrer les seniors
.map(Employee::salary) // Extraire les salaires
.sorted(Collections.reverseOrder()) // Trier par ordre décroissant
.toList();
Best practice for performance
Prenons un cas un peu plus avancé.
Supposons qu'on veuille calculer simultanément la moyenne des ventes et le nombre total de produits vendus pour les ventes supérieures à 1000€, groupées par région.
List<Sale> sales = List.of(
new Sale("Nord", "Laptop", 1200, 2),
new Sale("Sud", "Phone", 800, 5),
new Sale("Nord", "Tablet", 1500, 3),
new Sale("Est", "Laptop", 1100, 1),
new Sale("Sud", "Monitor", 1300, 4),
new Sale("Ouest", "Phone", 900, 2),
new Sale("Nord", "Keyboard", 1400, 6),
new Sale("Est", "Mouse", 1600, 8)
);Liste des ventes
Préparons-nous un record pour modéliser l'attendu :
record RegionStats(double averageAmount, int totalQuantity) {}Un algorithme peu performant ressemblera à ceci :
// 1er stream : filtrer et grouper pour calculer la moyenne
Map<String, Double> averageByRegion = sales.stream()
.filter(sale -> sale.amount() > 1000)
.collect(Collectors.groupingBy(
Sale::region,
Collectors.averagingDouble(Sale::amount)
));
// 2ème stream : filtrer et grouper pour calculer le total des quantités
Map<String, Integer> totalQuantityByRegion = sales.stream()
.filter(sale -> sale.amount() > 1000)
.collect(Collectors.groupingBy(
Sale::region,
Collectors.summingInt(Sale::quantity)
));
// 3ème stream : combiner les résultats
Map<String, RegionStats> result = averageByRegion.keySet().stream()
.collect(Collectors.toMap(
region -> region,
region -> new RegionStats(
averageByRegion.get(region),
totalQuantityByRegion.getOrDefault(region, 0)
)
));
System.out.println("Résultat avec plusieurs streams :");
result.forEach((region, stats) ->
System.out.printf("%s: Moyenne=%.2f€, Total quantité=%d%n",
region, stats.averageAmount(), stats.totalQuantity()));3 streams
Avec une bonne connaissance de l'API Collector, nous pouvons effectuer tout le traitement en un seul passage :
// APPROCHE AVEC UN SEUL STREAM ET TEEING (efficace)
Map<String, RegionStats> result = sales.stream()
.filter(sale -> sale.amount() > 1000)
.collect(Collectors.groupingBy(
Sale::region,
Collectors.teeing(
Collectors.averagingDouble(Sale::amount), // 1er collecteur
Collectors.summingInt(Sale::quantity), // 2ème collecteur
RegionStats::new // Fonction de combinaison
)
));
System.out.println("Résultat avec un seul stream et teeing :");
result.forEach((region, stats) ->
System.printf("%s: Moyenne=%.2f€, Total quantité=%d%n",
region, stats.averageAmount(), stats.totalQuantity()));Use teeing Collector
- Performance : Une seule itération pour calculer plusieurs agrégations
- Atomicité : Les deux calculs sont garantis sur le même ensemble de données
- Lisibilité : Pipeline de traitement plus clair et intention plus évidente
- Efficacité mémoire : Pas de collections intermédiaires
- Parallélisation : Fonctionne naturellement avec les streams parallèles
Collectors.teeing() est particulièrement utile quand vous devez calculer plusieurs métriques différentes sur le même flux de données filtré/transformé.
✍️ Conclusion : vers une maîtrise complète de la programmation fonctionnelle
L'exploration approfondie de l'API Stream et de l'API Collector révèle toute la puissance de la programmation fonctionnelle en Java. Au-delà des simples boucles optimisées, ces APIs offrent un véritable changement de paradigme dans notre façon de concevoir et d'écrire du code.
🚀 Les bénéfices concrets
Expressivité et Lisibilité : Le code devient une narration claire du métier, où chaque opération exprime une intention précise. Fini les boucles imbriquées difficiles à déchiffrer !
Réutilisabilité : Les prédicats métier, les transformations et les collectors personnalisés créent un arsenal de composants réutilisables qui enrichissent votre base de code.
Performance : L'évaluation paresseuse, les opérations de court-circuit et la possibilité de parallélisation offrent des optimisations naturelles souvent supérieures aux approches impératives.
Maintenabilité : La séparation claire entre le quoi (les transformations) et le comment (l'exécution) facilite grandement les évolutions et la maintenance.
🎓 Points clés à retenir
- Pensez Pipeline : Visualisez vos traitements comme des flux de transformation plutôt que comme des boucles
- Maîtrisez l'API Collector : C'est là que réside la vraie puissance pour les agrégations complexes
- Optimisez l'ordre :
filter→map→reducepour des performances optimales - Un seul passage : Exploitez
teeing()et les collectors composés pour éviter les multiples itérations - Restez fonctionnel : Évitez les effets de bord pour conserver les bénéfices de l'approche
🔮 Perspectives d'évolution
La programmation fonctionnelle en Java continue d'évoluer. Les futures versions du langage promettent encore plus d'expressivité.
Maîtriser ces concepts aujourd'hui, c'est se préparer à un Java plus moderne, plus expressif et plus proche du métier. L'investissement en temps d'apprentissage se transforme rapidement en gain de productivité et en plaisir de développement au quotidien.
La programmation fonctionnelle n'est plus une option en Java moderne, c'est une nécessité pour tout développeur souhaitant écrire du code élégant, performant et maintenable.