Ce cours est visible gratuitement en ligne.

Ce cours existe en livre papier.

Ce cours existe en eBook.

J'ai tout compris !
Apprenez à programmer en Python

Apprenez à programmer en Python

Mis à jour le vendredi 20 juin 2014
  • Facile

Voilà pas mal de chapitres, nous avons étudié les boucles. Ne vous alarmez pas, ce que nous avons vu est toujours d'actualité … mais nous allons un peu approfondir le sujet, maintenant que nous explorons le monde de l'objet.

Nous allons ici parler d'itérateurs et de générateurs. Nous allons découvrir ces concepts du plus simple au plus complexe et de telle sorte que chacun des concepts abordés reprenne les précédents. N'hésitez pas, par la suite, à revenir sur ce chapitre et à le relire, partiellement ou intégralement si nécessaire.

Les itérateurs

Nous utilisons des itérateurs sans le savoir depuis le moment où nous avons abordé les boucles et surtout, depuis que nous utilisons le mot-clé for pour parcourir des objets conteneurs.

ma_liste = [1, 2, 3]
for element in ma_liste:

Utiliser les itérateurs

C'est sur la seconde ligne que nous allons nous attarder : à force d'utiliser ce type de syntaxe, vous avez dû vous y habituer et ce type de parcours doit vous être familier. Mais il se cache bel et bien un mécanisme derrière cette instruction.

Quand Python tombe sur une ligne du type for element in ma_liste:, il va appeler l'itérateur de ma_liste. L'itérateur, c'est un objet qui va être chargé de parcourir l'objet conteneur, ici une liste.

L'itérateur est créé dans la méthode spéciale __iter__ de l'objet. Ici, c'est donc la méthode __iter__ de la classe list qui est appelée et qui renvoie un itérateur permettant de parcourir la liste.

À chaque tour de boucle, Python appelle la méthode spéciale __next__ de l'itérateur, qui doit renvoyer l'élément suivant du parcours ou lever l'exception StopIteration si le parcours touche à sa fin.

Ce n'est peut-être pas très clair… alors voyons un exemple.

Avant de plonger dans le code, sachez que Python utilise deux fonctions pour appeler et manipuler les itérateurs : iter permet d'appeler la méthode spéciale __iter__ de l'objet passé en paramètre et next appelle la méthode spéciale __next__ de l'itérateur passé en paramètre.

>>> ma_chaine = "test"
>>> iterateur_de_ma_chaine = iter(ma_chaine)
>>> iterateur_de_ma_chaine
<str_iterator object at 0x00B408F0>
>>> next(iterateur_de_ma_chaine)
't'
>>> next(iterateur_de_ma_chaine)
'e'
>>> next(iterateur_de_ma_chaine)
's'
>>> next(iterateur_de_ma_chaine)
't'
>>> next(iterateur_de_ma_chaine)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>>
  • On commence par créer une chaîne de caractères (jusque là, rien de compliqué).

  • On appelle ensuite la fonction iter en lui passant en paramètre la chaîne. Cette fonction appelle la méthode spéciale __iter__ de la chaîne, qui renvoie l'itérateur permettant de parcourir ma_chaine.

  • On va ensuite appeler plusieurs fois la fonction next en lui passant en paramètre l'itérateur. Cette fonction appelle la méthode spéciale __next__ de l'itérateur. Elle renvoie successivement chaque lettre contenue dans notre chaîne et lève une exception StopIteration quand la chaîne a été parcourue entièrement.

Quand on parcourt une chaîne grâce à une boucle for (for lettre in chaine:), c'est ce mécanisme d'itérateur qui est appelé. Chaque lettre renvoyée par notre itérateur se retrouve dans la variable lettre et la boucle s'arrête quand l'exception StopIteration est levée.

Vous pouvez reprendre ce code avec d'autres objets conteneurs, des listes par exemple.

Créons nos itérateurs

Pour notre exemple, nous allons créer deux classes :

  • RevStr : une classe héritant de str qui se contentera de redéfinir la méthode __iter__. Son mode de parcours sera ainsi altéré : au lieu de parcourir la chaîne de gauche à droite, on la parcourra de droite à gauche (de la dernière lettre à la première).

  • ItRevStr : notre itérateur. Il sera créé depuis la méthode __iter__ de RevStr et devra parcourir notre chaîne du dernier caractère au premier.

