Définition
Croiser deux listes de valeurs, les enchaîner l'une après l'autre, ou repérer les paires consécutives d'une série de mesures : ces besoins reviennent sans cesse, chacun avec sa propre boucle. Le vrai coût, c'est la liste intermédiaire que chaque boucle construit en mémoire avant de pouvoir s'en servir, quand le programme n'a souvent besoin que d'une valeur à la fois.
itertools est le module de la bibliothèque standard qui répond à ce problème : des fonctions qui reçoivent un itérable et en renvoient un autre, en calculant chaque valeur à la demande plutôt qu'en construisant le résultat entier d'un coup. Rien à installer, il fait partie de Python depuis toujours. Voici ce que ça donne avec combinations :
from itertools import combinations
equipe = ["Ana", "Bilal", "Chloe", "Dan"]
for binome in combinations(equipe, 2):
print(binome)
# ('Ana', 'Bilal')
# ('Ana', 'Chloe')
# ('Ana', 'Dan')
# ...Chaque fonction renvoie donc un itérateur : un objet vide à sa création, qui produit ses valeurs une par une. C'est ce qui permet de travailler sur des séquences plus longues que la mémoire disponible, et c'est aussi la source du piège le plus commun du module, décrit plus bas.
Ce qu'il remplace vraiment
Rien de ce que propose itertools n'est impossible à écrire à la main : quelques lignes de boucles suffisent. Le gain n'est pas une capacité nouvelle, mais une lecture plus rapide : une fonction nommée dit ce qu'elle fait, là où une double boucle oblige à reconstituer l'intention.
Le tableau suivant met en face de chaque fonction la boucle qu'elle évite d'écrire.
| Fonction | Ce qu'elle évite d'écrire |
|---|---|
chain(a, b) | Une boucle par source, l'une après l'autre |
product(a, b) | Une for imbriquée dans une autre, plus une par dimension ajoutée |
combinations(a, 2) | Une double boucle sur les rangs, plus un test pour ne pas compter deux fois la même paire |
islice(flux, 10) | Un compteur et une sortie manuelle pour s'arrêter au dixième élément |
pairwise(mesures) | Un accès à l'élément suivant par son rang, avec le dépassement qui va avec |
Le cas le plus parlant reste product. Croiser quatre réglages revient à empiler quatre boucles imbriquées, et à en ajouter une si un cinquième apparaît. Avec product, la liste des réglages devient un simple argument : ajouter une dimension ne touche plus au code, seulement à l'entrée.
Le piège de l'itérateur à usage unique
C'est l'erreur que commettent presque tous ceux qui découvrent le module, et elle ne lève aucune exception : le code s'exécute sans broncher. Un itérateur se consomme, aucune valeur n'est conservée par défaut, et le parcourir deux fois revient à repartir de rien.
Voici ce que cela donne dans la pratique :
from itertools import chain
tout = chain([1, 2], [3, 4])
print(list(tout)) # [1, 2, 3, 4]
print(list(tout)) # []La variante visuelle est tout aussi fréquente : afficher l'objet directement donne <itertools.chain object at 0x10f3a2b40> plutôt que les valeurs attendues. Ce n'est pas un bug, c'est le prix de la production à la demande.
La parade tient en une règle simple : convertir en liste avec list() dès que le résultat doit servir deux fois, et laisser l'itérateur tel quel sinon.
groupby ne groupe pas ce que l'on croit
Le nom groupby laisse penser qu'il rassemble toutes les occurrences d'une même clé. Ce n'est pas le cas : il ne regroupe que les éléments consécutifs qui la partagent, rien de plus. Dès qu'une autre clé s'intercale, le regroupement recommence de zéro.
Voici ce que ça donne sur des ventes non triées par ville :
from itertools import groupby
ventes = [("Paris", 10), ("Lyon", 4), ("Paris", 7)]
for ville, lignes in groupby(ventes, key=lambda v: v[0]):
print(ville, [ligne[1] for ligne in lignes])
# Paris [10]
# Lyon [4]
# Paris [7]Paris ressort deux fois plutôt qu'une, séparé par Lyon. Le correctif consiste à trier sur la même clé avant d'appeler groupby, en réutilisant la même lambda des deux côtés.
Oublier ce tri ne provoque aucune erreur : le code s'exécute et renvoie un résultat qui a l'air correct, simplement faux. C'est ce qui rend l'erreur coûteuse à repérer une fois en production.
Ce n'est pas une maladresse : groupby ne garde jamais plus d'un élément en mémoire, ce qui lui permet de grouper un journal de plusieurs gigaoctets déjà trié par date sans le charger en entier. Sur des données non triées et petites, un dictionnaire par clé fait aussi bien, plus simplement.
Quand il ne sert à rien
Un module qui propose autant de fonctions donne envie d'en placer une à chaque occasion, ce qui est le mauvais réflexe. Sur une séquence parcourue une seule fois, une compréhension de liste se lit mieux que starmap ou filterfalse, et zip avec enumerate couvrent déjà l'essentiel sans rien importer.
L'argument de la mémoire ne pèse qu'à partir d'un certain volume. Sur mille éléments, la version paresseuse et la version qui construit une liste se valent, et la seconde se relit mieux. Il prend son sens sur les flux longs, les gros fichiers et les suites sans fin.
Questions fréquentes
Faut-il l'installer avant de s'en servir ?
Non, il fait partie de la bibliothèque standard depuis Python 2.3, et un simple import suffit. Une recherche du même nom sur pip ramènerait au mieux un homonyme abandonné, au pire tout autre chose.
Quelle différence avec un générateur écrit à la main ?
Aucune sur le principe : un générateur produit lui aussi ses valeurs à la demande. La différence est pratique : ces fonctions sont écrites en C, plus rapides que leur équivalent Python, et surtout déjà nommées, testées et reconnues par qui relira le code six mois plus tard.
Pourquoi ma boucle sur count() ne s'arrête-t-elle jamais ?
Parce que count, cycle et repeat produisent des suites sans fin : aucune StopIteration ne viendra les arrêter. La limite se pose à la main, avec islice pour un nombre fixe d'éléments, avec takewhile si la condition dépend des valeurs, ou avec une sortie explicite dans la boucle.