Exploration de Python : traitement des séquences avec un style fonctionnel — map
Contexte
Ce billet s’inscrit dans une série de billets sur le traitement des séquences avec un style fonctionnel en Python dont le premier portait sur la fonction filter
.
Ce billet est donc le deuxième dans la série et traite de la fonction map
.
Les exemples de ce billet ont été testés avec la version 3.12 de Python.
Principe
La fonction map
est une fonction native tout comme filter
f et prend en paramètre une fonction et une séquence d'éléments comme cette dernière.
La fonction map
permet d'appliquer cette fonction passée en paramètre à chaque élément de la séquence et produit une nouvelle séquence résultante dont chaque élément est le résultat de l'application de cette fonction sur chaque élément de la séquence initiale.
Ainsi map
produit une nouvelle séquence dont chaque élément est l'élément correspondant de la première liste sur laquelle on a appliquée la fonction passée en paramètre.
Tout comme filter
, la séquence retournée par la fonction map
est un objet iterator
.
Voyons tout cela à travers un exemple simple dans lequel on transforme une séquence de nombres entiers en une séquence des carrés correspondants.
>>> powers_of_2 = map(lambda x: x**2, range(1, 10))
>>> powers_of_2
<map object at 0x00000218F9D9A4D0>
>>> next(powers_of_2)
1
>>> next(powers_of_2)
4
>>> print(*powers_of_2)
9 16 25 36 49 64 81
On note qu’on utilise ici une lambda
pour la fonction qui élève à la puissance.
Comparaison avec les boucles for et les compréhensions
L’équivalent avec une boucle for
pourrait s'écrire comme suit :
print("L'équivalent avec une boucle for : ", end="")
powers_of_2 = []
for number in range(1, 10):
powers_of_2.append(number**2)
print(powers_of_2)
L'équivalent avec une boucle for : [1, 4, 9, 16, 25, 36, 49, 64, 81]
Ce n’est pas strictement équivalent car comme on peut le constater dans l’exemple, map
retourne un iterator
. Pour faire quelque chose de vraiment équivalent, il faudrait utiliser une fonction génératrice comme ci-après.
def my_map(transformation_function, sequence):
for elt in sequence:
yield transformation_function(elt)
powers_of_two = my_map(lambda x: x**2, range(1, 11))
print(powers_of_two)
print(next(powers_of_two))
print(list(powers_of_two))
Qui après exécution donne quelque chose de similaire à ce qui suit :
PS map> python .\map_implementation_with_generator.py
<generator object my_map at 0x000001AFD17692A0>
1
[4, 9, 16, 25, 36, 49, 64, 81, 100]
Bien sûr on peut écrire du code équivalent sous forme de compréhension, ce qui est considéré comme un style plus pythonique :
>>> generator_of_powers_of_2_for_first_numbers = (x**2 for x in range(1, 10))
>>> generator_of_powers_of_2_for_first_numbers
<generator object <genexpr> at 0x0000027EFED42330>
>>> next(generator_of_powers_of_2_for_first_numbers)
1
>>> next(generator_of_powers_of_2_for_first_numbers)
4
>>> print(*generator_of_powers_of_2_for_first_numbers)
9 16 25 36 49 64 81
La fonction map retourne un iterator
La fonction map
retourne un iterator
(comme la fonction filter
) comme cela a été évoqué dans le paragraphe précédent. Ainsi, le retour de la fonction map
peut être manipulé comme tel. On peut par exemple utiliser le résultat directement avec les fonctions (natives) sum
, max
ou min
.
>>> sum(map(lambda x: x**2, range(1, 10)))
285
>>> # Maximum des carrés des 9 premiers entiers strictement positifs
>>> max(map(lambda x: x**2, range(1, 10)))
81
>>> # Minimum des carrés des 9 premiers entiers strictement positifs
>>> min(map(lambda x: x**2, range(1, 10)))
1
Et si nous voulons une liste ou un ensemble à partir du résultat de notre map
, il faut le convertir vers le type approprié. Par exemple pour transformer le résultat de map
vers une liste :
>>> list(map(lambda x: x**2, range(1, 10)))
[1, 4, 9, 16, 25, 36, 49, 64, 81]
Ou encore pour transformer le résultat de map
en un ensemble :
>>> set(map(lambda x: x**2, range(-9, 10)))
{64, 1, 0, 36, 4, 9, 16, 81, 49, 25}
On peut également convertir l’ iterator
obtenu avec map
vers un dictionnaire si ses éléments correspondent à des paires, de la même manière que l'on peut convertir une liste de paires vers un dictionnaire.
Dans l’exemple ci-dessus, la fonction passé à map
retourne un tuple
et non plus juste une valeur scalaire. Cela nous donne le résultat ci-après.
>>> # Tableau associatif des carrés des 9 premiers entiers strictement positifs
>>> dict(map(lambda x: (x, x**2), range(1, 10)))
{1: 1, 2: 4, 3: 9, 4: 16, 5: 25, 6: 36, 7: 49, 8: 64, 9: 81}
Il est possible tout comme avec filter
, les compréhensions) et de manière générale avec les iterator
en Python, de manipuler des séquences potentiellement infinies (en exploitant par exemple le module itertools
). Voici un exemple :
>>> import itertools
>>> powers_of_2 = map(lambda x: x**2, itertools.count(1))
>>> next(powers_of_2)
1
>>> next(powers_of_2)
4
>>> print(*itertools.takewhile(lambda x: x < 1000, powers_of_2))
9 16 25 36 49 64 81 100 121 144 169 196 225 256 289 324 361 400 441 484 529 576 625 676 729 784 841 900 961
>>> next(powers_of_2)
1089
En utilisant ici la fonction count
du module itertools
, on génère un iterator
infini de nombres entiers à partir de 1 et on applique la fonction map
dessus. Avec la fonction takewhile
, on prend des valeurs de l'iterator
générées par map
tant que les valeurs sont strictement inférieures à 1000.
Combiner map et filter
Avec les compréhensions on peut effectuer un filtrage sur les éléments. Avec map
on ne peut pas le faire aussi directement que dans une compréhension mais on peut bien sûr combiner map
et filter
pour avoir le même résultat.
>>> first_event_numbers = filter(lambda x: x % 2 == 0, range(1, 10))
>>> list(map(lambda x: x**2, first_event_numbers))
[4, 16, 36, 64]
Ici on a fait le choix de travailler en 2 temps, en créant une variable intermédiaire pour l’ iterator
produit par filter
. Bien sûr on pourrait passer directement filter
comme paramètre de map
. Néanmoins les expressions de cette forme deviennent vite peu lisibles.
>>> list(map(lambda x: x**2, filter(lambda x: x % 2 == 0, range(1, 10))))
[4, 16, 36, 64]
Contrairement à des langages fonctionnelles comme Elixir ou Clojure, il n’y a pas de sucre syntaxique comme le pipe operator |>
ou des threading macros comme ->>
pour faciliter l'écriture d'une chaîne d'opération sur des iterator
. De plus, on ne peut pas enchainer les fonctions comme map
, filter
, reduce
ou les fonctions de itertools
comme on le ferait avec les stream en Java par exemple, car justement ce sont des fonctions, pas des méthodes de l'objet iterator
. Il est donc souvent préférable pour des questions de lisibilité de passer par des variables intermédiaires pour éviter d'avoir des appels de fonctions imbriqués.
Il y a plusieurs manières d’obtenir l’équivalent d’une boucle ou d’une compréhension imbriquée avec map
, mais cela implique d'autres fonctions que juste map
. Ce point sera développé dans le billet sur flatmap
.
La fonction map en Python peut traiter plusieurs itérables
La fonction map
en Python peut en fait prendre plusieurs itérables, pas juste un seul. Il faut que la fonction qu'applique map
prenne elle-même en paramètre autant d'arguments qu'il y a d'itérables fournis. Chaque élément séquence produite par map
est le résultat de l'application de la fonction sur les éléments correspondants des différents itérables. Un exemple pour clarifier :
>>> print(*map(lambda x, y, z: x+y+z, ['1', '2', '3'], ['A', 'B', 'C'], ['a', 'b', 'c']))
1Aa 2Bb 3Cc
La fonction lambda prend 3 arguments et réalise leur concaténation en prenant respectivement le premier élément de chacun des itérables, puis le second, etc.
Avec une compréhension, vous obtiendriez le même résultat avec un code similaire à ce qui suit :
>>> print(*[x+y+z for x,y,z in zip(['1', '2', '3'], ['A', 'B', 'C'], ['a', 'b', 'c'])])
1Aa 2Bb 3Cc
On notera l’utilisation de la fonction zip
dans la compréhension, c'est ce que fait map
avec plusieurs itérables : d'une certaine manière, ils sont zippés implicitement.
Dans itertools
, il existe une variante de le fonction map
qui comme dans l'exemple de la compréhension fonctionnerait à partir d'un ensemble d'itérables zippés ou à partir d'un itérable de tuples, la fonction starmap
.
>>> from itertools import starmap
>>> print(*starmap(lambda x, y, z: x+y+z, zip(['1', '2', '3'], ['A', 'B', 'C'], ['a', 'b', 'c'])))
1Aa 2Bb 3Cc
ou à partir d’une liste de tuples directement
from itertools import starmap
print(*starmap(lambda x, y, z: x+y+z, [('1', 'A', 'a'), ('2', 'B', 'b'), ('3', 'C', 'c')]))
Ce qui dans un cas comme dans l’autre, donnerait le même résultat qu’avec map
.
Pour résumer le fonctionnement de map
avec cet exemple.
A comparer avec le fonctionnement de starmap
.
Si vous avez plusieurs itérables à partir desquels vous souhaiteriez produire une nouvelle séquence en fonction de leurs éléments de même indice, map
peut vous éviter d'utiliser la fonction zip
et c'est peut-être un exemple où vous la préférerez à l'utilisation d'une compréhension.
Si vous avez directement un itérable avec un tuple d’éléments à partir duquel vous voulez produire une nouvelle séquence dont les éléments sont construits à partir des composants du tuple, starmap
peut être à envisager.
Attardons nous sur quelques autres exemples inspirés de la documentation de la bibliothèque standard de Python.
Si nous voulons avoir une liste des nombres entre 1 et 9 élevés à la puissance d’eux-mêmes, on pourrait écrire quelque chose de la forme :
>>> print("x puissance x, pour x entier de 1 à 9 : ", *map(pow, range(1, 10), range(1, 10)))
x puissance x, pour x entier de 1 à 9 : 1 4 27 256 3125 46656 823543 16777216 387420489
On peut également travailler avec des séquences (potentiellement) infinies. La fonction repeat
de itertools
produit une séquence infinie de la valeur qu'on lui a passé en paramètre. Pour produire la liste des puissance de 2 des nombres de 1 à 9, on pourrait écrire quelque chose de la forme :
>>> from itertools import repeat
>>> print("les 9 premiers chiffres à la puissance 2 : ", *map(pow, range(1, 10), repeat(2)))
les 9 premiers chiffres à la puissance 2 : 1 4 9 16 25 36 49 64 81
Comme pour la fonction zip
, c'est la plus courte des 2 séquences qui déterminera la taille de la séquence produite. La fonction repeat(2)
produit ici une liste potentiellement infini de 2
mais seulement 9 valeurs se retrouveront utilisées pour la liste finale produite par map
.
Il est également possible que toutes les séquences passées à map
soit infinies. En partant de l'exemple précédent, une manière d'avoir la liste des puissances des nombres entiers à partir de 1 et d'afficher les 20 premiers pourrait être la suivante :
>>> from itertools import repeat, count, islice
>>> powers_of_2 = map(pow, count(1), repeat(2))
>>> print(
... "[map] Les puissances de 2 pour les 20 premiers nombres à partir de 1 : ",
... *islice(powers_of_2, 0, 20),
... )
[map] Les puissances de 2 pour les 20 premiers nombres à partir de 1 : 1 4 9 16 25 36 49 64 81 100 121 144 169 196 225 256 289 324 361 400
La fonction count
de itertools
génère une liste de valeurs à partir d'une valeur de départ (entière ou réelle) et d'un pas de progression (lui aussi entier ou réel), la valeur par défaut de ce pas étant la valeur 1
(qui est la valeur du pas dans l’exemple ci-dessus). Ici count(1)
produit la séquence 1, 2, 3, ...
. On utilise islice
(fonction venant elle aussi de itertools
) pour extraire l'intervalle des 20 premiers éléments de l'iterator
produit par map
: on précise en paramètre l'itérable (ici produit par la fonction map
), la valeur de départ de l’index pour l’intervalle et l'index de fin (valeur non incluse).
Synthèse
La fonction map
est une fonction native de Python. Elle prend en paramètre une fonction et un iterator
; elle retourne un nouvel iterator
dont chaque élément est le résultat de l'application de cette fonction sur chaque élément de la séquence initiale. La fonction map
peut être vue comme l'abstraction d'une boucle sur une liste pour appliquer une fonction sur chaque élément de la liste et produire une nouvelle liste résultante. La fonction map
en Python peut prendre plusieurs itérables en paramètres.
Il est bien sûr possible de combiner map
avec filter
pour filtrer avant d'appliquer map
ou au contraire après sur l'iterator
produit par map
.
Il y a bien sûr un gist avec les sources des exemples.
J’avais publié une première version de ce billet sur mon blog personnel.
Remerciement
Je remercie mon collègue Anthony Tenneriello pour sa relecture attentive et ses remarques.