Ce mécanisme est un peu nouveau, je vous mets le code sans trop de suspense. Si vous vous sentez de faire l'exercice, n'hésitez pas, mais je vous donnerai de toute façon l'occasion de pratiquer dès le prochain chapitre.

class RevStr(str):
    """Classe reprenant les méthodes et attributs des chaînes construites
    depuis 'str'. On se contente de définir une méthode de parcours
    différente : au lieu de parcourir la chaîne de la première à la dernière
    lettre, on la parcourt de la dernière à la première.
    
    Les autres méthodes, y compris le constructeur, n'ont pas besoin
    d'être redéfinies"""
    
    def __iter__(self):
        """Cette méthode renvoie un itérateur parcourant la chaîne
        dans le sens inverse de celui de 'str'"""
        
        return ItRevStr(self) # On renvoie l'itérateur créé pour l'occasion

class ItRevStr:
    """Un itérateur permettant de parcourir une chaîne de la dernière lettre
    à la première. On stocke dans des attributs la position courante et la
    chaîne à parcourir"""
    
    def __init__(self, chaine_a_parcourir):
       """On se positionne à la fin de la chaîne"""
        self.chaine_a_parcourir = chaine_a_parcourir
        self.position = len(chaine_a_parcourir)
    def __next__(self):
        """Cette méthode doit renvoyer l'élément suivant dans le parcours,
        ou lever l'exception 'StopIteration' si le parcours est fini"""
        
        if self.position == 0: # Fin du parcours
            raise StopIteration
        self.position -= 1 # On décrémente la position
        return self.chaine_a_parcourir[self.position]

À présent, vous pouvez créer des chaînes devant se parcourir du dernier caractère vers le premier.

>>> ma_chaine = RevStr("Bonjour")
>>> ma_chaine
'Bonjour'
>>> for lettre in ma_chaine:
...     print(lettre)
... 
r
u
o
j
n
o
B
>>>

Sachez qu'il est aussi possible de mettre en œuvre directement la méthode __next__ dans notre objet conteneur. Dans ce cas, la méthode __iter__ pourra renvoyer self. Vous pouvez voir un exemple, dont le code ci-dessus est inspiré, sur la documentation de Python.

Cela reste quand même plutôt lourd non, de devoir faire des itérateurs à chaque fois ? Surtout si nos objets conteneurs doivent se parcourir de plusieurs façons, comme les dictionnaires par exemple.

Oui, il subsiste quand même beaucoup de répétitions dans le code que nous devons produire, surtout si nous devons créer plusieurs itérateurs pour un même objet. Souvent, on utilisera des itérateurs existants, par exemple celui des listes. Mais il existe aussi un autre mécanisme, plus simple et plus intuitif : la raison pour laquelle je ne vous montre pas en premier cette autre façon de faire, c'est que cette autre façon passe quand même par des itérateurs, même si c'est implicite, et qu'il n'est pas mauvais de savoir comment cela marche en coulisse.

Il est temps à présent de jeter un coup d'œil du côté des générateurs.

Les générateurs

Les générateurs sont avant tout un moyen plus pratique de créer et manipuler des itérateurs. Vous verrez un peu plus loin dans ce chapitre qu'ils permettent des choses assez complexes, mais leur puissance tient surtout à leur simplicité et leur petite taille.

Les générateurs simples

Pour créer des générateurs, nous allons découvrir un nouveau mot-clé : yield. Ce mot-clé ne peut s'utiliser que dans le corps d'une fonction et il est suivi d'une valeur à renvoyer.

Attends un peu… une valeur ? À renvoyer ?

Oui. Le principe des générateurs étant un peu particulier, il nécessite un mot-clé pour lui tout seul. L'idée consiste à définir une fonction pour un type de parcours. Quand on demande le premier élément du parcours (grâce à next), la fonction commence son exécution. Dès qu'elle rencontre une instruction yield, elle renvoie la valeur qui suit et se met en pause. Quand on demande l'élément suivant de l'objet (grâce, une nouvelle fois, à next), l'exécution reprend à l'endroit où elle s'était arrêtée et s'interrompt au yield suivant… et ainsi de suite. À la fin de l'exécution de la fonction, l'exception StopIteration est automatiquement levée par Python.

