Aller au contenu

Programmation Fonctionnelle en Java : Partie 1 - Fondamentaux

Depuis Java 8, la programmation fonctionnelle a révolutionné la manière de coder en Java. Découvrez comment les lambdas, les streams et l'immutabilité peuvent transformer votre code en un modèle de lisibilité, de maintenabilité et d'efficacité.

Les fondamentaux de la programmation fonctionnelle en Java

📝 Introduction : quand le code raconte une histoire

Imaginez deux développeurs face au même problème : calculer le chiffre d'affaires mensuel par région à partir d'une liste de commandes. Le premier écrit 25 lignes de boucles imbriquées, de variables temporaires et de conditions. Le second écrit ceci :

Map<String, BigDecimal> revenueByRegion = orders.stream()
    .filter(order -> order.getDate().getMonth() == targetMonth)
    .collect(groupingBy(Order::getRegion, 
             summingBigDecimal(Order::getAmount)));

Exemple groupingBy

3 lignes. Lisibles. Testables. Maintenables.

Cette transformation n'est pas magique : c'est la puissance de la programmation fonctionnelle et déclarative introduite massivement en Java depuis la version 8. Si vous développez encore comme en 2010, cet article va changer votre façon de coder.

Ce sujet fera l'objet d'une suite de plusieurs articles :

❔ Pourquoi Java a embrassé le fonctionnel

Pendant des années, Java était synonyme de verbosité et de complexité. L'arrivée des lambdas, de l'API Stream et des interfaces fonctionnelles en 2014 a marqué un tournant historique. Les développeurs avaient besoin :

  • D'expressivité : dire quoi faire plutôt que comment
  • De sûreté : réduire les bugs liés aux états mutables
  • De performance : exploiter facilement le parallélisme
  • De maintenabilité : code plus court et plus clair

💥 L'impact Business du code fonctionnel

Au-delà de l'élégance technique, adopter ces paradigmes génère des bénéfices mesurables :

  • - de bugs en production (moins d'états mutables = moins d'effets de bord)
  • + de vitesse de développement (code plus expressif et réutilisable)
  • - de temps de debugging (fonctions pures plus prévisibles)
  • + de facilité pour l'onboarding (code auto-documenté)

🧑‍🎓 Comprendre les fondamentaux - de l'impératif au déclaratif

Le grand changement de perspective

Avant Java 8, nous étions des architectes de processus : nous décrivions chaque étape, chaque boucle, chaque condition. Depuis Java 8, nous pouvons devenir des architectes d'intentions : nous exprimons le résultat souhaité.

Exemple progressif : évolution d'un code Métier

Contexte : Une boutique en ligne veut analyser ses commandes pour identifier les clients VIP.

Étape 1 : L'approche traditionnelle (pré-Java 8)

public List<String> findVipCustomerNames(List<Order> orders) {
    // Variables mutables - source de bugs
    Map<String, BigDecimal> customerTotals = new HashMap<>();
    List<String> vipCustomers = new ArrayList<>();
    
    // Logique métier noyée dans les détails techniques
    for (Order order : orders) {
        String customerId = order.getCustomerId();
        BigDecimal currentTotal = customerTotals.get(customerId);
        
        if (currentTotal == null) {
            currentTotal = BigDecimal.ZERO;
        }
        
        BigDecimal newTotal = currentTotal.add(order.getAmount());
        customerTotals.put(customerId, newTotal);
    }
    
    // Encore une boucle pour filtrer
    for (Map.Entry<String, BigDecimal> entry : customerTotals.entrySet()) {
        if (entry.getValue().compareTo(new BigDecimal("1000")) >= 0) {
            vipCustomers.add(entry.getKey());
        }
    }
    
    return vipCustomers;
}

Old-School Programming

Problèmes identifiés :

  • 🔴 20 lignes pour une logique simple
  • 🔴 Variables mutables (customerTotalsvipCustomers)
  • 🔴 Logique métier cachée dans les détails techniques
  • 🔴 Difficile à tester unitairement
  • 🔴 Risque de NullPointerException

Étape 2 : La transformation fonctionnelle

private static final BigDecimal VIP_THRESHOLD = new BigDecimal("1000");

