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 iterable
s et retourne un iterator
.
Cet iterator
est en quelque sorte la concaténation des iterable
s 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 iterator
s 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'iterator
s est développé en une série iterator
s, 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 iterable
s. L’opérateur *
permet de développer l'iterator
de iterable
s en une série de iterable
s ; de là chain
produira les valeurs de chacun des iterable
s 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 iterable
s 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 iterable
s 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