Programmation Fonctionnelle en Java : Les Bases

Université de Toulon

LIS UMR CNRS 7020

2024-12-03

Introduction à la Programmation Fonctionnelle en Java

  • Qu’est-ce que la programmation fonctionnelle ?
    • Paradigme de programmation qui traite les calculs comme l’évaluation de fonctions mathématiques.
    • Mise l’accent sur les expressions, les fonctions pures et l’immutabilité.
    • Utilisation de fonctions de première classe et d’ordre supérieur.
  • Pourquoi la programmation fonctionnelle en Java ?
    • Simplifier le code en réduisant les effets de bord.
    • Améliorer la lisibilité et la maintenabilité du code.
    • Faciliter la parallélisation et la concurrence grâce à l’immutabilité.
    • Utilisation des expressions lambda et des streams pour manipuler les collections de manière déclarative.

Comparaison

// Structure de données
class Course {
    String name;
    double grade;
    
    Course(String name, double grade) {
        this.name = name;
        this.grade = grade;
    }
}
// Données d'exemple

class Student {
    String name;
    List<Course> courses;

    Student(String name, List<Course> courses) {
        this.name = name;
        this.courses = courses;
    }
}
List<Student> students = Arrays.asList(
    new Student("Alice", Arrays.asList(
        new Course("Math", 18.5),
        new Course("Physics", 17.0)
    )),
    new Student("Bob", Arrays.asList(
        new Course("Math", 12.0),
        new Course("Physics", 13.5)
    )),
    new Student("Charlie", Arrays.asList(
        new Course("Math", 15.0),
        new Course("Physics", 16.0)
    ))
);
// Version fonctionnelle : Trouver les noms des étudiants ayant une moyenne > 15 en Math
List<String> highPerformers = students.stream()
    .filter(student -> student.courses.stream()
        .filter(course -> course.name.equals("Math"))
        .mapToDouble(course -> course.grade)
        .average()
        .orElse(0.0) > 15.0)
    .map(student -> student.name)
    .collect(Collectors.toList());
highPerformers;
[Alice]
// Version impérative équivalente
List<String> highPerformersImperative = new ArrayList<>();
for (Student student : students) {
    double mathSum = 0.0;
    int mathCount = 0;
    for (Course course : student.courses) {
        if (course.name.equals("Math")) {
            mathSum += course.grade;
            mathCount++;
        }
    }
    if (mathCount > 0 && (mathSum / mathCount) > 15.0) {
        highPerformersImperative.add(student.name);
    }
}
highPerformersImperative
[Alice]

Avantages de la version fonctionnelle

  1. Lisibilité et Intent
    • ✓ Fonctionnel: La chaîne d’opérations reflète directement l’intention du développeur
    • ✗ Impératif: Multiples boucles et conditions imbriquées
  2. Concision et Maintenabilité
    • ✓ Fonctionnel: Une seule expression fluide
    • ✗ Impératif: Variables temporaires, compteurs, accumulateurs
  3. Immutabilité et Sûreté
    • ✓ Fonctionnel: Pas d’effets de bord, thread-safe par défaut
    • ✗ Impératif: État mutable partagé, risques de race conditions
  4. Composabilité et Réutilisation
    • ✓ Fonctionnel: Fonctions comme briques de base combinables
  5. Parallélisation et Performance
    • ✓ Fonctionnel: .parallel() suffit pour paralléliser
    • ✗ Impératif: Synchronisation manuelle complexe
  6. Debugging et Testing
    • ✓ Fonctionnel: Fonctions pures facilement testables
    • ✗ Impératif: Tests complexes dus aux états mutables

Les Interfaces Fonctionnelles

  • Définition: Une interface avec une seule méthode abstraite.

  • https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/function/package-summary.html

  • Interfaces génériques

    • Function<T, R>: Applique une fonction à un argument de type T et retourne un résultat de type R.
    • Consumer<T>: Consomme un argument de type T.
    • Predicate<T>: Représente une condition sur un élément de type T.
    • Supplier<T>: Fournit un élément de type T.
    • BiFunction<T,U> - like Function but with two parameters.
    • BiConsumer<T,U> - like Consumer but with two parameters.

Interfaces pour les types primitifs

  • IntFunction<R>: Accepte un int et retourne un résultat de type R.
  • IntConsumer: Consomme un int.
  • IntPredicate: Représente une condition sur un int.
  • IntSupplier: Fournit un int.
  • LongFunction<R>, LongConsumer, LongPredicate, LongSupplier
  • DoubleFunction<R>, DoubleConsumer, DoublePredicate, DoubleSupplier
  • BooleanSupplier

