Comment maîtriser les pipelines de stream avancés en Java ?

var ohMy = Stream.of("lions", "tigres", "ours");
Map<Integer, Optional<Character>> map = ohMy.collect(
Collectors.groupingBy(
String::length,
Collectors.mapping(
s -> s.charAt(0),
Collectors.minBy((a, b) -> a - b))));
System.out.println(map); // {5=Optional[l], 6=Optional[t]}

Nous n’allons pas vous dire que ce code est facile à lire. Nous vous dirons que c’est la chose la plus compliquée que vous devez comprendre. En comparant avec l’exemple précédent, vous pouvez voir que nous avons remplacé counting() par mapping(). Il se trouve que mapping() prend deux paramètres : la fonction pour la valeur et comment la regrouper davantage.

Vous pourriez voir des collecteurs utilisés avec un import statique pour rendre le code plus court. L’examen pourrait même utiliser var pour la valeur de retour et moins d’indentation que nous avons utilisée. Cela signifie que vous pourriez voir quelque chose comme ceci :

var ohMy = Stream.of("lions", "tigres", "ours");
var map = ohMy.collect(groupingBy(String::length,
    mapping(s -> s.charAt(0), minBy((a, b) -> a - b))));
System.out.println(map); // {5=Optional[l], 6=Optional[t]}

Le code fait la même chose que dans l’exemple précédent. Cela signifie qu’il est important de reconnaître les noms des collecteurs car vous pourriez ne pas avoir le nom de la classe Collectors pour vous guider.

Collecteurs de Teeing

Supposons que vous voulez renvoyer deux choses. Comme nous l’avons appris, c’est problématique avec les streams car vous n’obtenez qu’un seul passage. Les statistiques récapitulatives sont bonnes lorsque vous voulez ces opérations. Heureusement, vous pouvez utiliser teeing() pour renvoyer plusieurs valeurs de votre choix.

Tout d’abord, définissons le type de retour. Nous utilisons un record ici :

record Separations(String espacesSepares, String virgulesSepares) {}

Maintenant, nous écrivons le stream. En lisant, faites attention au nombre de Collectors :

var liste = List.of("x", "y", "z");
Separations resultat = liste.stream()
    .collect(Collectors.teeing(
        Collectors.joining(" "),
        Collectors.joining(","),
        (s, c) -> new Separations(s, c)));
System.out.println(resultat);

Lorsqu’il est exécuté, le code affiche ce qui suit :

Separations[espacesSepares=x y z, virgulesSepares=x,y,z]

Il y a trois Collectors dans ce code. Deux d’entre eux sont pour joining() et produisent les valeurs que nous voulons renvoyer. Le troisième est teeing(), qui combine les résultats en un seul objet que nous voulons renvoyer. De cette façon, Java est content car un seul objet est renvoyé, et nous sommes contents car nous n’avons pas à parcourir le stream deux fois.

Travailler avec des Concepts Avancés de Pipelines de Stream

Félicitations, il ne vous reste que quelques sujets à découvrir ! Dans cette dernière section sur les streams, nous allons découvrir la relation entre les streams et les données sous-jacentes, le chaînage des Optional, et le regroupement avec les collectors. Après cela, vous devriez être un pro des streams !

Lier les Streams aux Données Sous-jacentes

Que pensez-vous que ceci affiche ?

var chats = new ArrayList<String>();
chats.add("Annie");
chats.add("Ripley");
var stream = chats.stream();
chats.add("KC");
System.out.println(stream.count());

La bonne réponse est 3. Les trois premières lignes créent une List avec deux éléments. La quatrième ligne demande qu’un stream soit créé à partir de cette List. Souvenez-vous que les streams sont évalués paresseusement. Cela signifie que le stream n’est pas créé à la quatrième ligne. Un objet est créé qui sait où chercher les données quand elles seront nécessaires. À la cinquième ligne, la List reçoit un nouvel élément. À la sixième ligne, le pipeline du stream s’exécute. D’abord, il regarde la source et voit trois éléments.

Chaîner les Optional

À ce stade, vous êtes familier avec les avantages du chaînage d’opérations dans un pipeline de stream. Quelques-unes des opérations intermédiaires pour les streams sont disponibles pour Optional.

Supposons qu’on vous donne un Optional<Integer> et qu’on vous demande d’afficher la valeur, mais seulement si c’est un nombre à trois chiffres. Sans programmation fonctionnelle, vous pourriez écrire ceci :