public List<String> findVipCustomerNames(List<Order> orders) {
    final var groupedSumAmountByCustomers = orders.stream()
        .collect(groupingBy(Order::getCustomerId, 
                 summingBigDecimal(Order::getAmount)));
        
    return groupedSumAmountByCustomers.entrySet()
        .stream()
        .filter(entry -> entry.getValue().compareTo(VIP_THRESHOLD) >= 0)
        .map(Map.Entry::getKey)
        .sorted()
        .toList();
}

Exemple API Stream et API Collector

Bénéfices obtenus :

  • ✅ 8 lignes au lieu de 20
  • ✅ Aucune variable mutable
  • ✅ Intention claire : grouper → sommer → filtrer → extraire → trier
  • ✅ Facilement testable
  • ✅ Parallélisable en changeant stream() par parallelStream()

📜 Les trois piliers du fonctionnel en Java

1. L'immutabilité : "Une fois créé, jamais modifié"

// ❌ Approche mutable - source de bugs
public class MutableCustomer {
    private String name;
    private BigDecimal totalSpent;
    
    public void addPurchase(BigDecimal amount) {
        this.totalSpent = this.totalSpent.add(amount); // Risque de concurrence
    }
}

// ✅ Approche immuable - sûre et prévisible
public record Customer(String name, BigDecimal totalSpent) {
    
    public Customer addPurchase(BigDecimal amount) {
        return new Customer(name, totalSpent.add(amount));
    }
    
    public boolean isVip() {
        return totalSpent.compareTo(new BigDecimal("1000")) >= 0;
    }
}

Record Java

2. Les fonctions pures : "Même entrée = même sortie"

Nul besoin d'expliciter ce que sont les fonctions pures, Amin nous proposait un super article à ce sujet !

On rappellera avec deux exemples simplistes :

public class PricingCalculator {
    
    // ❌ Fonction impure - dépend de l'état externe
    private BigDecimal currentDiscount = new BigDecimal("0.1");
    
    public BigDecimal calculatePriceImpure(BigDecimal basePrice) {
        return basePrice.multiply(BigDecimal.ONE.subtract(currentDiscount));
    }
    
    // ✅ Fonction pure - prévisible et testable
    public static BigDecimal calculatePrice(BigDecimal basePrice, BigDecimal discountRate) {
        return basePrice.multiply(BigDecimal.ONE.subtract(discountRate));
    }
    
    // ✅ Composition de fonctions pures
    public static Function<BigDecimal, BigDecimal> createPriceCalculator(BigDecimal discountRate) {
        return basePrice -> calculatePrice(basePrice, discountRate);
    }
}

Pure Function

3. Les fonctions de première classe : "Les fonctions comme valeurs"

public class OrderProcessor {
    
    // Fonctions stockées comme des variables
    private final Predicate<Order> isRecentOrder = 
        order -> order.getDate().isAfter(LocalDate.now().minusDays(30));
    
    private final Function<Order, BigDecimal> calculateTotalWithTax = 
        order -> order.getAmount().multiply(new BigDecimal("1.20"));
    
    private final Consumer<Order> sendConfirmationEmail = 
        order -> emailService.send(order.getCustomerEmail(), "Commande confirmée");
    
    // Méthode générique utilisant des fonctions en paramètres
    public <T> List<T> processOrders(List<Order> orders, 
                                   Predicate<Order> filter,
                                   Function<Order, T> transformer) {
        return orders.stream()
            .filter(filter)
            .map(transformer)
            .toList();
    }
}

Higher Order Function

🥷 Maîtriser l'immutabilité en Java

Au-delà du mot-clé final

Le mot-clé final n'est que la première marche de l'immutabilité. Voici une progression complète :

Niveau 1 : immutabilité des références

public class OrderService {
    
    // ✅ Référence immutable
    private final PaymentProcessor paymentProcessor;
    
    // ❌ Piège : la liste est mutable !
    private final List<String> allowedCountries = new ArrayList<>();
    
    public OrderService(PaymentProcessor processor) {
        this.paymentProcessor = processor;
        // ❌ Modification possible après construction
        allowedCountries.add("FR");
        allowedCountries.add("DE");
    }
}

Fake Immutability

Niveau 2 : immutabilité profonde

public class OrderService {
    
    private final PaymentProcessor paymentProcessor;
    private final Set<String> allowedCountries;
    