Elles peuvent être vues comme des instances de classes anonymes n’ayant qu’une seule méthode dont les types de retour et des paramètres sont inférés.

Les Lambdas Expressions

Définition: Une lambda expression est une fonction anonyme qui peut être assignée à une variable. C’est une manière concise d’écrire une instance d’une interface fonctionnelle.

Elles peuvent être vues comme des instances de classes anonymes n’ayant qu’une seule méthode dont les types de retour et des paramètres sont inférés.

  • Syntaxe:

    (paramètres) -> corps

    Une lambda expression est composée :

    • d’un ou plusieurs noms de paramètres (entre parenthèses), éventuellement typés
    • du symbole -> (flèche lambda)
    • d’un corps de fonction. Si le corps se réduit à une seule expression, le return et les accolades sont facultatifs.

Intêret des Lambdas Expressions

  • Pourquoi utiliser les expressions lambda ?
    • Concision: Écriture de code plus compacte et lisible.
    • Programmation fonctionnelle: S’intègrent parfaitement avec les concepts de la programmation fonctionnelle (map, filter, reduce, etc.).
    • Streams: Utilisées avec les streams pour effectuer des opérations sur des collections.
  • Lien avec les interfaces fonctionnelles: Une expression lambda est toujours associée à une interface fonctionnelle. Le compilateur infère le type de l’interface en fonction du contexte.
import java.util.function.*;
DoubleBinaryOperator add = (x, y) -> x + y;  // Additionne deux nombres
DoubleBinaryOperator mul = (x, y) -> x * y;  // Additionne deux nombres

void apply(DoubleBinaryOperator operator, double x, double y) {
    logger.info("Résultat: " + operator.applyAsDouble(x, y));
}

apply(add, 2.0, 3.0);
apply(mul, 2.0, 3.0);
apply((x, y) -> x * x + y * y, 2.0, 3.0);
18:36:25.889 [IJava-executor-1] INFO  notebook -- Résultat: 5.0
18:36:25.906 [IJava-executor-1] INFO  notebook -- Résultat: 6.0
18:36:25.924 [IJava-executor-1] INFO  notebook -- Résultat: 13.0

Les Records (Java 14+)

  • Définition: Un record est une classe qui est principalement utilisée pour contenir des données. Il est idéal pour représenter des données immuables.

  • Pourquoi utiliser des records ?

    • Concisité: Syntaxe plus simple que les classes classiques.
    • Immutabilité: Par défaut immuables, ce qui évite les erreurs liées aux modifications accidentelles.
    • Génération automatique: equals(), hashCode() et toString(), basées sur les champs du record.
    • Égalité: L’égalité est basée sur les valeurs des champs.
public record Person(String nom, int age) {}

//Immutable List of Records
List<Person> personnes = List.of(
    new Person("Alice", 30),
    new Person("Bob", 25)
    // ...
);

Référence vers des méthodes ::

Qu’est-ce que l’opérateur :: ?

C’est un raccourci syntaxique en Java qui permet de créer une référence à une méthode ou un constructeur. Cette référence peut ensuite être utilisée comme si c’était une valeur, par exemple pour l’assigner à une variable ou pour la passer en argument à une méthode.

On peut ainsi définir une méthode qui ajoute des Personnes à une liste qui est créée à partir d’un Supplier qui fournit une liste de Personne en paramètre.

// Define two Supplier<List> lambdas:
Supplier<List> listFactory1 = ArrayList::new;  // Creates a Supplier that provides a new ArrayList
Supplier<List> listFactory2 = LinkedList::new;  // Creates a Supplier that provides a new LinkedList

// Function to initialize a List of Person objects:
public List<Person> initPersonList(Supplier<List> listFactory) {
  // Get a new List instance from the provided Supplier
  List<Person> personList = listFactory.get();

  // Add some Person objects to the list
  personList.add(new Person("Alice", 30));
  personList.add(new Person("Bob", 25));

  // Return the populated list
  return personList;
}

// Demonstrate usage with different Suppliers:
logger.info("List using ArrayList: {}", initPersonList(listFactory1));
logger.info("List using LinkedList: {}", initPersonList(listFactory2));
18:36:35.432 [IJava-executor-1] INFO  notebook -- List using ArrayList: [Person[nom=Alice, age=30], Person[nom=Bob, age=25]]
18:36:35.452 [IJava-executor-1] INFO  notebook -- List using LinkedList: [Person[nom=Alice, age=30], Person[nom=Bob, age=25]]

