, ,

Python: itérable, itérateur et itération

Les itérateurs vont nous permettre de parcourir les objets de manière simple et intuitive.

La boucle for abordée précédemment permet de parcourir de nombreux objets comme par exemple les séquences. On a vu que les boucles *for* étaient simples et permettaient d'écrire des itérations qui soient expressives. Les boucles *for* reposent sur un concept majeur en Python que l'on appelle le concept d'itérateur. Les itérateurs en Python sont des objets simples qui définissent une interface unique que l'on appelle le protocole d'itération. En plus de la simplicité et de l'efficacité de ce mécanisme, la notion d'itérateur permet de découpler l'objet qui itère de l'objet qui contient les données. L'avantage est que maintenant avec un itérateur, nous avons un objet extrêmement simple et compact que l'on peut parcourir de manière intuitive. Un objet que l'on peut parcourir grâce à un itérateur s'appelle un objet itérable. Donc un itérable est un objet que l'on peut parcourir de multiples fois. Ouvrons maintenant un interpréteur Python pour découvrir cette notion d'itération.

En Python, tous les types *built-in* sont itérables sauf évidemment les types numériques puisque ça n'aurait pas de sens de les parcourir. Nous allons maintenant déconstruire la manière dont fonctionne une boucle *for* sur les *itérables*. Regardons cela avec un exemple simple.

# création d'un ensemble s
>>> s = { 1, 2, 3, 'a'}
 
# une boucle pour parcourir les éléments de s
>>> for elem in s:
...   print(elem)
 
1
2
3
a
 
# ou une compréhension de liste
>>> [x for x in s if type(x) is int]
[1, 2, 3]

