Paramétrage du comportement du code


  • Evolution inévitable des besoins/exigences des clients (personnes)
  • Paramétrage du comportement : définition de blocs de code exécutables au besoin, par exemple en les transmettant en paramètres de fonctions
  • Illustration : filtrage d'éléments de collection selon un critère
    • ajouts itératifs à une collection jouant le rôle d'accumulateur ❶ des éléments de la liste complète qui remplissent le critère particulier recherché ❷
enum Département {
	EIE, IIM, MME, PSO
}

public static List<ElèvePops> filtreElèvesIIM(List<ElèvePops> élèves) {
	List<ElèvePops> élèvesIIM = new ArrayList<>(); // ❶
	for (ElèvePops élève : élèves) { 
		if (élève.getSpécialité().equals(Département.IIM)) { // ❷
			élèvesIIM.add(élève);
		}
	}
	return élèvesIIM; 
}

  • On peut paramétrer la spécialité pour avoir un filtre plus générique ❶ ; on filtre alors sur cette valeur de filtre transmise en paramètre ❷ :
public static List<ElèvePops> filtreElèvesParSpécialité(List<ElèvePops> élèves, Département spécialité) { // ❶
	List<ElèvePops> élèvesFiltrés = new ArrayList<>(); 
	for (ElèvePops élève : élèves){ 
		if (élève.getSpécialité().equals(spécialité)) { // ❷
			élèvesFiltrés.add(élève);
		}
	}
	return élèvesFiltrés; 
}

on a donc amélioré le moyen d'obtenir ce filtrage :

List<ElèvePops> élèvesIIM = filtreElèvesParSpécialité(tousElèves, Département.IIM);

  • On peut répéter ce principe pour obtenir des filtrages particuliers, par exemple les élèves qui ont validé au moins un certain nombre d'ECTS :
public static List<ElèvePops> filtreElèvesMinNombreECTSValidés(List<ElèvePops> élèves, int nombreMinimalECTSValidés) { // ❶
	List<ElèvePops> élèvesFiltrés = new ArrayList<>(); 
	for (ElèvePops élève : élèves) { 
		if (élève.getNombreECTSValidés() >= nombreMinimalECTSValidés) { // ❷
			élèvesFiltrés.add(élève);
		}
	}
	return élèvesFiltrés; 
}
  • Problème : on se répète beaucoup

  • Solution possible : fournir des surcharges de la méthode de filtrage telles qu'elles puissent couvrir différentes combinaisons de paramètres de filtrage, ex. :
List<ElèvePops> élèvesFiltrés = filtreElèves(tousElèves, Département.IIM, 50, Département.ESSONNE, ...);

Introduction de classes de prédicats

  • On peut imaginer des filtres individuels qui testent simplement une propriété : des prédicats, fonctions qui retournent une valeur booléenne
  • Modélisation par une simple interface avec une (unique) méthode de test pour le prédicat, ex. :
public interface PrédicatElèvePops { 
	public boolean test(ElèvePops élève);
}

ce qui permet de définir un certain nombre de classes de prédicats utiles, ex. :

public class PrédicatElèveIIM implements PrédicatElèvePops {
	public boolean test(ElèvePops élève) {
		return élève.getSpécialité().equals(Département.IIM);
	} 
}
Cela permet donc de définir une collection de prédicats, de type PrédicatElèvePops ​, qui peuvent donc être choisis à l'exécution

  • La méthode de filtrage peut maintenant simplement accepter comme paramètre une instance de PrédicatElèvePops ​ ❶, et partager le même code d'itération sur la collection pour tous les filtres
public static List<ElèvePops> filtreElèves(List<ElèvePops> élèves, PrédicatElèvePops filtre) { // ❶
	List<ElèvePops> élèvesFiltrés = new ArrayList<>();
		for (ElèvePops élève : élèves) { 
			if (filtre.test(élève)) {
				élèvesFiltrés.add(élève);
			}
		}
	}
	return élèvesFiltrés; 
}
séparation entre le code d'itération et le/les filtrages particuliers
  • On permet ainsi de paramétrer le comportement (behavior parameterization) de la méthode de filtrage, ex. :
List<ElèvePops> élèvesBoursiers = filtreElèves(tousElèves, new PrécicatElèveBoursier());

Moins de verbosité avec des classes anonymes

  • Afin de réduire la verbosité de l'approche, le recours à des classes anonymes peut s'avérer utile, puisque celles-ci permettent la définition et l'instanciation simultanée d'un objet
List<ElèvePops> élèvesIIM = filtreElèves(
	tousElèves,
	new PrédicatElèvePops() {
		public boolean test(ElèvePops élève) {
			return élève.getSpécialité().equals(Département.IIM);
		}
	} );
  • Le recours à des classes anonymes nous éviterait donc la création de nombreuses classes pour des cas de filtrages très ponctuels