Un exemple concret : Le tri sans lambdas

Avec une liste d’instances de la classe Person(prenom, age), si la classe redéfini equals et hashcode et implante l’interface Comparable en fonction du prenom on peut trier “canoniquement” la liste par nom avec Collections.sort(personnes); (impossible ici avec un record).

Exemple: Trier en implantant Comparator

  • Une liste d’objets Person (possédant les attributs ‘prénom’ et ‘âge’)
  • Person redéfinit les méthodes equals et hashCode et implémente l’interface Comparable en se basant sur le prénom
  • Collections.sort(personnes) pour trier (pas directement applicable aux records).

Pour trier des records ou par un autre critère par exemple l’âge, on peut implanter l’interface Comparator

public class PersonComparatorByAge 
  implements Comparator<Person> {
    public int compare(Person person1, Person person2) {
        return person1.age()-person2.age();
    }
}

et en fournir une instance à la méthode de classe sort.

List<Person> listByAges = new ArrayList<Person>(personnes);
Collections.sort(listByAges, new PersonComparatorByAge());
listByAges;
[Person[nom=Bob, age=25], Person[nom=Alice, age=30]]

Exemple: Trier avec une classe anonyme

Si le comparateur ne doit être utilisé qu’une fois on peut utiliser une classe anonyme.

//Tri par nom, puis age
List<Person> listByNomAge = new ArrayList<Person>(personnes);
Collections.sort(listByNomAge, new Comparator<Person>() {
    @Override
    public int compare(Person o1, Person o2) {
      return o1.nom()
        .equals(o2.nom())
           ?o1.age()-o2.age()
             :o1.nom().compareTo(o2.nom());
    }
});

listByNomAge;
[Person[nom=Alice, age=30], Person[nom=Bob, age=25]]

Exemple: Trier avec une lambda

// Tri par nom en utilisant une lambda expression
List<Person> listByNames = new ArrayList<Person>(personnes);
Collections.sort(listByNames, (o1, o2) -> o1.nom().compareTo(o2.nom()));
logger.info("Tri par nom: {}",listByNames);

// Tri par nom en utilisant une clé de tri
List<Person> listByAges = new ArrayList<Person>(personnes);
listByNames.sort(Comparator.comparing(Person::nom));
logger.info("Tri par age: {}",listByNames);

// Tri par âge
List<Person> listByAges = new ArrayList<Person>(personnes);
listByAges.sort(Comparator.comparingInt(Person::age));
logger.info("{}",listByAges);
18:37:02.832 [IJava-executor-1] INFO  notebook -- Tri par nom: [Person[nom=Alice, age=30], Person[nom=Bob, age=25]]
18:37:02.876 [IJava-executor-1] INFO  notebook -- Tri par age: [Person[nom=Alice, age=30], Person[nom=Bob, age=25]]
18:37:02.913 [IJava-executor-1] INFO  notebook -- [Person[nom=Bob, age=25], Person[nom=Alice, age=30]]

Exemple: Avec l’interface Comparator

L’interface Comparator fournit de nombreuses méthodes de comparaisons fonctionnelles :

// Sort list of Person objects in reverse order:
// 1. Primary sort by name (nom)
// 2. Secondary sort by age for same names
Collections.sort(listByNomAge, Comparator.comparing(Person::nom)
                                        .thenComparing(Person::age)  // Break ties by age
                                        .reversed());                // Reverse the entire ordering
// Return the sorted list (descending by name then age)
listByNomAge;
[Person[nom=Bob, age=25], Person[nom=Alice, age=30]]

Les interfaces fonctionnelles usuelles

Nous allons voir des exemples avec quatre interfaces courament utilisées : Function, Predicate, Supplier et Consumer.

Function<T,R> (apply)

L’interface Function applique un traitement sur son unique paramètre.

Le nom de l’unique méthode d’une Function est apply.

  • Utilisation :
    • Transformer des données: Convertir des types, appliquer des règles métier.
    • Composer des fonctions: Enchaîner plusieurs transformations.
    • Passer des comportements en paramètre: Rendre du code plus flexible et réutilisable.
  • .andThen() method
    • Takes another Function as parameter
    • Returns a new composed Function
    • Executes functions in order: first the original, then the parameter
  • .compose() executes functions in reverse order:
