Notes d'apprentissage de Python : traitement des séquences avec un style fonctionnel - flatmap
Contenu
Contexte
Ce billet s’inscrit dans une série de billets sur le traitement des séquences avec un style fonctionnel en Python.
NB : La version de Python utilisée dans les exemples de code est la version 3.
Pour pleinement profiter de ce billet, il faut que vous connaissiez déjà le fonctionnement de map et reduce en Python ou que vous lisiez d’abord les billets que j’ai écrit sur l’un et l’autre.
Le besoin
Dans les billets précédents sur filter, map et reduce, nous avons vu des fonctions natives ou pas qui existent en Python et permettent d’écrire du code avec un style fonctionnel.
Dans le billet sur map, nous avons comparé cette fonction à l’utilisation des compréhensions (qui lui sont souvent préférées en Python) en comparant par rapport aux exemples du billet qui leur était dédié.
Néanmoins, dans ce billet nous avions le cas d’une compréhension imbriquée pour représenter un échiquier sous la forme d’un tableau de pairs colonnes et lignes :
|
|
|
|
Comment obtenir la même chose avec map ?
Vers flatmap et au-delà
Dans un premier temps pourquoi ne pas partir sur une approche naïve dans laquelle on va avoir des fonctions map imbriquées, tout comme on a des boucles for imbriquée ?
|
|
Cela nous donne une liste d’objets map.
|
|
Transformons ces objets map en des listes.
|
|
Nous obtenons, une liste de listes de pairs, ces dernières correspondant à ce que l’on souhaite comme données. Seulement on souhaiterait juste avoir une liste de pairs, pas une liste de liste de pairs.
|
|
Avec la fonction map, on le voit l’implémentation ne va pas être directe et va demander d’utiliser d’autres fonctions. Dans le cadre d’une approche fonctionnelle, pour obtenir une solution élégante il faudrait s’appuyer sur une fonction de la forme flatmap en faisant un flatmap appelant le map. Le soucis est que la fonction flatmap n’existe pas en Python. Cependant Python ne nous laisse pas dépourvu de solutions pour arriver à nos fins.
Néanmoins avant tout chose peut-être un mot sur flatmap. Avec map vous transformez une séquence en une nouvelle séquence dans laquelle chaque élément est le résultat de l’appel de la fonction passée en paramètre.
Par exemple, avec une fonction très simple qui ajoute 1, exprimée sous forme de lambda sur la séquence des nombres de 0 à 9, on obtient la séquence des nombres de 1 à 10 :
|
|
|
|
Je sais, je radote, j’ai déjà expliqué la fonction map : c’est juste un petit rappel pour arriver à flatmap.
Maintenant, modifions la fonction pour qu’elle retourne une paire avec la valeur passée en paramètre et cette valeur plus un :
|
|
Cette fois-ci, on n’obtient plus une séquence de nombres, mais une séquence de liste de nombres
|
|
Ce n’est pas forcément gênant mais si ce que vous souhaitez exploiter au final c’est une liste de nombres et non une liste de listes de nombres, il y a un peu de travail à effectuer sur cette liste, pour l’aplatir, c’est-à-dire supprimer le niveau d’imbrication et obtenir la liste [0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10]. C’est là où on pourrait utiliser flatmap si la fonction existait en Python. Cela donnerait ceci :
|
|
qui aurait comme résultat :
|
|
Le fonction flatmap applique la fonction qui lui est passée en paramètre comme la fonction map mais concatène le résultat au reste de la séquence plutôt que de l’ajouter dans la séquence.
Si on essaie d’être un peu plus formel, la fonction map :
- prend en paramètre
- une fonction qui en paramètre un élément de type A et qui le transforme en élément de type B
- une séquence d’éléments de type A
- fournit en résultat une séquence d’éléments de type B
La fonction flatmap :
- prend en paramètre
- une fonction qui en paramètre un élément de type A et qui le transforme en élément de type Liste de B
- une séquence d’éléments de type A
- fournit en résultat une séquence d’éléments de type B
La fonction flatmap est importante dans les langages fonctionnelles et se retrouve au coeur de la notion de monade.
Maintenant que nous avons défini ce qu’est flatmap, comment peut-on l’implémenter en Python ?
Dans un premier temps revenons à notre problème concret avec l’échiquier.
Le résultat obtenu n’était pas si loin que cela de la cible. Il nous faudrait un moyen d’aplatir le résultat ou de concaténer l’ensemble des listes pour n’en faire qu’une.
Tiens d’ailleurs dans certains langgaes (comme Clojure), la fonction s’appelle mapcat et non flatmap.
Cela peut nous donner des pistes.
En effet, dans le module operator de Python on peut remarquer iconcat qui correspond à += pour des séquences.
Cela correspond bien à notre besoin : une fonction qui concatène deux séquences et retourne le résultat.
Il faudrait maintenant une fonction qui nous permette d’appliquer cette concaténation successivement à chaque liste dans la liste.
La fonction reduce.
|
|
Cela nous donne bien le résultat attendu :
|
|
Pour bien comprendre comment cela fonctionne, revenons au résultat avec l’approche naïve :
|
|
On a une liste de listes. Avec reduce, nous parcourons cette liste élément après élément, c’est-à-dire ici liste après liste, et nous les concaténons les unes après les autres avec la valeur obtenue à l’étape précédente, la valeur initiale étant la liste vide.
Cela nous guide vers une implémentation possible de flatmap.
|
|
Le paramètre fun est une fonction qui prend un paramètre correspondant au type produit par un iterator et retourne une list.
On peut la tester sur la génération du tableau de l’échiquier.
|
|
|
|
Ce qui nous donne bien le résultat attendu.
Cependant on notera que notre fonction fun doit retourner une liste ou du moins un type compatible avec operator.iconcat.
On peut bien sûr faire une version de flatmap impérative qui retournera elle-aussi une liste.
|
|
Si on l’applique à la génération de la liste des cases de l’échiquier :
|
|
On obtient un résultat similaire.
|
|
Néanmoins si ces solutions sont intéressantes car elles nous permettent de comprendre le fonctionnement de flatmap et de voir pour la version fonctionnelle une exemple de combinaison de reduce et map, elles ne sont pas pleinement satisfaisantes.
Nous allons voir comment itertools va pouvoir nous aider à trouver des solutions plus satisfaisantes.
Itertools for the win
Et splat le chien
Il est possible de développer une liste ou un de manière plus générale un iterator avec *, le splat operator : * développe un iterator en l’ensemble de ces éléments.
Illustrons cela.
|
|
Ce code donne comme affichage :
|
|
Cet opérateur splat est bien pratique et va pouvoir être utilisé en conjonction avec une fonction de itertools
Iterator unchained
Dans le module itertools il y a la fonction chain qui prend un ou plusieurs iterables et retourne un iterator.
Cet iterator est en quelque sorte la concaténation des iterables fournis en entrée de chain : les éléments de chaque iterable sont retournés en les uns après les autres avant de passer à l'iterable suivant.
Un exemple permettra d’éclairer mon propos.
|
|
Comme on peut le voir ci-après, ces lignes vont provoquer l’affichage des nombres de 0 à 25 : chain a produit un iterator qui va d’abord retourner les valeurs 0 à 9 provenant de list_0_to_9, puis les valeurs provenant de list_10_to_19 et enfin celles provenant de list_20_to_25.
|
|
On notera au passage l’utilisation de itertools.islice qui permet de sélectionner un ensemble d’éléments d’un iterable et itertools.count qui produit un iterator qui retourne des valeurs espacées régulièrement à partir d’une valeur de départ (ici l’espacement est la valeur par défaut 1, pour une valeur de départ valant respectivement 10 et 20).
Armé de * et de chain nous allons pouvoir construire une nouvelle version de flatmap qui retournera cette fois-ci un iterator.
Tout d’abord, avant de généraliser à une fonction, voyons comment on peut les utiliser pour constituer la liste des cases de l’échiquier.
|
|
Cela nous donne bien ce que nous attendons.
|
|
En effet, le résultat des 2 maps imbriqués est un iterator produisant lui même des iterators correspondant respectivement à la colonne des 'a', puis à la colonne des 'b' est ainsi de suite.
En appliquant le splat operator * sur ce résultat, l'iterator d'iterators est développé en une série iterators, qui sont passés à chain qui produit les éléments de chaque iterator les uns à la suite des autres, passant à l'iterator suivant dès que l'iterator courant est épuisé.
De là il est facile de généraliser à une fonction proposant une nouvelle implémentation de flatmap.
|
|
Si nous l’appliquons à la génération de la liste des cases de l’échiquier
|
|
Cela nous donne bien ici encore ce que nous attendons.
|
|
Si fun est une fonction retournant un iterable, le résultat de map(fun, iterator) sera un iterator produisant des iterables. L’opérateur * permet de développer l'iterator de iterables en une série de iterables ; de là chain produira les valeurs de chacun des iterables l’un à la suite de l’autre.
Product
Néanmoins, pour réaliser un produit cartésien comme on le fait dans le cas de la liste des cases de l’échiquier, il y a une solution plus simple en exploitant toujours itertools : la fonction product qui réalise le produit cartésiens des iterables qu’on lui fournit en entrée.
|
|
Cela donne bien le résultat escompté
|
|
La fonction product est une alternative intéressante à des boucles for imbriquées.
Elle prend un ou plusieurs iterables en entrée, ainsi qu’un paramètre optionnel spécifiant le nombre de répétitions dont la valeur par défaut vaut 1.
Elle mériterait comme la plupart des fonctions de itertools qu’on s’attarde sur elle mais cela sortirait du cadre de ce billet.
Un sujet pour une autre fois.
Ne pas confondre flatmap et flatten
Un dernier point, il y a une autre opération classique sur les séquences qui peut faire un peu penser à flatmap, c’est flatten.
Elle permet d’aplatir une séquence ayant un niveau arbitrairement profond d’imbrications.
La fonction flatmap ne réalise effectivement l’aplatissement qu’au premier niveau d’imbrication et cet aplatissement est implicitement fait à la suite de la transformation d’un élément de la séquence par une fonction de transformation qui transforme cet élément en une séquence.
La fonction flatten n’effectue pas de transformation des éléments, elle se contente de supprimer toutes imbrications dans la séquence.
Ainsi la liste [1,[2,3,4],[5[6,[7]]],8,[[[9]]]] serait transformée en [1,2,3,4,5,6,7,8,9] par une fonction flatten.
Il n’existe pas de fonction flatten dans la bibliothèque standard de Python.
Comme pour product, flatten mériterait qu’on s’y attarde mais cela sortirait du cadre de ce billet.
Là encore, un sujet pour une autre fois.
Synthèse
Ce billet clos une série de billets sur le traitement des séquences avec un style fonctionnel en Python dans laquelle on a abordé filter, map et reduce.
Cela a été l’occasion de s’attarder sur la fonction flatmap et de regarder comment l’implémenter de différentes manières en Python.
Cette exploration en soit contient son propre intérêt, mais il faut reconnaitre qu’en Python le besoin de recourir à filter, map et reduce est marginal, Python proposant des alternatives plus idiomatiques.
La fonction flatmap n’existe pas en Python en tant que fonction native ou en tant que fonction de la bibliothèque standard. Il est assez facile d’un coder une implémentation en s’appuyant sur itertools si c’est nécessaire, sachant que selon le besoin, itertools a peut-être déjà une solution prête à l’emploi.
Les exemples de code du billet sont disponibles dans un gist.
Ressources
- Billet chapeau sur le traitement des séquences avec un style fonctionnel
- Billet sur filter
- Billet sur map
- Billet sur reduce
- gist des exemples du billet
- Notion de Collection Pipeline
- Avec les opérations communes sur ce Collection Pipeline :
- Article Flat map in Python
- Article Python: Equivalent to flatMap for Flattening an Array of Arrays
- RxJS Marbles
- RxPy
- ReactiveX
Auteur TGITS
Modifié 2021-05-07