Here come lambda expressions Java_logo.pngJava 8

  • Les expressions lambda (lambda expressions) offrent une syntaxe très compacte pour la formulation de tels prédicats :
List<ElèvePops> élèvesIIM = filtreElèves(
	tousElèves, 
	(Elève élève) -> élève.getSpécialité().equals(Département.IIM)
	);
  • Cette notation offre une solution élégante pour traiter le problème du filtrage de manière générique, en définissant les prédicats comme un type paramétré :
public interface Prédicate<T> { 
	boolean test(T t);
}

en paramétrant également la méthode de filtrage :

public static <T> List<T> filtre(List<T> liste, Predicate<T> prédicat) {
	List<T> listeFiltrée = new ArrayList<>(); 
	for (T e: liste) { 
		if (prédicat.test(e)) { 
			listeFiltrée.add(e);
		}
	} 
	return listeFiltrée;
}

ce qui permet alors de filtrer tout ce que l'on souhaite, en fournissant une expression lambda adaptée, ex. :

List<ElèvePops> élèvesIIM = filtre(
	tousElèves, 
	(Elève élève) -> élève.getSpécialité().equals(Département.IIM)
	);
List<JeuVidéo> jeuxNonInstallés = filtre(
	bibliothèqueJeu, 
	(Jeu jeu) -> jeu.nonInstallé()
	);

Exemple : le retour des Comparator

  • Le tri d'une collection s'effectue en fournissant par exemple à la méthode Collection.sort ​ une instance de java.util.Comparator<T> de définition :
public interface Comparator<T> { 
	int compare(T o1, T o2); 
}
  • Sans les expressions lambda, on pouvait créer un type spécifique pour définir un tri, ou bien de manière plus concise et locale avoir recours à une classe anonyme :
tousElèves.sort(new Comparator<ElèvePops>() { 
	public int compare(ElèvePops e1, ElèvePops e2) { 
		return e1.getNomPrénom().compareTo(e2.getNomPrénom());
	} 
});
  • Avec l'introduction des expressions lambda, l'écriture devient simplement :
tousElèves.sort( (ElèvePops e1, ElèvePops e2) -> e1.getNomPrénom().compareTo(e2.getNomPrénom()) );

Expressions lambda

  • Une expression lambda est une notation compacte d'une fonction anonyme qui peut être transmise pour paramétrer du code

    • elle n'a pas de nom (mais on pourra l'affecter à des variables, la passer comme paramètre)
    • elle n'est pas membre d'un type
    • elle a une liste de paramètres, un corps, un type de retour
    • elle peut propager des exceptions
  • Syntaxe d'une expression lambda :

(paramètres) -> expression
(paramètres) -> { instructions; }
Cas particulier

Une expression lambda avec une seule instruction (retournant void), bien qu'elle ne soit pas une expression, peut être utilisée sans les accolades, ex: () -> System.out.println("une instruction");

  • Exemples de cas d'usages :
    • expression booléenne : (List<ElèvePops> élèves) -> élèves.isEmpty()
    • création d'objets : () -> new Examen()
    • traitement d'un objet : (ElèvePops e) -> { System.out.println(e.toString()); }
    • interrogation d'un objet : (Adresse a) -> a.getPays()
    • combinaison de valeurs : (int a, int b) -> a * b ​
    • comparaison d'objets : (ElèvePops e1, ElèvePops e2) -> e1.getNomPrénom().compareTo(e2.getNomPrénom())

Interfaces fonctionnelles (functional interfaces)

  • Les expressions lambda peuvent être utilisées là où l'on attend une interface fonctionnelle (celles-ci étaient largement utilisées dans les API pré-Java 8)
  • Une interface fonctionnelle définit une unique méthode abstraite, ex. :
@FunctionalInterface
public interface Predicate<T> { 
	boolean test(T t);
}

@FunctionalInterface
public interface Comparator<T> { 
	int compare(T o1, T o2); 
}
L'annotation @FunctionalInterface est recommandée pour permettre au compilateur de s'assurer que l'intention est bien respectée.
Une interface est-elle une interface fonctionnelle si elle a exactement une méthode abstraite, mais un certain nombre de méthodes default ?
Donc :

les expressions lambda permettent de fournir l'implémentation de la méthode abstraite d'une interface fonctionnelle de manière compacte (inline) : on les manipule comme des instances d'une implémentation concrète d'une interface fonctionnelle.


Types d'interfaces fonctionnelles

  • L'API standard définit un certain nombre d'interfaces fonctionnelles particulières (dans le package java.util.function ​)

  • Interfaces importantes :

    • Predicate<T>
    • Consumer<T>
    • Supplier<T>
    • Function<T, R>

Interface Predicate<T>

  • Predicate<T> définit une méthode abstraite qui accepte un objet d'un type générique et retourne un booléen
@FunctionalInterface
public interface Predicate<T> { 
	boolean test(T t); 
} 
  • Exemple d'utilisation :