import java.util.function.Function;
// Email formatter functions
// 1. Basic Functions
Function<String, String> addDomain = name -> name + "@company.com";
Function<String, String> makeValidEmail = String::toLowerCase;

// 2. Composition with andThen
Function<String, String> createEmail = makeValidEmail.andThen(addDomain);

// Test with different inputs
System.out.println("Simple: " + addDomain.apply("john.doe"));
System.out.println("Lowercase: " + makeValidEmail.apply("JOHN.DOE"));
System.out.println("andThen: " + createEmail.apply("John.Doe"));
System.out.println("compose: " + addDomain.compose(makeValidEmail).apply("JOHN.DOE"));
Simple: john.doe@company.com
Lowercase: john.doe
andThen: john.doe@company.com
compose: john.doe@company.com

On peut alors définir des méthodes qui prennent en paramètre une instance d’une interface fonctionnelle.

Ici une méthode qui parcourt la liste de personnes, applique la Function sur le prénom et l’affiche.

public class AfficheurDePersonnes {
  public static void appliqueEtAfficher(Function<String,String> fonction) {
    for (Person p : personnes) System.out.println(fonction.apply(p.nom()));
  }
}    

AfficheurDePersonnes.appliqueEtAfficher(createEmail);
alice@company.com
bob@company.com

On peut aussi directement utiliser une lambda de types compatibles (noter que les types de retour et du paramètre s sont inférés à partir des paramètres de l’interface fonctionnelle attendue).

AfficheurDePersonnes.appliqueEtAfficher(s->s.toUpperCase());
ALICE
BOB

Supplier<T> (get)

Un Supplier est une fonction sans paramètre qui produit une valeur et possède donc un seul paramètre générique (le type de l’objet retourné). C’est une sorte de factory.

Ainsi Supplier<String> fournis des chaines de caratères, Supplier<Integer> des objets représentant des entiers, … .

Utilisation :

  • Création d’objets : Générer des instances d’une classe.
  • Fourniture de valeurs par défaut: Définir des valeurs initiales. Génération de données aléatoires: Produire des nombres aléatoires, des chaînes aléatoires.

Pour les primitifs, il existe des variantes dédiées de l’interface (donc sans paramètres) comme IntSupplier.

import java.util.function.Supplier;
Random random = new Random();

// Different dice types using method reference
Supplier<Integer> D6 = () -> random.nextInt(6) + 1;
Supplier<Double> RandomTemperature = () -> random.nextDouble()*50 - 19;

System.out.println("D6 Roll: " + D6.get());
System.out.println("Random Temperature: " + RandomTemperature.get());

public class Statistique<T extends Number> {
    public static <T extends Number> double average(Supplier<T> supplier, int iterations) {
        double sum = 0.0;
        for (int i = 0; i < iterations; i++) {
            sum += supplier.get().doubleValue();
        }
        return sum / iterations;
    }
}
final int NB = 10;
logger.info("Random dices: {}", Statistique.average(D6, NB)+" (%d rolls)".formatted(NB));
logger.info("Random temperatures: {}", Statistique.average(RandomTemperature, NB)+" (%d temperatures)".formatted(NB));
logger.info("Random 0-100: {}", Statistique.average(()->random.nextInt(100)+1, NB)+" (%d numbers)".formatted(NB));
D6 Roll: 2
Random Temperature: -8.676348852325994
18:44:12.812 [IJava-executor-5] INFO  notebook -- Random dices: 4.8 (10 rolls)
18:44:12.823 [IJava-executor-5] INFO  notebook -- Random temperatures: 11.28101560265375 (10 temperatures)
18:44:12.837 [IJava-executor-5] INFO  notebook -- Random 0-100: 62.3 (10 numbers)

Consumer<T> (accept)

  • Interface fonctionnelle qui accepte une entrée et ne retourne rien
  • Méthode abstraite: void accept(T t)
  • Utilisée pour les opérations terminales
// Create list of names
List<String> names = List.of("Alice", "Bob", "Charlie");
        
// Define two consumers
Consumer<String> upperCase = name -> System.out.print(name.toUpperCase());
Consumer<String> greet = name -> System.out.println(" -> Hello " + name);
       
// Chain consumers with andThen
Consumer<String> process = upperCase.andThen(greet);
        
// Process each name
// The forEach method provides a functional approach to iteration, making code more concise and focusing on what to do with elements rather than how to iterate.
names.forEach(process);
ALICE -> Hello Alice
BOB -> Hello Bob
CHARLIE -> Hello Charlie

