ProPython
Notions avancées - Compréhensions, Itérateurs et Générateurs
19 Mar, 2021

Notions avancées - Compréhensions, Itérateurs et Générateurs

Cet article est le premier d'une série afin de vous apprendre des notions un peu plus avancées, et essentielles de Python. Et pour commencer, nous allons attaquer ce qui concerne les compréhensions, qui sont un moyen pratique et efficace de créer des structures de données comme des listes ou des dictionnaires. Nous allons aussi étudier en détail les itérateurs, et comment en créer facilement en utilisant des générateurs.

Les compréhensions

Définition

Les compréhensions sont un moyen syntaxique de créer des listes, des dictionnaires ou des sets (un set est une structure de données représentant un ensemble) d'une autre façon. L'avantage des compréhensions est de pouvoir apporter de la clarté au code, et d'éviter l'utilisation intempestive de boucles for pour des cas simples, voire même des cas un peu plus complexes impliquant des conditions. Au début, la syntaxe va vous paraître bizarre, mais vous allez vite vous y habituer, et vous les utiliserez tout le temps une fois que vous les maîtriserez !

Compréhensions de listes

Les compréhensions de listes permettent évidemment de créer des listes. Voici quelques exemples de compréhension de liste :

>>> [i for i in range(3)]
[0, 1, 2]
>>> [i*2 for i in range(3)]
[0, 2, 4]
>>> [i*2 for i in range(10) if i%2 == 0]
[0, 4, 8, 12, 16]

On peut en déduire une syntaxe pour les compréhensions de listes : [function(item) for item in iterator if condition]. Ce qui correspond, en utilisant une boucles for à :

l = []
for item in iterator:
    if condition:
        l.append(function(item))

Vous voyez donc l'intérêt des compréhensions de listes, en une ligne on fait la même chose qu'en 4 lignes avec une boucle for.

Voici quelques petites compréhensions de listes à réaliser pour vous entraîner à comprendre la syntaxe :

Liste des 10 premiers entiers
Liste des carrés des 10 premiers entiers
Liste des carrés des 10 premiers entiers impairs

On peut aussi compliquer les choses en utilisant plusieurs fois le mot-clé for dans les compréhensions. En fait, il faut garder en tête que tout ce qui est avant le dernier for doit-être une variable, donc si par exemple on imbrique une compréhension de liste dans une compréhension de liste, celle ci nous renvoie une liste qu'on peut utiliser comme variable pour notre dernier mot-clé for. Voici un exemple :

>>> [[i for i in range(3)] for n in range(2)]
[[0, 1, 2], [0, 1, 2]]

# Equivalent à :

>> [[0, 1, 2] for n in range(2)]
[[0, 1, 2], [0, 1, 2]]

Aussi, toute variable utilisée dans une compréhension de liste est accessible par n'importe quel mot-clé for, ce qui permet de lier les boucles entre elles par des variables.

Exemple assez compliqué, prenez votre temps pour comprendre :

>>> [[n for n in range(i, i+3) if n <= 10] for i in range(0, 10, 3)]
[[0, 1, 2], [3, 4, 5], [6, 7, 8], [9, 10]]

On crée une première liste via une compréhension dont les valeurs dépendent de l'extérieur de la compréhension. Puis on utilise cette liste dans la compréhension globale pour générer la liste globale. De cette façon on crée une liste à deux dimensions contenant des listes d'entiers. Bien-sûr on peut s'amuser à encore imbriquer cette liste dans une autre liste, mais au bout d'un moment cela rend le code illisible et il vaut mieux utiliser des bonnes vieilles boucles for.

>>> [[[n for n in range(i, i+3) if n <= 10] for i in range(0, 10, 3)] for j in range(3)]  # Illisible 

Voilà, si vous avez compris les compréhensions de listes, vous comprendrez les compréhensions de dictionnaires, le principe est le même.

Compréhensions de dictionnaires

Les compréhensions de dictionnaires sont similaires aux compréhensions de listes, la seule différence est que les dictionnaires nécessitent une clé en plus d'une valeur lors de l'initialisation. Voici quelques exemples de compréhensions de dictionnaires :

