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 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 !
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 :
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.
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.
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.
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 :
range
par exemple, cela dépend de comment vous traitez les données dans votre classe.__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.for
comme je l'ai fait, ou encore une compréhension de liste selon ce dont vous avez besoin.Nous allons maintenant découvrir une façon différente de créer des itérateurs !
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.
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 :
yield
.while
pour conditionner l'arrêt de l'itération.yield
, et cela ne pose pas de problèmes puisque nos variables sont gardées en mémoires.return
à un certain moment pour dire qu'on souhaite arrêter l'itération.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.
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