private static void troisChiffres(Optional<Integer> optional) {
    if (optional.isPresent()) { // if externe
        var num = optional.get();
        var string = "" + num;
        if (string.length() == 3) // if interne
            System.out.println(string);
    }
}

Ça fonctionne, mais cela contient des instructions if imbriquées. C’est une complexité supplémentaire. Essayons à nouveau avec la programmation fonctionnelle :

private static void troisChiffres(Optional<Integer> optional) {
    optional.map(n -> "" + n) // partie 1
        .filter(s -> s.length() == 3) // partie 2
        .ifPresent(System.out::println); // partie 3
}

C’est beaucoup plus court et expressif. Avec les lambdas, on a tendance à découper une seule instruction et à identifier les morceaux avec un commentaire. Nous l’avons fait ici pour montrer ce qui se passe avec la programmation fonctionnelle et non fonctionnelle.

Supposons qu’on nous donne un Optional vide. La première approche renvoie false pour l’instruction if externe. La deuxième approche voit un Optional vide et les méthodes map() et filter() le font passer tel quel. Ensuite, ifPresent() voit un Optional vide et n’appelle pas le paramètre Consumer.

Le cas suivant est celui où on nous donne un Optional.of(4). La première approche renvoie false pour l’instruction if interne. La deuxième approche transforme le nombre 4 en “4”. Le filter() renvoie alors un Optional vide puisque le filtre ne correspond pas, et ifPresent() n’appelle pas le paramètre Consumer.

Le dernier cas est celui où on nous donne un Optional.of(123). La première approche renvoie true pour les deux instructions if. La deuxième approche transforme le nombre 123 en “123”. Le filter() renvoie alors le même Optional, et ifPresent() appelle maintenant le paramètre Consumer.

Maintenant, supposons que nous voulions obtenir un Optional<Integer> représentant la longueur de la String contenue dans un autre Optional. Facile :

Optional<Integer> result = optional.map(String::length);

Et si nous avions une méthode auxiliaire qui faisait la logique de calcul pour nous et qui renvoie Optional<Integer> ? Utiliser map ne fonctionne pas :

Optional<Integer> result = optional
    .map(ChainingOptionals::calculator); // NE COMPILE PAS

Le problème est que calculator renvoie Optional<Integer>. La méthode map() ajoute un autre Optional, nous donnant Optional<Optional<Integer>>. Ce n’est pas bon. La solution est d’appeler flatMap() à la place :

Optional<Integer> result = optional
    .flatMap(ChainingOptionals::calculator);

Celle-ci fonctionne parce que flatMap supprime la couche inutile. En d’autres termes, elle aplatit le résultat. Chaîner des appels à flatMap() est utile lorsque vous voulez transformer un type Optional en un autre.

Scénario du Monde Réel

Exceptions vérifiées et Interfaces Fonctionnelles

Vous avez peut-être remarqué que la plupart des interfaces fonctionnelles ne déclarent pas d’exceptions vérifiées. C’est généralement correct. Cependant, c’est un problème lorsqu’on travaille avec des méthodes qui déclarent des exceptions vérifiées. Supposons que nous ayons une classe avec une méthode qui lance une exception vérifiée :

import java.io.*;
import java.util.*;
public class EtudeDesCasExceptions { 
    private static List<String> create() throws IOException {
        throw new IOException();
    }
}

Maintenant, nous l’utilisons dans un stream :

public void bon() throws IOException {
    EtudeDesCasExceptions.create().stream().count();
}

Rien de nouveau ici. La méthode create() lance une exception vérifiée. La méthode appelante la gère ou la déclare. Maintenant, qu’en est-il de celle-ci ?

public void mauvais() throws IOException {
    Supplier<List<String>> s = EtudeDesCasExceptions::create; // NE COMPILE PAS
}

L’erreur du compilateur est la suivante :

exception non gérée de type IOException

Le problème est que la lambda à laquelle cette référence de méthode se développe ne déclare pas d’exception. L’interface Supplier ne permet pas les exceptions vérifiées. Il y a deux approches pour contourner ce problème. L’une consiste à attraper l’exception et à la transformer en une exception non vérifiée.

