Contexte

Ce billet s’inscrit dans une série de billets sur le traitement des séquences avec un style fonctionnel en Python.

NB1 : La version de Python utilisée dans les exemples de code est la version 3.

NB2 : Ce billet a été modifié le 24/05/2021, par l’ajout d’un paragraphe sur le comportement de map avec plusieurs itérables.

Principe

La fonction map est native tout comme filter et prend également en paramètre une fonction et une séquence d’éléments.

La fonction map permet d’appliquer cette fonction à 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.

Principe de map

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def squared(x): return x**2

iterator_of_powers_of_2_for_first_numbers = map(squared, range(1, 10))
print("La fonction map retourne un objet iterator :", iterator_of_powers_of_2_for_first_numbers)
print("C'est bien un objet iterator avec les méthodes __iter__ et __next__")
print("Il possède la fonction __iter__ ('__iter__' in dir(iterator_of_powers_of_2_for_first_numbers)) :", '__iter__' in dir(iterator_of_powers_of_2_for_first_numbers))
print("Il possède la fonction __next__ ('__next__' in dir(iterator_of_powers_of_2_for_first_numbers)) :", '__next__' in dir(iterator_of_powers_of_2_for_first_numbers))
print("Premier élément :", next(iterator_of_powers_of_2_for_first_numbers))
print("Elément suivant :", next(iterator_of_powers_of_2_for_first_numbers))
print("La suite des éléments : ", end = '')
print(*iterator_of_powers_of_2_for_first_numbers)

Si vous exécutez ce code avec Python vous obtiendriez quelque chose de similaire à ce qui suit :

1
2
3
4
5
6
7
La fonction map retourne un objet iterator : <map object at 0x000002AB8739BA00>
C'est bien un objet iterator avec les méthodes __iter__ et __next__
Il possède la fonction __iter__ ('__iter__' in dir(iterator_of_powers_of_2_for_first_numbers)) : True
Il possède la fonction __next__ ('__next__' in dir(iterator_of_powers_of_2_for_first_numbers)) : True
Premier élément : 1
Elément suivant : 4
La suite des éléments : 9 16 25 36 49 64 81

Comparaison avec les boucles for et les compréhensions

L’équivalent avec une boucle for pourrait s’écrire comme suit :

1
2
3
4
5
print("L'équivalent avec une boucle for : ", end='')
powers_of_2 = []
for number in range(1,10):
    powers_of_2.append(squared(number))
print(powers_of_2)

Qui produirait l’affichage suivant :

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

1
2
3
4
5
6
7
8
9
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 :

1
2
3
<generator object my_map at 0x000002239CD9B270>
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 car plus lisible :

1
2
3
4
5
generator_of_powers_of_2_for_first_numbers = (x**2 for x in range(1, 10))
print(generator_of_powers_of_2_for_first_numbers)
print(next(generator_of_powers_of_2_for_first_numbers))
print(next(generator_of_powers_of_2_for_first_numbers))
print(*generator_of_powers_of_2_for_first_numbers)

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.

1
2
3
print("Somme des carrés des 9 premiers entiers strictement positifs :", sum(map(squared, range(1, 10))))
print("Maximum des carrés des 9 premiers entiers strictement positifs :", max(map(squared, range(1, 10))))
print("Minimum des carrés des 9 premiers entiers strictement positifs :", min(map(squared, range(1, 10))))

Ce qui donne :

1
2
3
Somme des carrés des 9 premiers entiers strictement positifs : 285
Maximum des carrés des 9 premiers entiers strictement positifs : 81
Minimum des carrés des 9 premiers entiers strictement positifs : 1

Et si nous voulons une liste ou un ensemble à partir du résultat de note map, il faut le convertir vers le type approprié. Par exemple pour transformer le résultat de map vers une liste :

1
2
iterator_of_powers_of_2_for_first_numbers = map(squared, range(1, 10))
print("Liste des carrés des 9 premiers entiers strictement positifs :", list(iterator_of_powers_of_2_for_first_numbers))
1
Liste des carrés des 9 premiers entiers strictement positifs : [1, 4, 9, 16, 25, 36, 49, 64, 81]

