Iterables, iterators et generators : les meilleurs amis de Python (oh, et on explique yield aussi)
Pourquoi y a-t-il « range() » et « xrange() » ? Que fait « (x**2 for x in (1, 2, 3)) » ? A quoi sert le mot clé « yield » ? Dois-je mettre les chocolats dans ma bouche un par un ou tous d’un coup ? La réponse, en bilingue français-Python.
Iterables
Quand vous créez une liste, vous pouvez récupérer chacun de ses éléments un par un. On parle d’itération :
>>> maliste = [1, 2, 3] >>> for i in maliste : ... print(i) 1 2 3
Maliste est un iterable, en anglais cela signifie « que l’on peut itérer ». En Python, itérable est une caractéristique que n’importe quel objet peut avoir, il lui suffit de déclarer la méthode « __iter__() » ou « __getitem__ » :
>>> print(maliste.__iter__) <method-wrapper '__iter__' of list object at 0xb73f648c>
Ainsi, les chaînes de caractères sont aussi des itérables :
>>> machaine = "e-vidence.net" >>> print(machaine.__getitem__) <method-wrapper '__getitem__' of str object at 0xb75005c0> >>> for lettre in machaine: ... print(lettre) e - v i d e n c e . n e t
Les dicionnaires aussi sont des iterables :
>>> dico = {"site": "e-vidence", "langage": "python"} >>> print(dico.__iter__) <method-wrapper '__iter__' of dict object at 0x8bc7acc> >>> for key in dico: ... print(key) ... langage site
Bref, les iterables, c’est « les trucs sur lesquels on peut faire une boucle “for” ». Les tuples, les sets et les fichiers aussi ! Du coup quand vous utilisez « range() », vous créez une liste, donc un iterable. Et quand vous utilisez une liste en intention, vous créez une liste, donc un itérable.
>>> print(range(3)) [0, 1, 2] >>> print([x*x for x in range(3)]) [0, 1, 4] >>> ma_liste = [x*x for x in range(3)] >>> for i in mylist : ... print(i) 0 1 4
Les itérables sont très pratiques car ils se comportent tous pareils : on se fiche de savoir ce qu’il y a dedans, on sait qu’on peut prendre chaque élément un par un. Vous n’avez rien à faire, vous n’avez même pas besoin de savoir si il est plein, ou si il utilise « __iter__() » ou quoique ce soit. On utilise « for », et c’est tout ce qu’on a besoin de savoir.
Cependant les listes, tuples et autres ont un défaut, ils stockent toutes les valeurs en mémoire. Ce n’est pas forcément ce que vous voulez si vous avez énormément de valeurs à passer en revue.
Iterators
Les itérateurs sont des objets qui permettent d’utiliser les iterables. Normalement vous ne les voyez jamais, car ils sont automatiquement utilisés par Python de manière invisible. Vous pouvez très bien coder en Python sans jamais avoir besoin de savoir ce qu’est un itérateur. Connaître les itérateurs relève donc plutôt de la culture générale.
Quand un objet déclare la méthode « __iter__() », cette méthode retourne un itérateur :
>>> l = [1, 2, 3] >>> iterateur = l.__iter__() >>> print(iterateur)
On utilise généralement la function « iter() » pour éviter d’appeler directement « __iter__() » :
>>> l = [1, 2, 3] >>> print(iter(l))
A quoi ça sert donc ? Et bien un itérateur possède une méthode « next() » qui permet d’avoir l’élément suivant de l’itérable dont il est issu. Quand il n’y a plus d’élément, il lève l’exception « StopIteration » :
>>> l = [1, 2, 3] >>> iterateur = iter(l) >>> print(iterateur.next()) 1 >>> print(iterateur.next()) 2 >>> print(iterateur.next()) 3 >>> print(iterateur.next()) StopIteration
Cet objet, c’est ce que « for » utilise ! Quand vous utilisez « for », Python récupère automatiquement l’itérateur et appelle « next() ». D’ailleurs, « for » fonctionne sur les itérateurs :
>>> l = [1, 2, 3] >>> iterateur = iter(l) >>> for x in iterateur : ... print x ... ... 1 2 3 >>> for x in iterateur : ... print x
Comme vous l’avez remarqué, on ne peut utiliser un itérateur qu’une seule fois. Une fois vidé, il reste vide.
Generators
Les générateurs sont des itérables, mais vous pouvez les lire une seule fois. C’est parcequ’ils ne gardent pas les valeurs en mémoire, mais les génèrent à la volée :
>>> mon_generateur = (x*x for x in range(3)) >>> for i in mon_generateur : ... print(i) 0 1 4
C’est la même chose que l’exemple du début avec la liste en intention, mais au lieu d’utiliser « [] », on utilise « () ». La grosse différence, c’est qu’on ne pourra pas faire un « for » une deuxième fois sur « mon_generateur » car les générateurs ne peuvent être lus qu’une fois : ici il calcule 0, puis oublie la valeur et calcule 1, puis calcule 4. Une valeur à la fois.
L’avantage, c’est que « mon_generateur » consomme très peu de mémoire : aucune liste ne stocke toutes les valeurs.
Yield
« Yield » est un mot clé que l’on utilise exactement comme « return », sauf que la fonction va retourner un générateur :
>>> def creerGenerateur() : ... maliste = range(3) ... for i in maliste : ... yield i*i ... >>> mon_generateur = creerGenerateur() # créer le gérérateur >>> print(mygenerator) # mon generateur est un objet ! >>> for i in mon_generateur: ... print(i) 0 1 4
Ici, c’est un exemple inutile, mais c’est pratique si votre fonction retourne un très gros ensemble de données que l’on a besoin de lire une seule fois.
Pour bien comprendre « yield », il faut se rentrer dans la tête qu’à l’appel de la fonction le code de la fonction n’est pas éxécuté. C’est le piège : la function retourne un objet générateur et ne fait rien d’autre ! Ensuite, le code va s’exécuter à chaque fois que « for » utilise le générateur.
Maintenant la partie difficile (concentrez vous, lisez plusieurs fois) :
La première fois que le corps de la function va être exécuté, il va s’exécuter du début jusqu’à ce qu’il rencontre « yield », et il va retourner la première valeur de la boucle. Puis, à chaque nouvel appel, on va éxécuter la partie de la boucle une nouvelle fois, arriver à « yield », et retourner la nouvelle valeur. On va continuer ainsi jusqu’à ce qu’il n’y ai plus de valeur à retourner.
Un générateur est considéré comme vide une fois que la fonction s’exécute, mais ne rencontre plus « yield », quelle qu’en soit la raison : boucle vide, if / else, exception…
Oh, et pour les tatillons, un générateur est son propre itérateur :
>>> def creerGenerateur() : ... maliste = range(3) ... for i in maliste : ... yield i*i ... >>> g = creerGenerateur() >>> print(g == iter(g)) True >>> g = creerGenerateur() >>> print(g.next()) 0
C’est bon, vous savez tout ce qu’il est nécessaire pour jouer avec les itérateurs et les générateurs, ainsi donc que « yield ». Pour les anglophiles, vous pouvez allez encore plus loin avec une passionnante présentations de Beazley sur des astuces à base de générateurs.
…
…
Toujours là ?
…
…
Bon encore un peu pour la route alors.
A ce stade là, vous avez du deviner à quoi sert « xrange() » : c’est l’équivalent de « range() », mais c’est un générateur (et un peu plus, car il possède son propre type) :
>>> print(range(3)) [0, 1, 2] >>> print(type(xrange(3))) <type 'xrange'> >>> print(xrange(3)) xrange(3) >>> print(list(xrange(3))) [0, 1, 2]
Si vous vous sentez vaillant, jetez un coup d’oeil à cet exemple amusant de générateur :
>>> class Banque(): # créons une banque, qui fabrique des distributeurs ... crise = False ... def creer_distributeur(self) : ... while not self.crise : ... yield "100 €" >>> bnp = Banque() # quand tout va bien, on a autant de monnaie que l'on veut >>> distributeur_du_coin = bnp.creer_distributeur() >>> print(distributeur_du_coin .next()) 100 € >>> print(distributeur_du_coin.next()) 100 € >>> print([distributeur_du_coin.next() for cash in range(5)]) ['100 €', '100 €', '100 €', '100 €', '100 €'] >>> bnp.crise = True # la crise est là, plus de monaie >>> print(distributeur_du_coin.next()) StopIteration >>> distributeur_de_la_defense = bnp.creer_distributeur() # même pour les nouveaux >>> print(distributeur_de_la_defense.next()) StopIteration >>> bnp.crise = False # si la crise s'en va, les anciens distributeurs sont vides >>> print(distributeur_du_coin.next()) StopIteration >>> nouveau_distributeur = bnp.creer_distributeur() # mais les nouveaux sont pleins >>> for cash in nouveau_distributeur : ... print cash 100 € 100 € 100 € 100 € 100 € 100 € 100 € 100 € 100 € ...
Voilà qui peut être utile, par exemple pour gérer l’accès à une ressource.
Enfin, si vous travaillez beaucoup avec les itérateurs, faites connaissance avec le module « itertools » : dupliquer des générateurs, les chaîner, groupe des valeurs, utiliser « map() » et « zip() » sans créer une nouvelle liste… Une seule réponse : « import itertools »
Un exemple ? Voyons l’ordre d’arrivée possible d’une courses de 4 chevaux :
>>> chevaux = [1, 2, 3, 4] >>> courses = itertools.permutations(chevaux) >>> print(courses) <itertools.permutations object at 0x8c2a77c> >>> for resultat in courses: ... print(resultat) ... [(1, 2, 3, 4), (1, 2, 4, 3), (1, 3, 2, 4), (1, 3, 4, 2), (1, 4, 2, 3), (1, 4, 3, 2), (2, 1, 3, 4), (2, 1, 4, 3), (2, 3, 1, 4), (2, 3, 4, 1), (2, 4, 1, 3), (2, 4, 3, 1), (3, 1, 2, 4), (3, 1, 4, 2), (3, 2, 1, 4), (3, 2, 4, 1), (3, 4, 1, 2), (3, 4, 2, 1), (4, 1, 2, 3), (4, 1, 3, 2), (4, 2, 1, 3), (4, 2, 3, 1), (4, 3, 1, 2), (4, 3, 2, 1)]
Happy hacking ![]()