public <T> List<T> filtre(List<T> liste, Predicate<T> prédicat) { 
 	List<T> résultat = new ArrayList<>(); 
 	for (T t: liste) { 
 		if (prédicat.test(t)) { 
 			results.add(t); 
 		}
 	} 
 	return results; 
 }

 Predicate<String> prédicatChaîneNonVide = (String s) -> !s.isEmpty();
 List<String> chaînesNonVides = filtre(chaînes, prédicatChaîneNonVide);
  • Variante : BiPredicate<T, U>

Interface Consumer<T>

  • Consumer<T> définit une méthode abstraite qui prend un objet d'un type générique et ne retourne pas de résultat (traitement sur l'objet)
@FunctionalInterface
public interface Consumer<T> { 
	void accept(T t); 
}
  • Variantes :

    • IntConsumer, LongConsumer, DoubleConsumer
    • BiConsumer<T, U>,
  • Exemple d'utilisation :

public <T> void forEach(List<T> liste, Consumer<T> c) { 
	for(T t: liste) { 
		c.accept(t); 
	}
} 

forEach( Arrays.asList("iim", "eie", "pso", "mme"), (String s) -> System.out.println(s) );

Interface Supplier<T>

  • Supplier<T> définit une méthode abstraite qui ne prend pas de paramètre et qui retourne un objet d'un type générique
@FunctionalInterface
public interface Supplier<T> { 
	T get(); 
}
  • Variantes : BooleanSupplier,IntSupplier, LongSupplier, DoubleSupplier

  • Exemple d'utilisation :

Supplier<Integer> générateurAléatoire = () -> new Random().nextInt(100);  
// ...
int randomNumber = générateurAléatoire.get();  

Interface Function<T, R>

  • Function<T, R> définit une méthode qui prend un objet d'un type générique et retourne un autre objet d'un type générique (correspondance (mapping) entre objets)
@FunctionalInterface
public interface Function<T, R> { 
	R apply(T t); 
} 
  • Exemple d'utilisation :
public <T, R> List<R> map(List<T> liste, Function<T, R> fonction) { 
	List<R> résultat = new ArrayList<>(); 
	for (T t: liste) { 
		résultat.add(fonction.apply(t));
	} 
	return résultat;
}

List<Integer> liste = map( Arrays.asList("iim", "eie", "pso", "mme"), (String s) -> s.length() );
  • Variantes :
    • IntFunction<R>, IntToDoubleFunction, IntToLongFunction, ...
    • ToIntFunction<T>, ToDoubleFunction<T>, ToLongFunction<T>
    • BiFunction<T, U, R>, ToIntBiFunction<T, U>, ...

Propagation d'exceptions dans les interfaces fonctionnelles

  • Nous avons vu que les expressions lambda peuvent propager des exceptions
  • Les signatures des méthodes des interfaces fonctionnelles rencontrées précédemment ne signalent toutefois pas d'exceptions sous contrôle (checked exceptions)

Expressions lambda

Correspondance entre expressions lambdas et interfaces fonctionnelles

  • Une expression lambda se substitue donc à une interface fonctionnelle, mais elle n'indique pas quelle interface fonctionnelle elle implémente
  • Il est donc nécessaire que le compilateur puisse déterminer le type cible d'une expression lambda (target type)
  • Exploitation par le compilateur du contexte de l'expression : variable d'affectation de l'expression, paramètre pour lequel l'expression est utilisée, transtypage explicite (cast)

Inférence des types des paramètres des expressions lambdas

  • Si le compilateur a accès aux types des paramètres d'une expression lambda, ceux-ci peuvent être omis (type inference)
    Exemple

    Par exemple, étant donnée l'interface fonctionnelle :

    public interface PrédicatElèvePops { 
    	public boolean test(ElèvePops élève);
    }
    

    et la méthode :

    public static List<ElèvePops> filtreElèves(List<ElèvePops> élèves, PrédicatElèvePops filtre)
    

    l'appel de la méthode suivant :

    List<ElèvePops> élèvesIIM = filtreElèves(
    	tousElèves, 
    	(Elève élève) -> élève.getSpécialité().equals(Département.IIM)
    	);
    

    peut omettre le type du paramètre de l'expression lambda :

    List<ElèvePops> élèvesIIM = filtreElèves(
    	tousElèves, 
    	élève -> élève.getSpécialité().equals(Département.IIM)
    	);
    
Est-il préférable d'omettre les types de paramètres lorsque c'est possible ?

Variables sans nom (unnamed variables) Java_logo.pngJava 22

  • Dans le cas où une expression lambda a des paramètres qui ne sont pas utilisés dans sa définition, possibilité d'utiliser une variable sans nom avec_ ​
    • ex. :(_, _) -> System.out.println("...")
Champ d'application des variables sans nom

Outre les paramètres d'expressions lambda, elles peuvent être utilisées pour :

  • try-with-resource
  • variables de boucles for
  • paramètres d'exceptions