Ou encore pour transformer le résultat de map en un ensemble :

1
2
iterator_of_powers_of_2 = map(squared, range(-9, 10))
print("Ensemble des carrés des entiers entre -9 et 9 :", set(iterator_of_powers_of_2))
1
Ensemble des carrés des entiers entre -9 et 9 : {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.

1
2
3
4
def squared_pair(x): return (x, x**2)

iterator_powers_of_2_for_first_numbers = map(squared_pair, range(1, 10))
print("Tableau associatif des carrés des 9 premiers entiers strictement positifs :",dict(iterator_powers_of_2_for_first_numbers))

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.

1
Tableau associatif des carrés des 9 premiers entiers strictement positifs : {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 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import itertools


neverending_iterator_of_powers_of_2 = map(squared, itertools.count(1))
print("Premier élément :", next(neverending_iterator_of_powers_of_2))
print("Elément suivant :", next(neverending_iterator_of_powers_of_2))
print("La suite des éléments pour les entiers inférieurs à 1000 : ", end='')
print(*itertools.takewhile(lambda x: x < 1000,
                           neverending_iterator_of_powers_of_2))
print("Elément suivant :", next(neverending_iterator_of_powers_of_2))

En utilisant ici la fonction count du module itertools, je génère un iterator infini de nombres entiers à partir de 1 et j’applique la fonction map dessus. Avec la fonction takewhile, je prends des valeurs de l'iterator généré par map tant que les valeurs sont strictement inférieures à 1000.

1
2
3
4
Premier élément : 1
Elément suivant : 4
La suite des éléments pour les entiers inférieurs à 1000 : 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
Elément suivant : 1089

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.

1
2
3
4
first_event_numbers = filter(lambda x: x % 2 == 0, range(1, 10))
print("Un objet _filter_, first_event_numbers :", first_event_numbers)
list_powers_of_2_for_first_event_numbers = map(squared, first_event_numbers)
print("Les premiers carrés :", list(list_powers_of_2_for_first_event_numbers))

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.

1
2
Un objet _filter_, first_event_numbers : <filter object at 0x0000020305D6CBE0>
Les premiers carrés : [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 est 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 élement séquence produite par map est résultat de l’application de la fonction sur les éléments correspondants des différents itérables. Un exemple pour clarifier :

1
2
print(*map(lambda x, y, z: x+y+z,
           ['1', '2', '3'], ['A', 'B', 'C'], ['a', 'b', 'c']))

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.

1
1Aa 2Bb 3Cc

Avec une compréhension, vous obtiendriez le même résultat avec un code similaire à ce qui suit :

1
print(*[x+y+z for x,y,z in zip(['1', '2', '3'], ['A', 'B', 'C'], ['a', 'b', 'c'])])

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.

1
2
3
4
from itertools import starmap


print(*starmap(lambda x, y, z: x+y+z, zip(['1', '2', '3'], ['A', 'B', 'C'], ['a', 'b', 'c'])))

ou à partir d’une liste de tuples directement

1
2
3
4
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.

Fonction map avec plusieurs itérables

A comparer avec le fonctionnement de starmap.

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 :

1
print("x puissance x, pour x entier de 1 à 9 : ", *map(pow, range(1, 10), range(1, 10)))

Ce qui nous donnerait l’affichage suivant :

1
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 :

1
print("les 9 preniers chiffres à la puissance 2 : ", *map(pow, range(1, 10), repeat(2)))

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.

1
les 9 preniers chiffres à la puissance 2 :  1 4 9 16 25 36 49 64 81

Il est également possible que toutes les séquences passées à map soit infinies. En partant de l’exemple précédcent, 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 :

1
2
3
4
5
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))

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 sera la valeur du pas dans notre exemple). Ici count(1) produit la séquence 1, 2, 3, .... On utilise islice toujours de itertools pour extraire l’intervalle des 20 premiers éléments de l'iterator produit par map, en précisant l’itérable duquel sélectionné la valeur de départ, l’index de départ et l’index de fin (non-inclus).

1
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

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.

L’ensemble du code du billet est disponible dans un gist.

Ressources