Un itérable est un objet dont on peut parcourir les valeurs, à l’aide d’un for
par exemple.
La liste que nous venons d’implémenter est un exemple d’itérable.
Les types str
, tuple
, list
, dict
et set
sont d’autres itérables bien connus.
Un grand nombre d’outils Python que nous verrons par la suite travaillent avec des itérables, il est donc intéressant d’en tirer profit.
L’objectif de ce chapitre va être de comprendre ce qu’est un itérable, et comment en implémenter un.
- for for lointain
- Utilisation des iterables
- Utilisation avancée : le module itertools
- L'unpacking
- TP : Itérateur sur listes chaînées
for for lointain
Les itérables et le mot-clef for
sont intimement liés. C’est à partir de ce dernier que nous itérons sur les objets.
Mais comment cela fonctionne en interne ? Je vous propose de regarder ça pas à pas, en nous aidant d’un objet de type list
.
1 | >>> numbers = [1, 2, 3, 4, 5] |
La première opération réalisée par le for
est d’appeler la fonction iter
avec notre objet.
iter
retourne un itérateur. L’itérateur est l’objet qui va se déplacer le long de l’itérable.
1 2 | >>> iter(numbers) <list_iterator object at 0x7f26896c0940> |
Puis, pas à pas, le for
appelle next
en lui précisant l’itérateur.next
fait avancer l’itérateur et retourne la nouvelle valeur découverte à chaque pas.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | >>> iterator = iter(numbers) >>> next(iterator) 1 >>> next(iterator) 2 >>> next(iterator) 3 >>> next(iterator) 4 >>> next(iterator) 5 >>> next(iterator) Traceback (most recent call last): File "<stdin>", line 1, in <module> StopIteration |
Qu’est-ce que ce StopIteration
? Il s’agit d’une exception, levée par l’itérateur quand il arrive à sa fin, qui signifie que nous en sommes arrivés au bout, et donc que la boucle doit cesser. for
attrape cette exception pour nous, ce qui explique que nous ne la voyons pas survenir dans une boucle habituelle.
Ainsi, le code suivant :
1 2 | for number in numbers: print(number) |
Peut se remplacer par celui-ci :
1 2 3 4 5 6 7 | iterator = iter(numbers) while True: try: number = next(iterator) except StopIteration: break print(number) |
En interne, iter
fait habituellement appel à la méthode __iter__
de l’itérable, et next
à la méthode __next__
de l’itérateur.
Ces deux méthodes ne prennent aucun paramètre. Ainsi :
- Un itérable est un objet possédant une méthode
__iter__
1 retournant un itérateur ; - Un itérateur est un objet possédant une méthode
__next__
retournant la valeur suivante à chaque appel, et levant une exception de typeStopIteration
en fin de course.
La documentation Python indique aussi qu’un itérateur doit avoir une méthode __iter__
où il se retourne lui-même, les itérateurs étant ainsi des itérables à part entièe.
Le cas des indexables
En début du chapitre, j’ai indiqué que notre liste Deque
était aussi un itérable. Pourtant, nous ne lui avons pas implémenté de méthode __iter__
permettant de la parcourir.
Il s’agit en fait d’une particularité des indexables, et de la fonction iter
qui est capable de créer un itérateur à partir de ces derniers.
Cet itérateur se contentera d’appeler __getitem__
sur notre objet avec des indices successifs, partant de 0 et continuant jusqu’à ce que la méthode lève une IndexError
.
Dans notre cas, ça nous évite donc d’implémenter nous-même __iter__
, mais ça complexifie aussi les traitements.
Souvenez-vous de notre méthode __getitem__
: elle parcourt la liste jusqu’à l’élément voulu.
Ainsi, pour accéder au premier maillon, on parcourt un élément, on en parcourt deux pour accéder au second, etc.
Donc pour itérer sur une liste de 5 éléments, on va devoir parcourir 1 + 2 + 3 + 4 + 5
soit 15 maillons, là où 5 seraient suffisants.
C’est pourquoi nous reviendrons sur Deque
en fin de chapitre pour lui intégrer sa propre méthode __iter__
.
-
À une approximation près, comme détaillé dans « Le cas des indexables ». ↩
Utilisation des iterables
Python et les itérables
Ce concept d’itérateurs est utilisé par Python dans une grande partie des ses builtins. Plutôt que de vous forcer à utiliser une liste, Python vous permet de fournir un objet itérable, pour sum
, max
ou map
par exemple.
Je vous propose de tester cela avec un itérable basique, qui nous permettra de réaliser un range
simplifié.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | class MyRange: def __init__(self, size): self.size = size def __iter__(self): return MyRangeIterator(self) class MyRangeIterator: def __init__(self, my_range): self.current = 0 self.max = my_range.size def __iter__(self): return self def __next__(self): if self.current >= self.max: raise StopIteration ret = self.current self.current += 1 return ret |
Maintenant, testons notre objet, en essayant d’itérer dessus à l’aide d’un for
.
1 2 3 4 5 6 7 8 9 10 | >>> MyRange(5) <__main__.MyRange object at 0x7fcf3b0e8f28> >>> for i in MyRange(5): ... print(i) ... 0 1 2 3 4 |
Voilà pour l’itération, mais testons ensuite quelques autres builtins dont je parlais plus haut.
1 2 3 4 5 6 | >>> sum(MyRange(5)) # sum réalise la somme de tous les éléments, soit 0 + 1 + 2 + 3 + 4 10 >>> max(MyRange(5)) # max retourne la plus grande valeur 4 >>> map(str, MyRange(5)) # Ici, map retournera chaque valeur convertie en str <map object at 0x7f8b81226cf8> |
Mmmh, que s’est-il passé ? En fait, map
ne retourne pas une liste, mais un nouvel itérateur. Si nous voulons en voir le contenu, nous pouvons itérer dessus… ou plus simplement, convertir le résultat en liste :
1 2 | >>> list(map(str, MyRange(5)) ['0', '1', '2', '3', '4'] |
Vous l’aurez compris, list
prend aussi n’importe quel itérable en argument, tout comme zip
ou str.join
par exemple.
1 2 3 4 5 6 | >>> list(MyRange(5)) [0, 1, 2, 3, 4] >>> list(zip(MyRange(5), 'abcde')) # Les chaînes sont aussi des itérables [(0, 'a'), (1, 'b'), (2, 'c'), (3, 'd'), (4, 'e')] >>> ', '.join(map(str, MyRange(5))) '0, 1, 2, 3, 4' |
J’en resterai là pour les exemples, sachez seulement que beaucoup de fonctions sont compatibles. Seules celles nécessitant des propriétés spécifiques de l’objet ne le seront pas par défaut, comme la fonction reversed
.
Retour sur iter
Je voudrais ici revenir sur la fonction iter
, qui crée un itérateur à partir d’un itérable.
Sachez que ce n’est pas sa seule utilité. Elle peut aussi créer un itérateur à partir d’une fonction et d’une valeur de fin.
C’est-à-dire que la fonction sera appelée tant que la valeur de fin n’a pas été retournée, par exemple :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | >>> n = 0 >>> def iter_func(): ... global n ... n += 1 ... return n ... >>> for i in iter(iter_func, 10): ... print(i) ... 1 2 3 4 5 6 7 8 9 |
Utilisation avancée : le module itertools
Nous l’avons vu, les itérables sont au cœur des fonctions élémentaires de Python. Je voudrais maintenant vous présenter un module qui vous sera propablement très utile : itertools
.
Ce module met à disposition de nombreux itérables plutôt variés, dont :
chain(p, q, ...)
— Met bout à bout plusieurs itérables ;islice(p, start, stop, step)
— Fait un travail semblable aux slices, mais en travaillant avec des itérables (nul besoin de pouvoir indexer notre objet) ;combinations(p, r)
— Retourne toutes les combinaisons der
éléments possibles dansp
;zip_longest(p, q, ...)
— Similaire àzip
, mais s’aligne sur l’itérable le plus grand plutôt que le plus petit (en permettant de spécifier une valeur de remplissage).
1 2 3 4 5 6 7 8 9 10 11 | >>> import itertools >>> itertools.chain('abcd', [1, 2, 3]) <itertools.chain object at 0x7f757b508c88> >>> list(itertools.chain('abcd', [1, 2, 3])) ['a', 'b', 'c', 'd', 1, 2, 3] >>> list(itertools.islice(itertools.chain('abcd', [1, 2, 3]), 1, None, 2)) ['b', 'd', 2] >>> list(itertools.combinations('abc', 2)) [('a', 'b'), ('a', 'c'), ('b', 'c')] >>> list(itertools.zip_longest('abcd', [1, 2, 3])) [('a', 1), ('b', 2), ('c', 3), ('d', None)] |
Je tiens enfin à attirer votre attention sur les recettes (recipes), un ensemble d’exemples qui vous sont proposés mettant à profit les outils présents dans itertools
.
L'unpacking
Une fonctionnalité courante de Python, liée aux itérables, est celle de l’unpacking. Il s’agit de l’opération qui permet de décomposer un itérable en plusieurs variables.
Prenons values
une liste de 3 valeurs, il est possible en une ligne d’assigner chaque valeur à une variable différente.
1 2 3 4 5 6 7 8 | >>> values = [1, 3, 5] >>> a, b, c = values >>> a 1 >>> b 3 >>> c 5 |
J’utilise ici une liste values
, mais tout type d’itérable est accepté, on pourrait avoir un range
ou un set
par exemple.
L’itérable n’a pas besoin d’être une séquence comme la liste.
1 2 | >>> a, b, c = range(1, 6, 2) >>> a, b, c = {1, 3, 5} # l'ordre n'est pas assuré dans ce dernier cas |
C’est aussi cette fonctionnalité qui est à l’origine de l’assignement multiple et de l’échange de variables.
1 2 3 4 5 6 7 8 9 10 | >>> x, y = 10, 20 >>> x 10 >>> y 20 >>> x, y = y, x >>> x 20 >>> y 10 |
En effet, nous avons dans ces deux cas, à gauche comme à droite du signe =
, des tuples.
Et celui de droite est décomposé pour correspondre aux variables de gauche.
Je parle de tuples, mais on retrouve la même chose avec des listes. Les assignations suivantes sont d’ailleurs équivalentes.
1 2 3 | >>> x, y = 10, 20 >>> (x, y) = (10, 20) >>> [x, y] = [10, 20] |
Structures imbriquées
Ces cas d’unpacking sont les plus simples : nous avons un itérable à droite et un ensemble « plat » de variables à gauche. Je dis « plat » parce qu’il n’y a qu’un niveau, aucune imbrication.
Mais il est possible de faire bien plus que cela, en décomposant aussi des itérables imbriqués les uns dans les autres.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | >>> a, ((b, c, d), e), (f, g) = [0, (range(1, 4), 5), '67'] >>> a 0 >>> b 1 >>> c 2 >>> d 3 >>> e 5 >>> f '6' >>> g '7' |
Opérateur splat
Mais on peut aller encore plus loin avec l’opérateur splat.
Cet opérateur est représenté par le caractère *
.
À ne pas confondre avec la multiplication, opérateur binaire entre deux objets, il s’agit ici d’un opérateur unaire : c’est-à-dire qu’il n’opère que sur un objet, en se plaçant devant.
Utilisé à gauche lors d’une assignation, il permet de récupérer plusieurs éléments lors d’une décomposition.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | >>> head, *tail = range(10) >>> head 0 >>> tail [1, 2, 3, 4, 5, 6, 7, 8, 9] >>> head, *middle, last = range(10) >>> head 0 >>> middle [1, 2, 3, 4, 5, 6, 7, 8] >>> last 9 >>> head, second, *middle, last = range(10) >>> head 0 >>> second 1 >>> middle [2, 3, 4, 5, 6, 7, 8] >>> last 9 |
Vous l’avez compris, la variable précédée du splat devient une liste, dont la taille s’ajuste en fonction du nombre d’éléments.
Il est donc impossible d’avoir deux variables précédées d’un splat, cela mènerait à une ambigüité. Ou plutôt, devrais-je préciser, une seul par niveau d’imbrication.
1 2 3 4 5 6 7 | >>> *a, (b, *c) = (0, 1, 2, (3, 4, 5)) >>> a [0, 1, 2] >>> b 3 >>> c [4, 5] |
Encore du splat
Nous avons vu l’opérateur splat utilisé à gauche de l’assignation, mais il est aussi possible depuis Python 3.51 de l’utiliser à droite. Il aura simplement l’effet inverse, et décomposera un itérable comme si ses valeurs avaient été entrées une à une.
1 2 3 | >>> values = *[0, 1, 2], 3, 4, *[5, 6], 7 >>> values (0, 1, 2, 3, 4, 5, 6, 7) |
Il est bien sûr possible de combiner les deux.
1 2 3 4 5 6 7 | >>> first, *middle, last = *[0, 1, 2], 3, 4, *[5, 6], 7 >>> first 0 >>> middle [1, 2, 3, 4, 5, 6] >>> last 7 |
-
Pour plus d’informations sur les possibilités étendues de l’opérateur splat offertes par Python 3.5 : <https://zestedesavoir.com/articles/175/sortie-de-python-3-5/#2-principales-nouveautes> ↩
TP : Itérateur sur listes chaînées
Revenons sur nos listes chaînées afin d’y implémenter le protocole des itérables.
Notre classe Deque
a donc besoin d’une méthode __iter__
retournant un itérateur, que nous appellerons simplement DequeIterator
.
1 2 | def __iter__(self): return DequeIterator(self) |
Cet itérateur contiendra une référence vers un maillon, puis, à chaque appel à __next__
, renverra la valeur du maillon courant, tout en prenant soin de passer au maillon suivant pour le prochain appel. StopIteration
sera levée si le maillon courant vaut None
.
On ajoutera aussi __iter__
dans l’itérateur, comme vu plus tôt, dans le cas où cet itérateur serait utilisé comme itérable.
1 2 3 4 5 6 7 8 9 10 11 12 13 | class DequeIterator: def __init__(self, deque): self.current = deque.first def __next__(self): if self.current is None: raise StopIteration value = self.current.value self.current = self.current.next return value def __iter__(self): return self |
Testons maintenant notre implémentation…
1 2 3 4 5 6 7 8 | >>> for i in Deque([1, 2, 3, 4, 5]): ... print(i) ... 1 2 3 4 5 |
… Ça marche !
Passons enfin aux ressources de la documentation concernant les itérables et itérateurs.
- Définition du terme itérable : https://docs.python.org/3/glossary.html#term-iterable
- Du terme itérateur : https://docs.python.org/3/glossary.html#term-iterator
- Type itérateur : https://docs.python.org/3/library/stdtypes.html#iterator-types
- Module
itertools
: https://docs.python.org/3/library/itertools.html
Les PEP, propositions et descriptions de nouvelles fonctionnalités, sont aussi des sources d’informations intéressantes.
- Unpacking généralisé : https://www.python.org/dev/peps/pep-0448
Et pour finir, quelques ressources annexes sur ces sujets :
- Nouvelles fonctionnalités de Python 3.5 : https://zestedesavoir.com/articles/175/sortie-de-python-3-5/
- Article Sam&Max sur l’opérateur splat : http://sametmax.com/operateur-splat-ou-etoile-en-python/