1. Impératif vs style fonctionnel
Commençons par une question simple : à quoi sert donc ce fameux « style fonctionnel » ? En quoi est-il meilleur que l’approche habituelle avec des boucles ? Et que signifie exactement « style fonctionnel » en Java ?
Style impératif
Le style impératif, c’est lorsque vous dites à l’ordinateur comment faire quelque chose, étape par étape. Par exemple, si vous devez obtenir à partir d’une liste de chaînes la liste des longueurs de ces chaînes, ne garder que les longueurs impaires et les trier par ordre décroissant, vous écrivez à peu près ceci :
List<String> words = Arrays.asList("cat", "elephant", "rhino", "tiger", "mouse");
List<Integer> lengths = new ArrayList<>();
for (String word : words) {
int len = word.length();
if (len % 2 != 0) {
lengths.add(len);
}
}
lengths.sort(Comparator.reverseOrder());
System.out.println(lengths); // [7, 5, 3]
Ici, nous créons explicitement une liste intermédiaire, ajoutons des éléments manuellement, trions — le tout étape par étape.
Style fonctionnel
Le style fonctionnel, c’est lorsque vous décrivez ce que vous souhaitez obtenir, et non comment le faire. En Java, cela est réalisé via la Stream API :
List<String> words = Arrays.asList("cat", "elephant", "rhino", "tiger", "mouse");
List<Integer> result = words.stream()
.map(String::length)
.filter(len -> len % 2 != 0)
.sorted(Comparator.reverseOrder())
.toList();
System.out.println(result); // [7, 5, 3]
Ici, on construit comme un « tapis roulant » de traitement des données : d’abord on transforme les mots en leurs longueurs (map), puis on filtre les longueurs impaires (filter), puis on trie (sorted). Tout cela — dans une seule chaîne, sans collections intermédiaires explicites ni boucles.
Comparaison : itératif vs style fonctionnel
Dans l’approche impérative habituelle, on écrit une boucle dans laquelle on explique pas à pas à l’ordinateur quoi faire : parcourir chaque élément, vérifier une condition, ajouter à une nouvelle liste ou afficher à l’écran. Le code fonctionne, mais occupe plus de lignes ; et plus la tâche est complexe, plus il est difficile à lire et à maintenir.
Le style fonctionnel permet de décrire non pas le processus lui-même, mais ce que l’on veut obtenir. Au lieu d’une longue boucle, on construit une chaîne d’opérations : filtrer, transformer, collecter le résultat. C’est plus court, plus lisible et cela réduit le risque d’erreurs, car il y a moins de « travail manuel » avec des collections mutables.
Il y a aussi l’envers de la médaille. Pour un débutant, une telle chaîne peut sembler déroutante : plusieurs lambdas à la suite se lisent parfois plus difficilement qu’une simple boucle. Le style fonctionnel gagne donc en concision et en expressivité, mais demande un peu d’habitude et d’expérience visuelle.
2. Opérations de base de l’API Stream
La Stream API n’est pas juste « un nouveau type de boucle », mais un ensemble complet d’outils pour traiter les collections dans un style fonctionnel. Voyons les opérations principales.
Comment obtenir un Stream ?
List<String> list = List.of("a", "bb", "ccc");
Stream<String> stream = list.stream();
Opérations intermédiaires
- map — transforme les éléments du flux
- filter — filtre les éléments selon une condition
- flatMap — transforme chaque élément en flux et les « aplatit »
- sorted — tri
- distinct — supprime les doublons
- limit / skip — limite/ignore des éléments
Opérations terminales
- forEach — exécuter une action pour chaque élément
- collect — collecter le résultat dans une collection
- reduce — réduire le flux à une seule valeur (par exemple, une somme)
- count — compter le nombre d’éléments
- anyMatch, allMatch, noneMatch — vérification de conditions
Exemple : chaîne de traitement
List<String> names = List.of("Anna", "Boris", "Victoria", "George", "Diana");
List<String> filtered = names.stream()
.filter(name -> name.length() > 3)
.map(String::toUpperCase)
.sorted()
.toList();
System.out.println(filtered); // [BORIS, VIKA, DASHA]
Schéma visuel du « tapis roulant » :
[Anna, Boris, Victoria, George, Diana]
| filter (length>3)
[Boris, Victoria, Diana]
| map (toUpperCase)
[BORIS, VIKA, DASHA]
| sorted
[BORIS, VIKA, DASHA]
| toList
Chaque opération ne modifie pas la collection d’origine — un nouveau flux est créé.
3. Exécution en flux et évaluation paresseuse
Opérations intermédiaires et terminales
Les Streams ont deux types d’opérations. Les premières — intermédiaires, comme map, filter ou sorted. Elles renvoient un nouveau flux et semblent « promettre » de faire quelque chose, mais en réalité, rien ne s’exécute encore. Les secondes — terminales, par exemple forEach, collect ou reduce. Celles-ci lancent réellement tout le traitement. Le point clé est que tant que vous n’avez pas appelé une opération terminale, le flux reste « paresseux » — les calculs ne commencent pas.
Exemple :
Stream<String> stream = List.of("a", "bb", "ccc").stream()
.map(s -> {
System.out.println("map: " + s);
return s.toUpperCase();
});
System.out.println("Avant forEach");
stream.forEach(System.out::println);
Sortie :
Avant forEach
map: a
A
map: bb
BB
map: ccc
CCC
On voit que map ne s’exécute pas tant que forEach n’a pas commencé.
Pourquoi est-ce puissant ?
Cette approche permet de construire, sans surcoût, des chaînes de transformations aussi longues que nécessaire. Le flux ne commencera à travailler que lorsque cela sera vraiment nécessaire. Grâce à cela, on peut traiter sans problème d’énormes volumes de données, voire des séquences infinies. Et l’évaluation paresseuse aide également à économiser de la mémoire et à exécuter les calculs plus efficacement.
4. Pratique : tâche « Chaînes → longueurs → impaires → par ordre décroissant »
Résolvons étape par étape la tâche : « À partir d’une liste de chaînes, obtenir la liste des longueurs des chaînes, ne conserver que les longueurs impaires, trier par ordre décroissant. »
Solution impérative
List<String> words = Arrays.asList("cat", "elephant", "rhino", "tiger", "mouse");
List<Integer> lengths = new ArrayList<>();
for (String word : words) {
int len = word.length();
if (len % 2 != 0) {
lengths.add(len);
}
}
lengths.sort(Comparator.reverseOrder());
System.out.println(lengths); // [7, 5, 3]
Solution fonctionnelle avec la Stream API
List<String> words = Arrays.asList("cat", "elephant", "rhino", "tiger", "mouse");
List<Integer> result = words.stream()
.map(String::length) // on transforme les chaînes en leurs longueurs
.filter(len -> len % 2 != 0) // on ne garde que les longueurs impaires
.sorted(Comparator.reverseOrder()) // tri décroissant
.toList(); // on collecte dans un List (Java 16+)
System.out.println(result); // [7, 5, 3]
Explications :
- map(String::length) — pour chaque chaîne, on obtient sa longueur.
- filter(len -> len % 2 != 0) — on ne garde que les longueurs impaires.
- sorted(Comparator.reverseOrder()) — on trie par ordre décroissant.
- toList() — on collecte le flux dans une nouvelle liste.
Analogie
C’est comme si vous construisiez une « chaîne » dans une usine : à chaque étape, les pièces sont retravaillées, et seulement à la toute fin tout est rangé dans une boîte.
5. Autres exemples : map, filter, forEach, collect
Exemple 1 : filtrage et affichage
List<String> names = List.of("Anna", "Boris", "Victoria", "George", "Diana");
names.stream()
.filter(name -> name.contains("a"))
.forEach(System.out::println);
// Affichera : Anna, Diana
Exemple 2 : transformation et collecte dans un Set
Set<String> upperNames = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toSet());
System.out.println(upperNames); // [ANNA, BORIS, VIKA, GLEB, DASHA]
Exemple 3 : obtenir la somme des longueurs de toutes les chaînes
int totalLength = names.stream()
.mapToInt(String::length)
.sum();
System.out.println("Longueur totale : " + totalLength);
Exemple 4 : utilisation de Predicate et Function
Predicate<String> longName = name -> name.length() > 4;
Function<String, String> greet = name -> "Bonjour, " + name + " !";
names.stream()
.filter(longName)
.map(greet)
.forEach(System.out::println);
// Bonjour, Boris!
// Bonjour, Diana!
6. Particularités du style fonctionnel avec la Stream API
Absence d’état mutable
Le style de la Stream API encourage les fonctions « pures » — sans effets de bord. Autrement dit, il vaut mieux ne pas modifier des variables externes à l’intérieur des lambdas.
Mauvais :
List<String> result = new ArrayList<>();
names.stream()
.filter(name -> name.startsWith("A"))
.forEach(result::add); // side-effect!
Mieux :
List<String> result = names.stream()
.filter(name -> name.startsWith("A"))
.toList();
Composition des opérations
On peut construire des chaînes très longues en combinant map, filter, sorted et d’autres méthodes. L’essentiel — ne pas en abuser : si la chaîne dépasse la hauteur de l’écran, il est peut-être judicieux de la découper en parties.
Évaluation paresseuse
La Stream API ne fait rien tant qu’on n’atteint pas une opération terminale. Cela permet d’économiser des ressources et de construire des pipelines de traitement efficaces.
Immutabilité des collections d’origine
Un Stream ne modifie pas la collection d’origine ! Toutes les transformations renvoient un nouveau flux/une nouvelle collection.
7. Quand utiliser la Stream API
La Stream API est idéale lorsque :
- Il faut traiter rapidement une collection (filtrage, transformation, tri).
- On veut un code concis et lisible.
- On ne veut pas créer manuellement des collections intermédiaires.
- Il faut ajouter facilement du parallélisme (via parallelStream()).
Le style impératif est parfois préférable si :
- Une logique complexe est nécessaire avec plusieurs boucles et conditions imbriquées.
- Il est important de conserver des performances maximales dans des zones critiques (la Stream API est parfois légèrement plus lente).
- Il faut travailler avec un état mutable (par exemple, mettre à jour des éléments « sur place »).
8. Erreurs typiques avec la Stream API
Erreur n° 1 : utilisation de forEach pour collecter une collection. Beaucoup de débutants utilisent forEach pour ajouter des éléments à une nouvelle collection. Ce n’est pas du style fonctionnel ! Utilisez plutôt collect ou toList().
Erreur n° 2 : optimisation prématurée. N’essayez pas d’utiliser immédiatement parallelStream() — le parallélisme n’est utile que pour de très grandes collections et des tâches intensives CPU.
Erreur n° 3 : mélange de la Stream API et de collections mutables. La Stream API implique de travailler avec des données immuables. Il ne faut pas modifier les éléments d’une collection à l’intérieur des lambdas.
Erreur n° 4 : « perte » du résultat. Si vous oubliez d’appeler une opération terminale, rien ne se produira.
Erreur n° 5 : lambdas trop complexes. Si une expression lambda dépasse une à deux lignes, extrayez-la dans une méthode séparée avec un nom explicite.
GO TO FULL VERSION