Predicate<T> (test)

  • Interface fonctionnelle avec méthode boolean test(T t)
  • Évalue une condition sur un objet de type T
  • Retourne true/false selon le test

L’interface Predicate a un paramètre, le type d’objet sur lequel s’applique le prédicat et renvoie un booléen.

// Méthodes par défaut
and(Predicate<T>)    // Et logique
or(Predicate<T>)     // Ou logique
negate()             // Négation
  • Utilisation typique :

    • Filtrage : Sélectionner des éléments d’une collection répondant à un critère.
    • Validation : Vérifier si une valeur est valide.
    • Conditionnement : Exécuter du code si une condition est vraie.
// Name validation predicates
Predicate<String> startsWithCapital = name -> name.matches("[A-Z][a-z]+");
Predicate<String> hasValidLength = name -> name.length() >= 2 && name.length() <= 30;
Predicate<String> noSpecialChars = name -> name.matches("[A-Za-z]+");

// Combine predicates
Predicate<String> isValidName = startsWithCapital
    .and(hasValidLength)
    .and(noSpecialChars);

// Test
System.out.println("Is 'John' valid? " + isValidName.test("John"));
System.out.println("Is 'j0hn' valid? " + isValidName.test("j0hn"));
Is 'John' valid? true
Is 'j0hn' valid? false

Applications à la liste de personnes

Ces functions sont très utilises pour construire efficacement des traitements sur des séquences de données. Avant le cours, sur les flux de données (Stream). Voilà une illustration pour construire un traitement générique d’une liste d’élément: filtrage en fonction d’un condition passée en paramètre, transformation des éléments de la liste et application d’un traitement a!ux résultats.

import java.util.function.Predicate;
public class Processor<T, R> {
    public void run(Iterable<T> iterable, Predicate<T> predicate,  Function<T, R> function, Consumer<R> consumer) {
        iterable.forEach(t -> {
            if (predicate.test(t)) {
                R result = function.apply(t);
                consumer.accept(result);
            }
        });
    }
}

et utiliser une lambda expression pour le prédicat et une référence vers une fonction pour afficher le résultat

List<Person> people = List.of(
   new Person("Alice", 25),
   new Person("Bob", 17),
   new Person("Charlie", 30),
   new Person("Alfred", 16)
);

// Multiple predicates
Predicate<Person> isAdult = p -> p.age() >= 18;
Predicate<Person> nameStartsWithA = p -> p.nom().startsWith("A");

// Multiple functions
Function<Person, String> formatName = p -> p.nom().toUpperCase();
Function<String, String> addTitle = p -> "Mr/Ms. " + p;

// Multiple consumers
Consumer<String> print = System.out::println;
Consumer<String> log = s -> logger.info(s);

// Process the data
Processor<Person, String> processor = new Processor<>();
processor.run(people, 
   isAdult
      .and(nameStartsWithA),
   formatName
      .andThen(addTitle),
   print
      .andThen(log)
);
Mr/Ms. ALICE
18:46:39.465 [IJava-executor-6] INFO  notebook -- Mr/Ms. ALICE

La même classe générique Processor peut être utilisée pour filtrer des entiers.

// Sample data
List<Integer> numbers = List.of(1, 15, 30, 45, 60, 75, 90);

// Multiple predicates
Predicate<Integer> isGreaterThan30 = n -> n > 30;
Predicate<Integer> isDivisibleBy15 = n -> n % 15 == 0;

// Multiple functions
Function<Integer, String> formatNumber = n -> String.format("Number: %d", n);
Function<String, String> addComment = s -> s + " (multiple of 15)";

// Multiple consumers
Consumer<String> print = System.out::println;
Consumer<String> log = s -> logger.info(s);

// Process the data
Processor<Integer, String> processor = new Processor<>();
processor.run(numbers, 
    isGreaterThan30
        .and(isDivisibleBy15),
    formatNumber
        .andThen(addComment),
    print
        .andThen(log)
);
Number: 45 (multiple of 15)
18:46:52.825 [IJava-executor-6] INFO  notebook -- Number: 45 (multiple of 15)
Number: 60 (multiple of 15)
18:46:52.825 [IJava-executor-6] INFO  notebook -- Number: 60 (multiple of 15)
Number: 75 (multiple of 15)
18:46:52.825 [IJava-executor-6] INFO  notebook -- Number: 75 (multiple of 15)
Number: 90 (multiple of 15)
18:46:52.825 [IJava-executor-6] INFO  notebook -- Number: 90 (multiple of 15)