Cette compréhension de liste retourne la liste de tous les éléments de cet itérable (dans ce cas-là c'est un ensemble), lorsque ces éléments sont des entiers.

Maintenant essayons de comprendre comment est-ce que la boucle *for* va faire pour parcourir cet objet. En fait, la boucle *for* va faire les opérations suivantes:

Elle va commencer par récupérer l'itérateur sur cet ensemble; On peut instancier un objet *iterateur* sur l'ensemble *s*.

>>> it = iter(s)
 
# on affiche le type de l'objet it
>>> type(it)
set_iterator

iter est la fonction *built-in* qui permet de créer un itérateur sur un objet itérable ici l'ensemble *s*. On voit que *it* est un objet de type `set_iterator`. En appelant cette méthode *iter*, nous avons créé un itérateur sur l' objet ensemble *s*.

La boucle *for*, après avoir appelé cette méthode *iter*, appelle une méthode *next*, et c'est ainsi qu' elle parcours chaque élément de cet objet itérable.

# obtenir le premier élément de l'ensemble
# par manipulation de l'itérateur
>>> next(it)
1

On obtient le premier élément de l' ensemble en appelant next(it). On peut la rappeler, on obtient le deuxième élément, le troisième élément, le quatrième élément et lorsqu'il n'y a plus d'élément, la méthode *next* va retourner une exception qui s'appelle StopIteration.

Ensuite on ne peut plus obtenir d'autres éléments via cet itérateur, on obtiendra en permanence l'exception StopIteration, ce qui représente bien le fait qu'un itérateur ne peut se parcourir qu'une seule fois.

En pratique, vous n'aurez jamais à appeler vous-mêmes les méthodes *iter* et *next* sur vos objets pour être capables de les parcourir. Ce sont les mécanismes d'itération comme par exemple les boucles for ou les compréhensions qui vont faire ça pour vous. Cependant, c'est très important de comprendre ce protocole d'itération parce que ça vous permettra par la suite d'écrire vos propres objets itérables ou vos propres itérateurs.

Essayons maintenant de formaliser un petit peu plus ces notions d'itérable et d'itérateur. Il y a deux types d'objets, les itérables et les itérateurs. Ce sont deux types d'objets qui sont conceptuellement différents.

On peut s'interroger à propos de la méthode *iter* sur l'itérateur qui retourne l'itérateur lui-même… La raison est qu'un objet est itérable parce qu'il a une méthode *iter* qui retourne un itérateur; et bien un itérateur est également itérable parce qu'il a une méthode *iter* qui retourne un itérateur. Le fait que ce soit lui-même ne change rien à l'affaire, ça reste un objet itérable. Par conséquent, tous les mécanismes d'itération, *boucle for*, *compréhension de liste*, peuvent prendre soit un itérable, soit un itérateur, et le parcourir de manière totalement simple et intuitive.

On peut également s'interroger sur la raison d'être des deux notions itérable et itérateur puisque les boucles *for* peuvent prendre ces deux objets de manière indifférente… En fait, ces deux objets sont conceptuellement différents: l'itérable est l'objet qui contient les données et l'itérateur est un objet simple et compact qui parcourt les données qui sont contenues dans l'itérable.

Lorsque vous manipulez des objets itérables, comme les listes, c'est votre mécanisme d'itération qui va s'occuper de parcourir ces objets. Mais dans certains cas, vous n'aurez pas d'itérable, vous aurez directement un itérateur. C'est par exemple le cas des fichiers. Pourquoi est-ce que les fichiers sont des itérateurs? On le comprend assez aisément; on voit bien que si on avait à lire un fichier qui fasse des dizaines de megabytes ou des centaines de megabytes, ça serait une mauvaise idée d'avoir entièrement à le charger en mémoire. Or, le seul moyen d'avoir un itérable, c'est d'avoir un objet qui contient toutes les données en mémoire. Le choix de Python pour les fichiers, est de fournir un itérateur qui va parcourir ligne par ligne le fichier qui est contenu sur le disque dur. Évidemment, si vous avez le besoin de stocker toutes les lignes d'un fichier dans une liste, vous pouvez le faire mais vous le ferez de manière explicite.

Regardons maintenant de nouveau le fonctionnement des itérateurs et notamment le fait qu'un itérateur ne peut se parcourir qu'une seule fois.

# une liste contenant 2 éléments
>>> a = [1, 2]
 
# une autre liste de 2 éléments
>>> b = [3, 4]
 
# je peux très bien prendre un itérateur sur ma liste
>>> ita = a.__iter__()
 
# j'obtiens un objet de type list_iterator
>>> type(ita)
list_iterator

Mais en pratique, nous ne le ferons pas, l' objet *liste* est itérable et on peut faire autant de boucles *for* que l' on souhaite sur cet objet. En fait, comme on le sait, la liste va contenir une référence vers les objets qui sont contenus dans la liste, par conséquent, mon objet *liste* existe en mémoire. Mais regardons maintenant un autre type d'objet. Créons un nouvel objet qui est un objet *zip* qui va prendre deux listes, *a* et *b*.

# creation d'un nouvel objet zip
>>> z = zip(a,b)

Que fait la fonction built-in zip? En fait, elle prend le premier élément de chaque liste et l'insère dans un tuple, ensuite, elle prend le deuxième élément de chaque liste et l'insère dans un *tuple* et cetera. On voit bien dans ce cas-là qu'il n'y aurait pas vraiment d'intérêt à créer une structure de données temporaire qui contiendrait la liste de tous les *tuples*. Donc le choix de Python a été de créer un objet *zip* qui est en fait un itérateur. Regardons cela.

>>> z is iter(z)
True

*z* est son propre itérateur. On a ici la certitude qu'il s'agit d'un objet itérateur. Par conséquent, je ne peux le parcourir qu'une seule fois. Donc maintenant, si je fais une compréhension de liste:

# obtenir la liste des tuples
>>> [i for i in z]
[(1, 3), (2, 4)]
 
# Si on essaye une seconde fois de créer une liste en compréhension
# avec le même itérateur, on obtient une liste vide
>>> [i for i in z]
[]

En effet, l' itérateur a été consommé, maintenant il est vide, on ne peut plus le parcourir. Et on peut le vérifier en faisant un `next(z)` qui lèvera une exception StopIteration.

L'intérêt des itérateurs est qu'ils sont simples, compacts, et très peu coûteux à créer. Par conséquent, si on veut une nouvelle fois parcourir les couples d' éléments des listes, il suffit de recréer un nouvel objet itérateur; c'est extrêmement peu coûteux à créer puisque cet objet ne va rien parcourir et ne va faire aucun calcul. Et en fait, le calcul ne sera fait qu'au moment où l'on va itérer sur cet itérateur.

I faut garder à l'esprit que les itérateurs sont des objets simples et compacts, que l'on ne peut parcourir qu'une seule fois ; par contre, en général, créer un nouvel itérateur est très peu coûteux donc nous pouvons en créer à chaque fois que nous en avons besoin d'un nouveau.

Pour résumer l'itérable est un objet que l'on peut parcourir autant de fois que l'on veut avec n'importe quel mécanisme d'itération en Python ; et que l'itérateur est un objet simple et compact que l'on ne peut parcourir qu'une seule fois.

Le module itertools

itertools fournit sous forme d'itérateurs des utilitaires communs qui peuvent être très utiles. On vous rappelle que l'intérêt premier des itérateurs est de parcourir des données sans créer de structure de données temporaire, donc à coût mémoire faible et constant.

Ciquer sur le lien siuvant pour visualiser la documentation du module.

Comme vous le voyez dans la doc, les fonctionnalités de itertools tombent dans 3 catégories :

À nouveau, toutes ces fonctionnalités sont offertes sous la forme d'itérateurs.

chain permet de concaténer plusieurs itérables sous la forme d'un itérateur:

>>> import itertools
 
>>> for x in itertools.chain((1, 2), [3, 4]):
...    print(x)
1
2
3
4

islice qui fournit un itérateur sur un slice d'un itérable. On peut le voir comme une généralisation de range qui parcourt n'importe quel itérable.

>>> import string
 
>>> support = string.ascii_lowercase
 
>>> print(f'support={support}')
support=abcdefghijklmnopqrstuvwxyz
 
# range
>>> for x in range(3, 8):
...     print(x)
3
4
5
6
7
 
# islice
>>> for x in itertools.islice(support, 3, 8):
...     print(x)
d
e
f
g
h