Capture de valeurs dans les expressions lambdas

  • Les expressions lambdas peuvent, outre leurs paramètres, accéder à certaines variables déclarées hors de leur définition (outer scope)
    • champs d'instances
    • champs statiques
    • variables locales réellement final ( effectively final)
    • variables locales non réellement final

donc par exemple, on peut capturer dans le corps de l'expression lambda ❶ ci-dessous la variable locale final département ​ :

final Département département = Département.IIM;
PrédicatElèvePops filtre = (Elève élève) -> élève.getSpécialité().equals(département); // ❶
Java permet-il l'expression de fermetures (closures)
  • Une fermeture est une fonction qui peut référencer des variables déclarées hors d'elle sans restrictions.
  • Les expressions lambda de Java sont contraintes : elles ne peuvent pas modifier le contenu de variables locales (car n'ont donc accès qu'à des variables locales réellement final) : elles peuvent réaliser des fermetures sur des valeurs, et non sur des variables.
  • Les variables d'instances sont elles accessibles via la capture de la variable locale final this.


Les références de méthodes (method references)

  • Les références de méthodes permettent de transmettre des méthodes existantes (ce que l'ont fait avec les expressions lambda, méthodes anonymes)
  • Cela dispense d'avoir à écrire une lambda qui se limiterait au seul appel d'une méthode particulière
    La syntaxe générale est : Type::méthode ​

    l'expression lambda (Etudiant étudiant) -> étudiant.getID()
    est donc ramenable à Etudiant::getID

Exemples pour la transmission de paramètres
  • méthode d'instance sans paramètre
    • lambda :(Etudiant étudiant) -> étudiant.getID()
      • Etudiant::getID
  • méthode d'instance avec paramètre
    • lambda : (étudiant, ue) -> étudiant.valideUE(ue)
      • Etudiant::valideUE
    • lambda : (ue) -> this.valideUE(ue)
      • this::valideUE

Types de références de méthodes

Types
  • références à des méthodes static
    • lambda : (arguments) -> Classe.thodeStatique(arguments)
      • Classe::méthodeStatique ​
      • ex. : Math::cos
  • références à des méthodes d'instance
    • lambda : (argument0, autresArguments) -> argument0.thodeInstance(autresArguments)
      • Classe::méthodeInstance ​
      • ex. : Etudiant::getID
  • références à des méthodes d'instance d'un objet existant ou d'une expression
    • lambda : (arguments) -> expression.thodeInstance(arguments)
      • expression::méthodeInstance ​
      • ex. : élève::getID
  • références à des constructeurs
    • lambda : (arguments) -> new Classe(arguments)
      • Classe::new
      • ex. : transmission d'un constructeur à une méthode ❶ qui instanciera de futurs objets de cette classe ❷. Le type du paramètre est une fonction qui prend en paramètre un entier et retourne un nouvel objet du type du constructeur : Function< Integer, SalleDeCours>
List< Integer> capacités = Arrays.asList(25, 25, 50);
List< SalleDeCours> salles = map(capacités, SalleDeCours::new); // ❶
List< SalleDeCours> map(List< Integer> capacités, Function<Integer, SalleDeCours> constructeur) { // ❸
	List< SalleDeCours> résultat = new ArrayList< >();
	for (Integer capacité : capacités) {
		résultat.add(constructeur.apply(capacité)); // ❷
	}
	return résultat;
}

Composition d'expressions lambdas

  • Des expressions lambdas plus complexes peuvent être obtenues par composition d'expressions
  • Méthodes particulières de certaines classes de l'API standard
Composition de Comparator
  • méthode qui retourne un Comparator fondé sur une Function qui utilise une valeur des objets pour leur comparaison :
    • Comparator<Etudiant> comparateur = Comparator.comparing(Etudiant::getAgeEnJours);
  • inversion de l'ordre de tri par composition :
    • Comparator.comparing(Etudiant::getAgeEnJours).reversed()
  • inversion de l'ordre de tri par composition :
    • Comparator.comparing(Etudiant::getAgeEnJours).reversed()
  • composition d'un tri secondaire:
    • Comparator.comparing(Etudiant::getAgeEnJours).thenComparing(Etudiant::getID)
Composition de Predicate
  • méthodes de compositionnegate ​, and ​ et or ​
    Predicate< Etudiant> étudiantsIIM = (Elève élève) -> élève.getSpécialité().equals(Département.IIM);
    Predicate< Etudiant> étudiantsNonIIM = étudiantsIIM.negate();
    Predicate< Etudiant> étudiantsNonIIMAnglais = étudiantsNonIIM
    	.and(étudiant -> !étudiant.mobilitéOK())
    	.or(étudiant -> étudiant.scoreTOEIC < 500);
    
Composition de Function
  • méthode de compositioncompose ​ : calcule tout d'abord la fonction passée en paramètre (g(x)), puis la fonction utilisée (f(x)), soit f(g(x))
    Function< Integer, Integer> f = x -> x + 1;
    Function< Integer, Integer> g = x -> x * 2;
    Function< Integer, Integer> h = f.compose(g);
    int résultat = h.apply(1); // f(g(1)) = f(2) = 3
    
  • méthode de composition andThen ​ :calcule tout d'abord la fonction utilisée (f(x)), puis la fonction passée en paramètre (g(x)), soit g(f(x))
    • permet de définir des suites (pipelines) d'applications de fonction, ex. :
    Function< String, String> corrigeTexte = TexteUtils::corrigeTexte;
    Function< String, String> transformations = 
    	corrigeText
    	.andThen(TexteUtils::simplifieTexte)
    	.andThen(TexteUtils::ajouteAnnotations);
    

Exploitation des expressions lambdas pour des traitements déclaratifs

  • Supposons que l'on souhaite afficher les noms des élèves n'ayant pas validé le TOEIC, en les ordonnant de la plus grande dette (écart au seuil de validation) à la plus petite
  • Une approche impérative classique consisterait à :
    • construire une liste des élèves sans TOEIC
    • trier cette liste par score au TOEIC croissant
    • construire une liste de résultats au format attendu

L'API des Streams Java_logo.pngJava 8

Notion de stream
  • séquence non modifiable d'éléments provenant d'une source sur laquelle des traitements de données sont possibles : interface java.util.stream.Stream
  • les sources peuvent correspondre à des collections ou des ressources d'entrée-sortie (IO)
  • les traitements peuvent être effectués de façon séquentielle ou parallèle
  • de nombreuses opérations sur les streams retournent une instance de Stream, permettant l'enchaînement des traitements, possiblement de manière paresseuse (lazy)
  • l'opération d'itération sur les éléments d'une stream est réalisée de façon transparente
Important
  • les collections représentent les données, les streams portent sur les traitements
  • les éléments d'une stream sont rendus disponibles de façon paresseuse lorsqu'il sont réclamés
  • une stream ne peut être parcourue qu'une seule fois ❶ (sinon ❷ : java.lang.IllegalStateException) :
Stream< String> stream = Arrays.asList("EIE", "IIM", "MME", "PSO").stream();
stream.forEach(System.out::println); // ❶
stream.forEach(System.out::println); // ❷

Opérations sur les streams

  • Il existe 2 types d'opérations sur les streams : terminales et intermédiaires
Opérations terminales
  • produisent un résultat (possiblement void, ex. forEach ​)
  • ferment une stream
  • opérations importantes :
    • collect ​ : réduit la stream pour obtenir une collection
    • count ​ : retourne le nombre d'éléments d'une stream (long)
    • forEach ​ : applique une expression lambda pour chaque élément d'une stream
Opérations intermédiaires
  • peuvent être connectées pour des traitements enchaînés
  • aucun traitement tant qu'une opération terminale n'est pas appelé (traitement paresseux) : permet à une opération terminale de réduire des opérations intermédiaires en une seule passe
  • opérations importantes :
    • filter ​ : filtre les éléments d'une stream à l'aide de Predicate<T> (retourne boolean), retourne Stream<T>
    • limit ​ : conserve un certain nombre d'éléments, retourne Stream<T>
    • distinct ​ : consever les éléments uniques, retourne Stream<T>
    • map ​ : établit une correspondance avec les éléments d'une stream à l'aide de Function<T, R> (retourne R), retourne Stream<R>
    • sorted ​ : tri les éléments d'une stream à l'aide de Comparator<T> (retourne int), retourne Stream<T>


Mise en évidence du traitement paresseux des opérations intermédiaires

Qu'affiche le code ci-dessous ?
record Elève(String name, int toeicScore) { }  
  
List<Elève> élèves = Arrays.asList(  
        new Elève("E1", 500),  
        new Elève("E2", 700),  
        new Elève("E3", 200),  
        new Elève("E4", 600),  
        new Elève("E5", 100),  
        new Elève("E6", 800)  
);  
  
List< String> résultat = élèves.stream()  
        .filter( e -> e.toeicScore() >= 600  )  
        .map( e -> e.name + " " + e.toeicScore())  
        .limit(2)  
        .toList();  
  
résultat.stream().forEach(e -> System.out.println(e));

Opérations intermédiaires : filtrer les éléments d'une stream

  • filter ​ : filtre les éléments d'une stream à l'aide de Predicate<T>​ ​ (retourne boolean​ ​), retourne Stream<T>
List<Elève> élèvesAvecToeic = élèves.stream()  
        .filter(Elève::aSonToeic)  
        .toList();
  • Variante distinct ​ : filtre une stream pour ne conserver que des éléments uniques (se fonde sur les méthodes hashcode ​ et equals ​ du type des éléments)
List<Integer> scoresAuToeic = élèves.stream()  
        .map(e -> e.toeicScore())  
        .distinct()  
        .sorted()  
        .toList();

Opérations intermédiaires : découpage de stream

  • takeWhile ​ : conserve les éléments d'une stream tant que Predicate<T>​ ​ est vrai, retourne Stream<T>
  • dropWhile ​ : conserve tous les éléments d'une stream après qu'un
    Predicate<T>​ ​ est faux, retourne Stream<T>
  • limit(nombre) : conserve au plus les nombre ​ premiers éléments d'une stream
  • skip(nombre) : conserve tous les éléments d'une stream après les nombre ​ premiers

Opérations intermédiaires : correspondances entre éléments

  • map ​ : établit une correspondance avec les éléments d'une stream à l'aide de Function<T, R> (retourne R), retourne Stream<R>
  • flatMap ​ : établit une correspondance avec les éléments d'une stream avec une autre stream puis concatène les streams résultantes en une unique stream
Motivation et exemple pour flatMap ​
  • On voudrait connaître tous les cours suivis par au moins un élève d'une classe
    - on suppose que la classe Elève ​ dispose d'une méthode List<Cours> getCoursSuivis()
Stream< List< Cours>> stream = élèves.stream() // Stream< Elève>
	.map(élève -> élève.getCoursSuivis()); // Stream< List< Cours>> 

stream.forEach(cours -> System.out.println(cours));
  • On obtient une Stream<List<Elève>>, sur lequel on ne peut pas directement extraire la liste des cours suivis (sans répétition), ex.
[CS101, CS203, CS440, CS601]
[CS101, CS201, CS440, CS605]
[CS101, CS201, CS202, CS402, CS711]
...
  • La méthode flatMap ​ remplace chaque valeur d'une stream, puis concatène les différentes streams en une seule (flattening) : on peut ici utiliser List::stream pour obtenir une stream pour chaque List<Cours>
Stream< List< Cours>> stream = élèves.stream() // Stream< Elève>
	.map(élève -> élève.getCoursSuivis()) // Stream< List< Cours>> 
	.flatMap(List::stream) // Stream< Cours>
	.sorted() // on suppose que Cours est Comparable< Cours>
	.distinct();

stream.forEach(cours -> System.out.println(cours));
  • On obtient cette fois une liste aplatie, puis triée et dédoublonnée, ex.
[CS101, CS201, CS202, CS203, CS402, CS440, CS601, CS605, CS711]

Opérations terminales : recherche

  • anyMatch ​ : indique si au moins un élément d'une stream correspond à un prédicat
  • allMatch ​ : indique si tous les éléments d'une stream correspondent à un prédicat
  • noneMatch ​ : indique si aucun élément d'une stream ne correspond à un prédicat
  • findAny ​ : extrait un élément quelconque d'une stream

Opérations terminales : réduction

  • Les opérations de réduction (reducing) combinent les éléments d'une stream de façon répétée pour produire une unique valeur : méthode reduce ​
  • Ces opérations permettent un traitement en parallèle () si les expressions lambda qu'elles utilisent ne modifient pas d'état et utilisent des opérations associatives et commutatives pour permettre une exécution dans un ordre quelconque
  • Ces opérations utilisent un état interne pour l'accumulation du résultat
Calcul de la valeur minimale d'une stream d'entiers
Optional< Integer> min = nombres.stream()
	.reduce( (e1, e2) -> e1 < e2 ? e1 : e2 );

ou avec une référence de méthode :

Optional< Integer> min = nombres.stream()
	.reduce(Integer::min);
Calcul de la somme des entiers d'une stream
  • Surcharge retournant un Optional<T> (si la stream est vide) :
Optional< Integer> somme = nombres.stream()
	.reduce( (e1, e2) -> e1 + e2 );

ou surcharge qui prend une valeur initiale :

Optional< Integer> somme = nombres.stream()
	.reduce(0, Integer::sum);


Construction de streams

  • On peut construire une stream à partir de valeurs fournies
    • Stream<String> stream = Stream.of("EIE", "IIM", "MME", "PSO");
    • Stream.empty()
    • Stream<String> user = Stream.ofNullable(System.getProperty("user"); (retourne Stream.empty() le cas échéant)
    • Arrays.stream(tableauEntiers)
  • à partir de fichiers
    • méthodes de java.nio.file.Files qui retournent des streams, ex.
long nombreMotsUniques = 0;
try(Stream<String> lignes = Files.lines(Paths.get("fichier.txt"))) {
	nombreMotsUniques = lignes
		.flatMap(ligne -> Arrays.stream(ligne.split(" ")))
		.distinct()
		.count();
}
catch (IOException) { ... }
  • à partir d'une fonction : streams non limitées / infinies
    • méthode iterate ​ qui produit itérativement de nouvelles valeurs indéfiniment (possibilité de combiner avec limit(n)), ex.
      • Stream.iterate(0, n -> n + 2).limit(10).forEach(System.out::println);
      • surcharge qui prend un prédicat : IntStream.iterate(n -> n < 10, n -> n + 2).forEach(System.out::println);
    • méthode generate ​ qui prend une expression lambda de type Supplier<T>, ex.
      • Stream.generate(Math::random).limit(10).forEach(System.out::println);

Collecte de données en sortie de stream

  • Un collecteur est une opération terminale sur les streams qui définit une réduction des éléments de la stream
  • L'interface Collector, transmise à collect ​, applique une fonction de transformation aux éléments de la stream et accumule les résultats dans une collection
    • ex. toList()
  • Les collecteurs ont trois types d'usage :
    • réductions
    • regroupements
    • partitionnements

Collecteurs sur streams de réduction

  • Recherche d'extrema : Collectors.maxBy ​, Collectors.minBy ​
  • Calculs : Collectors.summingInt ​ (Collectors.SummingLong...), Collectors.averagingInt ​, Collectors.summarizingInt ​
    • ex. Collectors.summarizingInt ​ fournit un objet de type IntSummaryStatistics qui donne accès aux valeurs somme, moyen, minimum et maximum sur une stream d'entiers, ex. : IntSummaryStatistics s = élèves.stream().collect(summarizingInt(Eleve::getScoreToeic));
  • Construction de chaînes : Collectors.joining ​ (utilisation efficace en interne d'un objet StringBuilder)
    • ex. String tousElèves = élèves.stream().map(Elèves::getNom).collect(joining(", "));
  • Opérations de réduction : Collectors.reducing ​ (dont un argument agrège deux éléments du même type en un seul)
    • ex. Optional<Elève> élèveAvecLeMeilleurScoreAuToeic = élèves.stream().collect(reducing((e1, e2) -> e1.getScoreToeic() > e2.getScoreToeic() ? e1 : e2));
Coexistence des méthodes collect ​ et reduce ​

La méthode collect ​ est conçue pour modifier un accumulateur pour calculer son résultat, de façon compatible avec une traitement parallèle par les streams.


Collecteurs sur streams de regroupement

  • Les opérations de regroupement peuvent être complexes à écrire en style impératif
  • Regroupements sur streams : groupingBy ​ fondée sur une fonction de classification
    • ex. Map<Département, List<Elève> élèvesParDépartement = élèves.stream().collect(groupingBy(Elève::getDépartement));
  • Possibilité de fournir une fonction de classification par expression lambda ❶, ex.
public enum SituationMobilité { VALIDE, EN_COURS, A_VENIR}
Map<SituationMobilité, List<Elève>> élèvesParSituationMobilité = élèves.stream()
	.collect(groupingBy(
		élève -> { // ❶
			if (élève.mobilitéEffectuée()) return SituationMobilité.VALIDE;
			else if (élève.mobilitéEnCours()) return SituationMobilité.EN_COURS;
			else return SituationMobilité.A_VENIR;
		}
	));
  • Possibilité de combinaison pour des regroupements multi-niveaux ❶, au moyen d'un second paramètre à groupingBy ​, ex.
Map<Département, Map<SituationMobilité, List<Elève>> élèvesParDépartementEtSituationMobilité = élèves.stream()
	.collect(groupingBy(Elève::getDépartement), 
		groupingBy( // ❶
			élève -> {
				if (élève.mobilitéEffectuée()) return SituationMobilité.VALIDE;
				else if (élève.mobilitéEnCours()) return SituationMobilité.EN_COURS;
				else return SituationMobilité.A_VENIR;
			}
		)
	);
  • Possibilité de fournir en second paramètre à groupingBy ​ tous types de collecteur
    • ex. Map<Département, Long> nombresDElevesParDépartement = élèves.stream().collect(groupingBy(Elèves::getDépartement, counting())) ;


Collecteurs sur streams de partitionnement

  • Le partitionnement est une opération de regroupement qui définit deux groupes (type Boolean)
  • Partitionnement sur streams : partitioningBy ​ fondée sur une fonction de partitionnement
    • ex. Map<Boolean, List<Elève> élèvesAvecMobilitéValidée = élèves.stream().collect(partitioningBy(Elève::modilitéEffectuée));
  • permet contrairement à filter ​ de conserver tous les éléments, pas uniquement ceux qui correspondent à un prédicat


Streams parallèles

  • Une stream parallèle sépare ses éléments en plusieurs parties, et effectue le traitement sur chacun d'elle sur un thread propre, puis regroupe les résultats (fondée sur le cadre fork/join)
  • Transformation en stream parallèle avec parallel()
Exemple : calcul de la somme des entiers de 1 à n
  • en séquentiel :
long sommeEnSéquentiel(long n) {
	return Stream.iterate(1L, i -> i + 1)
		.limit(n)
		.reduce(0L, Long::sum);
}
  • en parallèle ❶ :
long sommeEnParallèle(long n) {
	return Stream.iterate(1L, i -> i + 1)
		.limit(n)
		.parallel() // ❶
		.reduce(0L, Long::sum);
}
  • Séparation (transparente) de la stream en plusieurs parties, réduction de chacune de ces parties en parallèle, puis même opération de réduction sur les résultats pour chaque partie
Configuration du nombre de threads utilisés par les streams parallèles
  • par défaut, utilisation d'un nombre de threads correspondant aux nombre de cores disponibles :
    Runtime.getRuntime().availableProcessors()
  • configuration par thread parallèle impossible, mais possibilité de configuration globale :
    System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "4");



Conditions favorables aux streams parallèles

Quelques critères
  • La parallélisation est mal adaptée en cas d'accès partagé à des données mutables (accès concurrents)
  • Sur de faibles volumes de données, les traitements parallèles induisent un coût qui ne sera souvent pas compensé
  • Les streams pour des types primitifs (ex. IntStream) évitent de coûteux autoboxing / unboxing
  • Certains opérations sont mal adaptées aux traitements parallèles : par exemple findFirst ​ qui repose sur l'ordre des éléments est coûteux, alors que findAny ​ le sera beaucoup moins
Le recours à des microbenchmarks peut s'avérer utile pour s'assurer de la pertinence d'utilisation de streams parallèles
  • Il est à la fois pertinent d'utiliser au mieux la capacité de traitement d'une machine en occupant autant que possible l'ensemble de ses processeurs
  • Mais il faut anticiper des accès concurrents aux ressources de la machine hôte


Évolution des préférences d'écriture

  • Les introductions des différentes versions de Java tentent de favoriser l'écriture d'un code plus lisible dont l'intention est plus facile à comprendre
  • Cela suggère :
    • le refactoring des classes anonymes vers des expressions lambda
    • l'utilisation de références de méthodes là où cela est possible
    • le refactoring de code impératif de traitement sur des données vers les streams
Exemple de bénéfice : exécution différée conditionnelle
  • Un code particulier ne peut être exécuté que s'il est vraiment nécessaire : pour cela on introduit souvent des vérifications
  • Par exemple, l'évaluation d'une expression utilisée comme paramètre de méthode est réalisée à chaque appel de la méthode
  • Parfois, son évaluation n'est pas adaptée au contexte : par exemple, le message transmis à une méthode de journalisation (logging) ne sera vraiment utilisé que si le niveau de journalisation doit considérer ce message, ex.
logger.log(Level.WARNING, construitMessage());
  • Il est possible d'adapter les méthodes pour qu'elles exploitent la capacité d'exécution différée conditionnelle, ex.
public void log(Level niveau, Supplier< String> fournisseurMessage)
  • qui s'invoque donc ainsi :
logger.log(Level.WARNING, () -> construitMessage());

Réécriture de patrons de conception (design patterns)

Exemple : Template

Le patron Template porte sur des situations où l'on souhaite pouvoir adapter localement un algorithme. Cela peut typiquement être représenté au travers de méthodes abstraites, qui seront implémentées dans des sous-classes qui préciseront leur stratégie propres, ex.

abstract class École {
	public void inscrireÉlève(int id) {
		Élève e = getÉlève(id);
		inscrireÉlève(e);
	}
	abstract void inscrireÉlève(Élève e);
}
  • Il est possible de transmettre à une méthode une/des expressions lambda qui viendront donc paramétrer son exécution, ex.
public void inscrireÉlève(int id, Consumer< Élève> inscrireÉlève) {
	Élève e = getÉlève(id);
	inscrireÉlève.accept(e);
}
  • Ce qui permet de tels appels :
école.inscrireÉlève(237, 
					(Élève e) -> e.message("Merci " + e.getNom() + " de venir chercher le laisser-passer A38") );

Réécriture de patrons de conception (design patterns)

Exemple : Factory

Le patron Factory vise à masquer au client d'une classe ses détails d'instanciation, ex.

class DocumentFactory {
	public Document produireDocumentReglémentaire(String nomDocument) {
		return switch (name) {
			case "règlement intérieur" -> new DocumentRèglementIntérieur();
			case "charte web" -> new DocumentCharteWeb();
			default -> throw new IllegalArgumentException("Type de document inconnu : " + nomDocument);
		};
	}
}
  • Il est possible d'utiliser des objets de type Supplier<T> pour la création de chaque type de documents, qui peuvent donc être organisés dans des map, ex.
Map< String, Supplier< Document>> map = new HashMap<>(); 
map.put("règlement intérieur", DocumentRèglementIntérieur::new);
map.put("charte web", DocumentCharteWeb::new);
// …
class DocumentFactory {
	public Document produireDocumentReglémentaire(String nomDocument) {
		Supplier< Document> supplier = map.get(nomDocument);
		if (supplier != null) return supplier.get();
		throw new IllegalArgumentException("Type de document inconnu : " + nomDocument);
	}
}