Exploration de Python : traitement des séquences avec un style fonctionnel — map

Christophe Vaudry
norsys-octogone
Published in
9 min readJun 28, 2024

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 filterf 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 mapet 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.

Synthèse du fonctionnement de map sur l’exemple précédent

A comparer avec le fonctionnement de starmap.

Synthèse du fonctionnement de starmap sur l’exemple précédent

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 repeatde 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.

--

--

Christophe Vaudry
norsys-octogone

Developer working for Norsys. Programming languages explorer. Know nothing.