Nous allons prendre un exemple très simple pour commencer :

>>> def mon_generateur():
...     """Notre premier générateur. Il va simplement renvoyer 1, 2 et 3"""
...     yield 1
...     yield 2
...     yield 3
...
>>> mon_generateur
<function mon_generateur at 0x00B494F8>
>>> mon_generateur()
<generator object mon_generateur at 0x00B9DC88>
>>> mon_iterateur = iter(mon_generateur())
>>> next(mon_iterateur)
1
>>> next(mon_iterateur)
2
>>> next(mon_iterateur)
3
>>> next(mon_iterateur)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>>

Je pense que cela vous rappelle quelque chose ! Cette fonction, à part l'utilisation de yield, est plutôt classique. Quand on l'exécute, on se retrouve avec un générateur. Ce générateur est un objet créé par Python qui définit sa propre méthode spéciale __iter__ et donc son propre itérateur. Nous aurions tout aussi bien pu faire :

for nombre in mon_generateur(): # Attention on exécute la fonction
    print(nombre)

Cela rend quand même le code bien plus simple à comprendre.

Notez qu'on doit exécuter la fonction mon_generateur pour obtenir un générateur. Si vous essayez de parcourir notre fonction (for nombre in mon_generateur), cela ne marchera pas.

Bien entendu, la plupart du temps, on ne se contentera pas d'appeler yield comme cela. Le générateur de notre exemple n'a pas beaucoup d'intérêt, il faut bien le reconnaître.

Essayons de faire une chose un peu plus utile : un générateur prenant en paramètres deux entiers, une borne inférieure et une borne supérieure, et renvoyant chaque entier compris entre ces bornes. Si on écrit par exemple intervalle(5, 10), on pourra parcourir les entiers de 6 à 9.

Le résultat attendu est donc :

>>> for nombre in intervalle(5, 10):
...     print(nombre)
... 
6
7
8
9
>>>

Vous pouvez essayer de faire l'exercice, c'est un bon entraînement et pas très compliqué de surcroît.

Au cas où, voici la correction :

def intervalle(borne_inf, borne_sup):
    """Générateur parcourant la série des entiers entre borne_inf et borne_sup.
    
    Note: borne_inf doit être inférieure à borne_sup"""
    
    borne_inf += 1
    while borne_inf < borne_sup:
        yield borne_inf
        borne_inf += 1

Là encore, vous pouvez améliorer cette fonction. Pourquoi ne pas faire en sorte que, si la borne inférieure est supérieure à la borne supérieure, le parcours se fasse dans l'autre sens ?

L'important est que vous compreniez bien l'intérêt et le mécanisme derrière. Je vous encourage, là encore, à tester, à disséquer cette fonctionnalité, à essayer de reprendre les exemples d'itérateurs et à les convertir en générateurs.

Si, dans une classe quelconque, la méthode spéciale __iter__ contient un appel à yield, alors ce sera ce générateur qui sera appelé quand on voudra parcourir la boucle. Même quand Python passe par des générateurs, comme vous l'avez vu, il utilise (implicitement) des itérateurs. C'est juste plus confortable pour le codeur, on n'a pas besoin de créer une classe par itérateur ni de coder une méthode __next__, ni même de lever l'exception StopIteration : Python fait tout cela pour nous. Pratique non ?

Les générateurs comme co-routines

Jusqu'ici, que ce soit avec les itérateurs ou avec les générateurs, nous créons un moyen de parcourir notre objet au début de la boucle for, en sachant que nous ne pourrons pas modifier le comportement du parcours par la suite. Mais les générateurs possèdent un certain nombre de méthodes permettant, justement, d'interagir avec eux pendant le parcours.

Malheureusement, à notre niveau, les idées d'applications utiles me manquent et je vais me contenter de vous présenter la syntaxe et un petit exemple. Peut-être trouverez-vous par la suite une application utile des co-routines quand vous vous lancerez dans des programmes conséquents, ou que vous aurez été plus loin dans l'apprentissage du Python.