>>> {i:i*i for i in range(5)}
{0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
>>> {i:i*i for i in range(5) if i < 3}
{0: 0, 1: 1, 2: 4}
>>> {name:i for name, i in zip(["John", "Harry", "Andy"], range(3))}
{'John': 0, 'Harry': 1, 'Andy': 2}
C'est quoi zip ?

zip est une fonction prenant en paramètres plusieurs itérateurs, les regroupant et renvoyant un itérateur. Imaginons deux listes : l1 = [1, 2, 3] et l2 = ["a", "b", "c"]. zip(l1, l2) va regrouper l1 et l2 en prenant successivement un objet de l1 et un objet de l2 pour les mettre dans un tuple. Nous en parlerons plus en détail dans un prochain article.

On en déduit donc la syntaxe des compréhensions de dictionnaires : {key:value for key, value in iterator if condition}. Ou encore : {function1(item):function2(item) for item in iterator if condition. Les compréhensions de dictionnaires sont moins communes, mais tout de même utiles alors il est intéressant de les connaître !

Maintenant, nous allons parler plus en détail des itérateurs.

Les itérateurs

Définition

Un itérateur est une sorte de curseur vous permettant de parcourir une séquence de données d'une certaine façon. Un itérateur permet donc de créer un objet générique pour parcourir des données. En effet, peu importe la structure de l'objet à parcourir, l'itérateur agit toujours de la même façon.

Créer un itérateur

Un itérateur est un objet. Si l'on souhaite créer des itérateurs personnalisés, il faut donc créer une classe. On va avoir de deux méthodes spéciales. Si vous ne vous souvenez plus de ce que sont les méthodes spéciales, j'en parle dans l'article Bases de Python - Programmation Orientée Objet.

La méthode __iter__ est une méthode prenant en entrée un objet itérable et renvoyant un objet itérateur. Dans notre cas, puisque nous créons une classe spécifiquement pour notre itérateur, la méthode __iter__ renverra uniquement self.

La méthode __next__ permet d'appeler l'élément suivant du flux d'itération.

Voici un exemple pour comprendre ces deux méthodes :

>>> mon_iterateur = iter(range(3))  # Appelle la méthode __iter__
 >>> mon_iterateur
 <range_iterator object at 0x00000283BBAE9990>
 >>> next(mon_iterateur)
 0
 >>> next(mon_iterateur)
 1
 >>> next(mon_iterateur)
 2
 >>> next(mon_iterateur)
 Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
 StopIteration

On créé d'abord un objet itérateur. Ensuite, on appelle next afin d'itérer sur cet objet. Finalement, lorsque l'itération est terminée et qu'on essaie d'appeler next, une erreur est levée.

Maintenant, nous avons tous les éléments pour créer nos itérateurs. Essayons par exemple de créer un itérateur qui prend le premier élément d'une structure de données, puis le dernier, puis le deuxième, puis l'avant dernier, etc...

class MonIterateur:

    def __init__(self, data):
        self.data = data
        self.index = 0
        self.reverse = False

    def __iter__(self):
        return self

    def __next__(self):
        if len(self.data)%2 == 0:
            if self.index == len(self.data) // 2 and not self.reverse :
                raise StopIteration
        if len(self.data) % 2 == 1:
            if self.index == len(self.data) // 2 + 1 and self.reverse:
                raise StopIteration


        if not self.reverse:
            self.index += 1
            self.reverse = True
            return self.data[self.index-1]
        self.reverse = False
        return self.data[-self.index]



for i in MonIterateur([1, 2, 3, 4, 5, 6]):
    print(i)

# 1, 6, 2, 5, 3, 4

Quelques explications par rapport au code maintenant :

  • Le constructeur de notre classe itérateur prend en paramètre l'objet itérable à partir du quel on souhaite créer notre itérateur. Dans l'exemple, c'est une liste. Nous ne sommes pas obligés de passer un objet itérable en paramètres, regardez la fonction range par exemple, cela dépend de comment vous traitez les données dans votre classe.
  • On définit d'autres attributs dans notre constructeurs, qui nous seront utiles pour le traitement des données.
  • Dans notre méthode __next__, on lève une exception StopIteration qui permet d'arrêter l'itération lorsque certaines conditions sont remplies. Dans notre exemple, on souhaite s'arrêter au milieu de la structure de données, lorsqu'on a parcouru tous les éléments en alternant la gauche et la droite.
  • Pour utiliser notre itérateur, on peut utiliser par exemple une boucle for comme je l'ai fait, ou encore une compréhension de liste selon ce dont vous avez besoin.
  • Le reste est du code que vous pouvez normalement comprendre, en revanche j'avoue que les itérateurs sont tout de même un peu tordus, si vous avez des questions contactez moi ou laissez un commentaire !

Nous allons maintenant découvrir une façon différente de créer des itérateurs !

Les générateurs

Définition

Les générateurs sont des outils pour créer des itérateurs de façon plus simple et moins contraignante. Ils nous évitent de devoir créer des classes juste pour des itérateurs puisqu'ils fonctionnent comme des fonctions, un peu particulières cependant. En effet, on utilise pas l'instruction return pour renvoyer des valeurs, mais à la place l'instruction yield qui permet de conserver le contexte d'exécution. Ce qui signifie qu'un générateur va pouvoir stocker les variables dont vous aurez besoin à la manière d'une classe. De plus, il génère automatiquement les méthodes __iter__ et __next__ et lève automatiquement une exception lorsque l'itération est terminée.

Syntaxe

Niveau syntaxe, la déclaration est similaire à celle d'une fonction. Sauf qu'à chaque fois que l'on appelle next, l'exécution de la fonction est comme mise en pause, et reprend lorsqu'on redemande à itérer une nouvelle fois.

Voici un exemple pour comprendre ceci :

def generator():
    n = 0
    print("Premier affichage")
    yield n

    n += 1
    print("Second affichage")
    yield n

    n += 1
    print("Dernier affichage")
    yield n

>>> gen = generator()
>>> next(gen)
Premier affichage
0
>>> next(gen)
Second affichage
1
>>> next(gen)
Dernier affichage
2

Vous voyez bien que c'est comme si la fonction était mise en pause entre chaque appel à next.

Maintenant, essayons de réécrire notre exemple de toute à l'heure en utilisant les générateurs :

def mon_generateur(data):
    reverse = False
    index = 0

    if len(data) % 2 == 0:
        while index != len(data) // 2:
            if not reverse:
                reverse = True
                index += 1
                yield data[index-1]
            reverse = False
            yield data[-index]

    if len(data) % 2 == 1:
        while index != len(data) // 2 + 1:
            if not reverse:
                reverse = True
                index += 1
                yield data[index-1]
            if index == len(data) // 2 + 1:
                return
            reverse = False
            yield data[-index]


for i in mon_generateur([1, 2, 3, 4, 5, 6, 7]):
    print(i)


# 1, 6, 2, 5, 3, 4

Cela prend quand même un peu moins de lignes, et encore le code n'est pas du tout optimisé ! Quelques explications tout de même à propos du code :

  • On déclare les variables dont on a besoin pour nos traitements tout en haut de la fonction. Ces variables sont donc conservées entre chaque yield.
  • On utilise une boucle while pour conditionner l'arrêt de l'itération.
  • On modifie nos variables entre chaque yield, et cela ne pose pas de problèmes puisque nos variables sont gardées en mémoires.
  • On utilise return à un certain moment pour dire qu'on souhaite arrêter l'itération.

Expressions de générateurs

Les expressions de générateurs sont un peu l'équivalent pour les générateurs des compréhensions de listes pour les listes. C'est à dire qu'elles permettent d'écrire facilement des générateurs. Voici un exemple :

>>> gen = (x for x in range(10))
>>> next(gen)
0
>>> next(gen)
1
>>> for x in gen:
...    print(x)
...
2
3
...
9

Mais j'aurai pu obtenir exactement le même résultat en utilisant une liste ?

En effet, de cette façon :

ma_liste = [x for x in range(10)]
for x in ma_liste:
    print(x)

Mais la différence est qu'une liste contient les objets, et qu'ils sont tous stockés en mémoire. En revanche, un générateur génère les objets au fur et à mesure qu'ils sont demandés. Cela améliore la performance et permet d'effectuer les mêmes opérations beaucoup plus rapidement qu'en utilisant des listes. C'est une façon d'optimiser le code.

Le mot de la fin

Les notions étudiées dans cet article peuvent être un peu déroutantes au début. Et vous avez peut-être du mal à comprendre leurs utilités. Mais vous verrez, avec la pratique et les projets, vous comprendrez à quoi ils peuvent bien servir. En tous cas les compréhensions sont toujours très utiles, je parle surtout pour les itérateurs et les générateurs. Pour ma part, je ne les utilise pas souvent, alors que j'utilise tout le temps les compréhensions. Mais quand je leur trouve une utilité, cela est souvent la meilleure façon de résoudre un problème et cela me fait bien gagner du temps et des lignes de code !

Si vous avez des questions ou avez besoin de précisions, n'hésitez-pas à me contacter par email : contact@propython.fr, ou bien via l'onglet Contact visible si vous êtes sur ordinateur.

On se retrouve prochainement pour la suite de votre apprentissage du Python

Laisser un commentaire

Premium - 15€/mois

L'accès à des articles inédits, à une multitude de ressources, à de nouveaux projets, mais également à des vidéos explicatives, découvrez ici pourquoi passer premium.

Articles liés

Catégories

Ressources

Retrouvez une collection de ressources (des scripts, des fiches résumé, des images...) liées aux articles du blog ou au Python.
Voir

Contact

contact@propython.fr
Se connecter pour envoyer un message directement depuis le site.

Navigation

AccueilSe connecterCréer un compteRessourcesPremium

Catégories

Pages légales

Politique de confidentialitéMentions légalesConditions générales de vente