    public OrderService(PaymentProcessor processor, Collection<String> countries) {
        this.paymentProcessor = processor;
        // ✅ Copie défensive + collection immutable
        this.allowedCountries = Set.copyOf(countries);
    }
    
    public Set<String> getAllowedCountries() {
        // ✅ Retour direct sûr (Set.copyOf crée une collection immutable)
        return allowedCountries;
    }
}

Defensive Programming

Niveau 3 : records - L'immutabilité élégante

// ✅ Record simple
public record Product(String name, BigDecimal price, Category category) {
    
    // Validation dans le constructeur compact
    public Product {
        if (price.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("Le prix doit être positif");
        }
    }
    
    // Méthodes dérivées
    public boolean isExpensive() {
        return price.compareTo(new BigDecimal("100")) > 0;
    }
}

// ✅ Record avec collections
public record Order(String id, 
                   Customer customer, 
                   List<OrderItem> items, 
                   LocalDateTime createdAt) {
    
    public Order {
        // Copie défensive automatique
        items = List.copyOf(items);
    }
    
    public BigDecimal getTotalAmount() {
        return items.stream()
            .map(OrderItem::getPrice)
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
    
    // Méthode "with" pour modification immutable
    public Order withAdditionalItem(OrderItem newItem) {
        List<OrderItem> newItems = new ArrayList<>(items);
        newItems.add(newItem);
        return new Order(id, customer, newItems, createdAt);
    }
}

New Record over mutation

Pattern Builder immuable

Pour des objets complexes, le pattern Builder reste pertinent :

public record ComplexOrder(String id,
                          Customer customer,
                          List<OrderItem> items,
                          ShippingAddress address,
                          PaymentMethod payment,
                          List<Discount> discounts,
                          OrderStatus status) {
    
    public static Builder builder() {
        return new Builder();
    }
    
    public static class Builder {
        private String id;
        private Customer customer;
        private List<OrderItem> items = new ArrayList<>();
        private ShippingAddress address;
        private PaymentMethod payment;
        private List<Discount> discounts = new ArrayList<>();
        private OrderStatus status = OrderStatus.PENDING;
        
        public Builder id(String id) {
            this.id = id;
            return this;
        }
        
        public Builder customer(Customer customer) {
            this.customer = customer;
            return this;
        }
        
        public Builder addItem(OrderItem item) {
            this.items.add(item);
            return this;
        }
        
        public Builder addItems(Collection<OrderItem> items) {
            this.items.addAll(items);
            return this;
        }
        
        // ... autres méthodes
        
        public ComplexOrder build() {
            return new ComplexOrder(id, customer, List.copyOf(items), 
                                  address, payment, List.copyOf(discounts), status);
        }
    }
}

Builder pattern

✍️ Conclusion

La programmation fonctionnelle et déclarative en Java représente bien plus qu'une simple évolution syntaxique : c'est un véritable changement de paradigme qui transforme notre approche du développement logiciel. À travers les concepts explorés dans cet article, nous avons pu constater comment cette approche répond aux défis modernes du développement d'applications robustes et maintenables.

L'expressivité métier et la lisibilité du code s'en trouvent considérablement améliorées, permettant aux développeurs d'exprimer leurs intentions de manière plus naturelle et proche du langage métier. Les fonctions pures et l'immutabilité constituent les fondations d'un code plus prévisible et moins sujet aux erreurs, particulièrement dans un contexte multi-threadé où la concurrence est omniprésente.

L'écosystème Java moderne, enrichi par l'API Stream, les interfaces fonctionnelles standardisées et les records, offre désormais tous les outils nécessaires pour adopter ce paradigme sans compromis sur les performances. La composition de fonctions permet de construire des solutions complexes à partir de briques élémentaires réutilisables, favorisant ainsi la maintenabilité et la testabilité du code.

Adopter la programmation fonctionnelle en Java n'est pas une révolution brutale mais plutôt une évolution naturelle qui s'intègre harmonieusement avec l'existant. Elle nous invite à repenser nos habitudes de développement pour produire un code plus élégant, plus sûr et ultimement plus efficace. Dans un monde où la complexité des systèmes ne cesse de croître, ces paradigmes deviennent non plus un luxe, mais une nécessité pour tout développeur soucieux de la qualité de son code.

Dernier