Les co-routines sont un moyen d'altérer le parcours… pendant le parcours. Par exemple, dans notre générateur intervalle, on pourrait vouloir passer directement de 5 à 10.

Le système des co-routines en Python est contenu dans le mot-clé yield que nous avons vu plus haut et l'utilisation de certaines méthodes de notre générateur.

Interrompre la boucle

La première méthode que nous allons voir est close. Elle permet d'interrompre prématurément la boucle, comme le mot-clé break en somme.

generateur = intervalle(5, 20)
for nombre in generateur:
    if nombre > 17:
        generateur.close() # Interruption de la boucle

Comme vous le voyez, pour appeler les méthodes du générateur, on doit le stocker dans une variable avant la boucle. Si vous aviez écrit directement for nombre in intervalle(5, 20), vous n'auriez pas pu appeler la méthode close du générateur.

Envoyer des données à notre générateur

Pour cet exemple, nous allons étendre notre générateur pour qu'il accepte de recevoir des données pendant son exécution.

Le point d'échange de données se fait au mot-clé yield. yield valeur « renvoie » valeur qui deviendra donc la valeur courante du parcours. La fonction se met ensuite en pause. On peut, à cet instant, envoyer une valeur à notre générateur. Cela permet d'altérer le fonctionnement de notre générateur pendant le parcours.

Reprenons notre exemple en intégrant cette fonctionnalité :

def intervalle(borne_inf, borne_sup):
    """Générateur parcourant la série des entiers entre borne_inf et borne_sup.
    Notre générateur doit pouvoir "sauter" une certaine plage de nombres
    en fonction d'une valeur qu'on lui donne pendant le parcours. La
    valeur qu'on lui passe est la nouvelle valeur de borne_inf.
    
    Note: borne_inf doit être inférieure à borne_sup"""
    borne_inf += 1
    while borne_inf < borne_sup:
        valeur_recue = (yield borne_inf)
        if valeur_recue is not None: # Notre générateur a reçu quelque chose
            borne_inf = valeur_recue
        borne_inf += 1

Nous configurons notre générateur pour qu'il accepte une valeur éventuelle au cours du parcours. S'il reçoit une valeur, il va l'attribuer au point du parcours.

Autrement dit, au cours de la boucle, vous pouvez demander au générateur de sauter tout de suite à 20 si le nombre est 15.

Tout se passe à partir de la ligne du yield. Au lieu de simplement renvoyer une valeur à notre boucle, on capture une éventuelle valeur dans valeur_recue. La syntaxe est simple : variable = (yield valeur_a_renvoyer). N'oubliez pas les parenthèses autour de yield valeur.

Si aucune valeur n'a été passée à notre générateur, notre valeur_recue vaudra None. On vérifie donc si elle ne vaut pas None et, dans ce cas, on affecte la nouvelle valeur à borne_inf.

Voici le code permettant d'interagir avec notre générateur. On utilise la méthode send pour envoyer une valeur à notre générateur :

generateur = intervalle(10, 25)
for nombre in generateur:
    if nombre == 15: # On saute à 20
        generateur.send(20)
    print(nombre, end=" ")

Il existe d'autres méthodes permettant d'interagir avec notre générateur. Vous pouvez les retrouver, ainsi que des explications supplémentaires, sur la documentation officielle traitant du mot-clé yield.

En résumé

  • Quand on utilise la boucle for element in sequence:, un itérateur de cette séquence permet de la parcourir.

  • On peut récupérer l'itérateur d'une séquence grâce à la fonction iter.

  • Une séquence renvoie l'itérateur permettant de la parcourir grâce à la méthode spéciale __iter__.

  • Un itérateur possède une méthode spéciale, __next__, qui renvoie le prochain élément à parcourir ou lève l'exception StopIteration qui arrête la boucle.

  • Les générateurs permettent de créer plus simplement des itérateurs.

  • Ce sont des fonctions utilisant le mot-clé yield suivi de la valeur à transmettre à la boucle.

L'auteur

Découvrez aussi ce cours en...

Exemple de certificat de réussite
Exemple de certificat de réussite