public void moche() {
    Supplier<List<String>> s = () -> {
        try {
            return EtudeDesCasExceptions.create();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    };
}

Cela fonctionne. Mais le code est laid. L’un des avantages de la programmation fonctionnelle est que le code est censé être facile à lire et concis. Une autre alternative est de créer une méthode wrapper avec try/catch.

private static List<String> createSafe() {
    try {
        return EtudeDesCasExceptions.create();
    } catch (IOException e) {
        throw new RuntimeException(e);
    } }

Maintenant, nous pouvons utiliser le wrapper sécurisé dans notre Supplier sans problème.

public void enveloppe() {
    Supplier<List<String>> s2 = EtudeDesCasExceptions::createSafe;
}

Utiliser un Spliterator

Supposons que vous achetez un sac de nourriture pour que deux enfants puissent nourrir les animaux au zoo pour enfants. Pour éviter les disputes, vous êtes venu préparé avec un sac vide supplémentaire. Vous prenez environ la moitié de la nourriture du sac principal et la mettez dans le sac que vous avez apporté de chez vous. Le sac original existe toujours avec l’autre moitié de la nourriture.

Un Spliterator offre ce niveau de contrôle sur le traitement. Il commence avec une Collection ou un stream – c’est votre sac de nourriture. Vous appelez trySplit() pour retirer de la nourriture du sac. Le reste de la nourriture reste dans l’objet Spliterator original.

Les caractéristiques d’un Spliterator dépendent de la source de données sous-jacente. Une source de données Collection est un Spliterator basique. En revanche, lors de l’utilisation d’une source de données Stream, le Spliterator peut être parallèle ou même infini. Le Stream lui-même est exécuté paresseusement plutôt que lorsque le Spliterator est créé.

Implémenter votre propre Spliterator peut devenir compliqué. Vous devez savoir comment travailler avec certaines des méthodes courantes déclarées sur cette interface. Les méthodes simplifiées que vous devez connaître sont dans le tableau suivant.

MéthodeDescription
Spliterator<T> trySplit()Renvoie Spliterator contenant idéalement la moitié des données, qui est supprimée du Spliterator actuel. Cette méthode peut être appelée plusieurs fois et finira par renvoyer null lorsque les données ne sont plus divisibles.
void forEachRemaining(Consumer<T> c)Traite les éléments restants dans Spliterator.
boolean tryAdvance(Consumer<T> c)Traite un seul élément de Spliterator s’il en reste. Renvoie si l’élément a été traité.

Maintenant, regardons un exemple où nous divisons le sac en trois :

var stream = List.of("oiseau-", "lapin-", "chat-", "chien-", "poisson-", "agneau-",
    "souris-");
Spliterator<String> sacOriginalDeNourriture = stream.spliterator();
Spliterator<String> sacEmma = sacOriginalDeNourriture.trySplit();
sacEmma.forEachRemaining(System.out::print); // oiseau-lapin-chat-

Spliterator<String> sacJuliette = sacOriginalDeNourriture.trySplit();
sacJuliette.tryAdvance(System.out::print); // chien-
sacJuliette.forEachRemaining(System.out::print); // poisson-

sacOriginalDeNourriture.forEachRemaining(System.out::print); // agneau-souris-

Nous définissons une List. Nous créons deux références Spliterator. La première est le sac original, qui contient les sept éléments. La seconde est notre division du sac original, mettant approximativement la moitié des éléments du début dans le sac d’Emma. Nous imprimons ensuite les trois contenus du sac d’Emma.

Notre sac original de nourriture contient maintenant quatre éléments. Nous créons un nouveau Spliterator et mettons les deux premiers éléments dans le sac de Juliette. Nous utilisons tryAdvance() pour afficher un seul élément, puis la ligne suivante affiche tous les éléments restants (un seul).

Nous avons commencé avec sept éléments, en avons retiré trois, puis deux de plus. Cela nous laisse avec deux éléments dans le sac original créé. Ces deux éléments sont affichés.

Maintenant, essayons un exemple avec un Stream. C’est une façon compliquée d’afficher 123 :

var sacOriginal = Stream.iterate(1, n -> ++n)
    .spliterator();
Spliterator<Integer> nouveauSac = sacOriginal.trySplit();
nouveauSac.tryAdvance(System.out::print); // 1
nouveauSac.tryAdvance(System.out::print); // 2
nouveauSac.tryAdvance(System.out::print); // 3

Vous avez peut-être remarqué que c’est un stream infini. Pas de problème ! Le Spliterator reconnaît que le stream est infini et n’essaie pas de vous donner la moitié. À la place, nouveauSac contient un grand nombre d’éléments. Nous obtenons les trois premiers puisque nous appelons tryAdvance() trois fois. Ce serait une mauvaise idée d’appeler forEachRemaining() sur un stream infini !

Notez qu’un Spliterator peut avoir un certain nombre de caractéristiques telles que CONCURRENT, ORDERED, SIZED et SORTED. Vous ne verrez qu’un Spliterator simple.

Collecter les Résultats

Vous avez presque fini d’apprendre sur les streams. Le dernier sujet s’appuie sur ce que vous avez appris jusqu’à présent pour regrouper les résultats. Au début du chapitre, vous avez vu l’opération terminale collect(). Il existe de nombreux collecteurs prédéfinis, notamment ceux présentés dans le tableau suivant. Ces collecteurs sont disponibles via des méthodes statiques sur la classe Collectors. Nous examinons les différents types de collecteurs dans les sections suivantes. Nous avons omis les types génériques par simplicité.

Il existe un autre collecteur appelé reducing(). C’est une réduction générale au cas où tous les collecteurs précédents ne répondent pas à vos besoins.

Utiliser les Collecteurs de Base

Heureusement, beaucoup de ces collecteurs fonctionnent de la même manière. Regardons un exemple :

var ohMy = Stream.of("lions", "tigres", "ours");
String result = ohMy.collect(Collectors.joining(", "));
System.out.println(result); // lions, tigres, ours

Nous passons le collecteur prédéfini joining() à la méthode collect(). Tous les éléments du stream sont ensuite fusionnés en une String avec le délimiteur spécifié entre chaque élément. Il est important de passer le Collector à la méthode collect. Il existe pour aider à collecter les éléments. Un Collector ne fait rien par lui-même.

Essayons-en un autre. Quelle est la longueur moyenne des trois noms d’animaux ?

var ohMy = Stream.of("lions", "tigres", "ours");
Double result = ohMy.collect(Collectors.averagingInt(String::length));
System.out.println(result); // 5.333333333333333

Le modèle est le même. Nous passons un collecteur à collect(), et il effectue la moyenne pour nous. Cette fois, nous avons dû passer une fonction pour indiquer au collecteur ce qu’il fallait faire pour la moyenne. Nous avons utilisé une référence de méthode, qui renvoie un int lors de l’exécution. Avec les streams primitifs, le résultat d’une moyenne était toujours un double, quel que soit le type moyenné. Pour les collecteurs, c’est un Double puisque ceux-ci ont besoin d’un Object.

Souvent, vous vous retrouverez à interagir avec du code qui a été écrit sans streams. Cela signifie qu’il attendra un type Collection plutôt qu’un type Stream. Pas de problème. Vous pouvez toujours vous exprimer en utilisant un Stream puis convertir en Collection à la fin. Par exemple :

var ohMy = Stream.of("lions", "tigres", "ours");
TreeSet<String> result = ohMy
    .filter(s -> s.startsWith("t"))
    .collect(Collectors.toCollection(TreeSet::new));
System.out.println(result); // [tigres]

Cette fois, nous avons les trois parties du pipeline de stream. Stream.of() est la source du stream. L’opération intermédiaire est filter(). Enfin, l’opération terminale est collect(), qui crée un TreeSet. Si nous ne nous soucions pas de quelle implémentation de Set nous obtenons, nous aurions pu écrire Collectors.toSet(), à la place.

Collecter dans des Maps

Le code utilisant des Collectors impliquant des maps peut devenir assez long. Nous allons le construire lentement. Assurez-vous de comprendre chaque exemple avant de passer au suivant. Commençons par un exemple simple pour créer une map à partir d’un stream :

var ohMy = Stream.of("lions", "tigres", "ours");
Map<String, Integer> map = ohMy.collect(
    Collectors.toMap(s -> s, String::length));
System.out.println(map); // {lions=5, ours=4, tigres=6}

Lors de la création d’une map, vous devez spécifier deux fonctions. La première fonction indique au collecteur comment créer la clé. Dans notre exemple, nous utilisons la String fournie comme clé. La seconde fonction indique au collecteur comment créer la valeur. Dans notre exemple, nous utilisons la longueur de la String comme valeur.

Renvoyer la même valeur passée dans une lambda est une opération courante, donc Java fournit une méthode pour cela. Vous pouvez réécrire s -> s comme Function.identity(). Ce n’est pas plus court et peut être plus ou moins clair, donc utilisez votre jugement pour décider si vous voulez l’utiliser.

Maintenant, nous voulons faire l’inverse et mapper la longueur du nom de l’animal au nom lui-même. Notre première tentative incorrecte est présentée ici :

var ohMy = Stream.of("lions", "tigres", "ours");
Map<Integer, String> map = ohMy.collect(Collectors.toMap(
    String::length,
    k -> k)); // MAUVAIS

L’exécution donne une exception similaire à celle-ci :

Exception in thread "main"
java.lang.IllegalStateException: Duplicate key 5

Quel est le problème ? Deux des noms d’animaux ont la même longueur. Nous n’avons pas dit à Java quoi faire. Le collecteur devrait-il choisir le premier qu’il rencontre ? Le dernier qu’il rencontre ? Concaténer les deux ? Puisque le collecteur n’a aucune idée de quoi faire, il “résout” le problème en lançant une exception et en en faisant notre problème. Comme c’est gentil. Supposons que notre exigence est de créer une String séparée par des virgules avec les noms d’animaux. Nous pourrions écrire ceci :

var ohMy = Stream.of("lions", "tigres", "ours");
Map<Integer, String> map = ohMy.collect(Collectors.toMap(
    String::length,
    k -> k,
    (s1, s2) -> s1 + "," + s2));
System.out.println(map); // {5=lions,ours, 6=tigres}
System.out.println(map.getClass()); // class java.util.HashMap

Il se trouve que la Map renvoyée est une HashMap. Ce comportement n’est pas garanti. Supposons que nous voulons imposer que le code renvoie un TreeMap à la place. Pas de problème. Nous ajouterions simplement une référence au constructeur comme paramètre :

var ohMy = Stream.of("lions", "tigres", "ours");
TreeMap<Integer, String> map = ohMy.collect(Collectors.toMap(
    String::length,
    k -> k,
    (s1, s2) -> s1 + "," + s2,
    TreeMap::new));
System.out.println(map); // // {5=lions,ours, 6=tigres}
System.out.println(map.getClass()); // class java.util.TreeMap

Cette fois, nous obtenons le type que nous avons spécifié. Vous suivez jusqu’ici ? Ce code est long mais pas particulièrement compliqué. Nous vous avions promis que le code serait long !

Grouper, Partitionner et Mapper

Bon travail jusqu’ici. Les créateurs d’examen aiment poser des questions sur groupingBy() et partitioningBy(), donc assurez-vous de bien comprendre ces sections. Maintenant, supposons que nous voulons obtenir des groupes de noms par leur longueur. Nous pouvons faire cela en disant que nous voulons grouper par longueur.

var ohMy = Stream.of("lions", "tigres", "ours");
Map<Integer, List<String>> map = ohMy.collect(
    Collectors.groupingBy(String::length));
System.out.println(map); // {5=[lions, ours], 6=[tigres]}

Le collecteur groupingBy() indique à collect() qu’il doit regrouper tous les éléments du stream dans une Map. La fonction détermine les clés dans la Map. Chaque valeur dans la Map est une List de toutes les entrées qui correspondent à cette clé.

Notez que la fonction que vous appelez dans groupingBy() ne peut pas renvoyer null. Elle n’autorise pas les clés null.

Supposons que nous ne voulons pas une List comme valeur dans la map et préférons un Set à la place. Pas de problème. Il existe une autre signature de méthode qui nous permet de passer un collecteur en aval. Il s’agit d’un second collecteur qui fait quelque chose de spécial avec les valeurs.

var ohMy = Stream.of("lions", "tigres", "ours");
Map<Integer, Set<String>> map = ohMy.collect(
    Collectors.groupingBy(
        String::length,
        Collectors.toSet()));
System.out.println(map); // {5=[lions, ours], 6=[tigres]}

Nous pouvons même changer le type de Map renvoyé grâce à un autre paramètre.

var ohMy = Stream.of("lions", "tigres", "ours");
TreeMap<Integer, Set<String>> map = ohMy.collect(
    Collectors.groupingBy(
        String::length,
        TreeMap::new,
        Collectors.toSet()));
System.out.println(map); // {5=[lions, ours], 6=[tigres]}

C’est très flexible. Et si nous voulons changer le type de Map renvoyé mais laisser le type de valeurs seul comme une List ? Il n’y a pas de méthode spécifique pour cela car il est facile de l’écrire avec les méthodes existantes.

var ohMy = Stream.of("lions", "tigres", "ours");
TreeMap<Integer, List<String>> map = ohMy.collect(
    Collectors.groupingBy(
        String::length,
        TreeMap::new,
        Collectors.toList()));
System.out.println(map);

Le partitionnement est un cas particulier de regroupement. Avec le partitionnement, il n’y a que deux groupes possibles : true et false. Le partitionnement est comme diviser une liste en deux parties.

Supposons que nous fabriquons une pancarte à placer à l’extérieur de l’exposition de chaque animal. Nous avons deux tailles de pancartes. L’une peut accueillir des noms de cinq caractères ou moins. L’autre est nécessaire pour les noms plus longs. Nous pouvons partitionner la liste en fonction de la pancarte dont nous avons besoin.

var ohMy = Stream.of("lions", "tigres", "ours");
Map<Boolean, List<String>> map = ohMy.collect(
    Collectors.partitioningBy(s -> s.length() <= 5));
System.out.println(map); // {false=[tigres], true=[lions, ours]}

Ici, nous passons un Predicate avec la logique pour savoir à quel groupe appartient chaque nom d’animal. Maintenant, supposons que nous avons découvert comment utiliser une police différente, et sept caractères peuvent maintenant tenir sur la petite pancarte. Pas de soucis. Nous changeons simplement le Predicate.

var ohMy = Stream.of("lions", "tigres", "ours");
Map<Boolean, List<String>> map = ohMy.collect(
    Collectors.partitioningBy(s -> s.length() <= 7));
System.out.println(map); // {false=[], true=[lions, tigres, ours]}

Remarquez qu’il y a toujours deux clés dans la map—une pour chaque valeur booléenne. Il se trouve qu’une des valeurs est une liste vide, mais elle est toujours là. Comme avec groupingBy(), nous pouvons changer le type de List en autre chose.

var ohMy = Stream.of("lions", "tigres", "ours");
Map<Boolean, Set<String>> map = ohMy.collect(
    Collectors.partitioningBy(
        s -> s.length() <= 7,
        Collectors.toSet()));
System.out.println(map); // {false=[], true=[lions, tigres, ours]}

Contrairement à groupingBy(), nous ne pouvons pas changer le type de Map qui est renvoyé. Cependant, il n’y a que deux clés dans la map, donc est-ce vraiment important quel type de Map nous utilisons ?

Au lieu d’utiliser le collecteur en aval pour spécifier le type, nous pouvons utiliser n’importe lequel des collecteurs que nous avons déjà montrés. Par exemple, nous pouvons regrouper par la longueur du nom de l’animal pour voir combien nous en avons de chaque longueur.

var ohMy = Stream.of("lions", "tigres", "ours");
Map<Integer, Long> map = ohMy.collect(
    Collectors.groupingBy(
        String::length,
        Collectors.counting()));
System.out.println(map); // {5=2, 6=1}

Déboguer des Génériques Compliqués

Lorsque vous travaillez avec collect(), il y a souvent plusieurs niveaux de génériques, rendant les erreurs du compilateur illisibles. Voici trois techniques utiles pour gérer cette situation :

  • Recommencez avec une instruction simple, et continuez à ajouter. En faisant un petit changement à la fois, vous saurez quel code a introduit l’erreur.
  • Extrayez des parties de l’instruction en instructions séparées. Par exemple, essayez d’écrire Collectors.groupingBy(String::length, Collectors.counting());. S’il compile, vous savez que le problème est ailleurs. S’il ne compile pas, vous avez une instruction beaucoup plus courte à dépanner.
  • Utilisez des jokers génériques pour le type de retour de l’instruction finale : par exemple, Map<?, ?>. Si ce changement seul permet au code de compiler, vous saurez que le problème vient du type de retour qui n’est pas ce que vous attendez.

Enfin, voici le collecteur `mapping()` – un outil puissant qui descend d’un niveau dans vos données et applique un autre collecteur. Prenons un exemple concret: obtenir la première lettre du premier animal alphabétiquement pour chaque longueur de nom.