Commençons par nous intéresser aux listes, ces structures de données essentielles en Python, surtout dans notre travail en data science et machine learning. Dès qu'on manipule des ensembles de données, qu'on analyse des résultats ou qu'on transforme des séries de valeurs, nous avons très souvent affaire à des listes.

Une liste est capable de contenir n'importe quel type d'objet Python. Elle peut regrouper des nombres, des chaînes, des fonctions ou même d'autres listes. Ce mélange est autorisé et très utile. Par exemple :

LISTE = [1, 3.0, "Hello", print]
type(LISTE)  # 

Les listes sont donc à la fois flexibles et puissantes. Elles nous permettent de structurer nos données de manière intuitive et directe, tout en offrant une compatibilité parfaite avec les boucles et fonctions intégrées de Python.

Itération et itérateurs

Une des grandes forces des listes réside dans le fait qu'elles sont itérables. Cela signifie que nous pouvons en extraire un itérateur grâce à la fonction iter(), puis en obtenir les éléments un à un à l'aide de next() :

iterator = iter(LISTE)
next(iterator)  # 1
next(iterator)  # 3.0
next(iterator)  # "Hello"
next(iterator)  # 

Si nous appelons next() une fois de trop, Python lève une exception StopIteration pour indiquer qu'il n'y a plus d'éléments à parcourir.

Erreur levée :

next(iterator)

StopIteration                             Traceback (most recent call last)
 in ()
----> 1 print(next(Liste_2))

StopIteration:

Cette mécanique est élégante, mais dans la pratique, nous utilisons surtout les boucles for, qui gèrent tout cela pour nous.

La boucle for automatise l'itération de manière propre et sécurisée :

for element in LISTE:
    print(element)

Elle crée l'itérateur, appelle next() à chaque tour et s'arrête au bon moment, sans que nous ayons à gérer quoi que ce soit manuellement. Cela rend notre code à la fois plus clair et plus fiable.

Prenons un exemple simple pour afficher les carrés des nombres :

for nombre in [1, 2, 3]:
    carré = nombre ** 2
    print(carré)
# 1
# 4
# 9

Les fonctions essentielles : range, enumerate, reversed et zip

Python nous fournit aussi plusieurs fonctions essentielles qui enrichissent la manière dont nous parcourons les listes. Ces fonctions rendent nos boucles plus expressives et souvent plus efficaces.

La fonction range() nous permet de générer facilement des suites de nombres. Elle peut prendre un, deux ou trois arguments :

range(5)         # 0, 1, 2, 3, 4
range(2, 6)      # 2, 3, 4, 5
range(1, 10, 2)  # 1, 3, 5, 7, 9 (le pas)

Utilisée dans une boucle, elle permet un contrôle précis sur les indices ou les répétitions :

for i in range(5):
    print(i)
# 0
# 1
# 2
# 3
# 4

Il est important de noter que range() ne retourne pas une liste complète, mais un itérable de type range, qui produit les valeurs à la volée.

Cela permet de gagner en mémoire, car les éléments ne sont pas tous stockés en même temps. Cet objet se comporte de manière similaire à un générateur, bien qu'il ne soit pas un générateur au sens strict.

Avec enumerate(), nous récupérons à la fois l'index et la valeur d'un élément lors d'un parcours. Cela simplifie grandement les cas où nous devons garder une trace de la position :

for index, valeur in enumerate(["A", "B", "C"]):
    print(index, valeur)
# 0 A
# 1 B
# 2 C

Cette approche est bien plus lisible qu'une gestion manuelle du compteur.

La fonction reversed() nous permet de parcourir une liste en sens inverse, toujours sans dupliquer les données en mémoire :

for element in reversed(["A", "B", "C"]):
    print(element)
# C
# B
# A

Comme range(), reversed() retourne un itérable. Cela signifie qu'on peut le parcourir avec une boucle, mais l'itérateur créé lors du parcours ne peut être utilisé qu'une fois.

Enfin, zip() nous permet de combiner plusieurs listes, élément par élément. Cela s'avère très pratique lorsque nous devons traiter des données associées :

plats = ["Poisson", "Salade", "Pizza", "Frites"]
prix = [18, 13, 24]

for plat, prix in zip(plats, prix):
    print("Le", plat, "est à", prix, "euros.")
# Le Poisson est à 18 euros.
# La Salade est à 13 euros.
# La Pizza est à 24 euros.

Cette méthode s'arrête dès qu'une des listes est épuisée.

Elle permet aussi de combiner plus de deux listes en même temps. À noter qu'il est possible de faire la même chose avec des indices, mais c'est moins élégant et la boucle ne s'arrête pas si la liste est épuisée, une erreur IndexError sera levée.

for i in range(len(plats)):
    print("Le", plats[i], "est à", prix[i], "euros.")
# Le Poisson est à 18 euros.
# La Salade est à 13 euros.
# La Pizza est à 24 euros.
IndexError                                Traceback (most recent call last)
 in ()
      3 
      4 for i in range(len(plats)):
----> 5     print("Le", plats[i], "est à", prix[i], "euros.")

IndexError: list index out of range

Avec ces outils – range, enumerate, reversed, et zip – nous maîtrisons l'art de parcourir et manipuler les listes de façon propre et performante. Ces fonctions sont indispensables dès que notre code commence à croître en complexité ou en volume de données.

Différence entre générateur et itérateur

En Python, un générateur est un type particulier d'itérateur, mais tous les itérateurs ne sont pas des générateurs.

Un itérateur est un objet qui implémente (inclut) les méthodes __iter__() et __next__() (il possède ces deux méthodes spéciales). On peut obtenir un itérateur à partir d'un itérable comme une liste, une chaîne ou un tuple :

liste = [1, 2, 3]
it = iter(liste)

print(next(it))  # 1
print(next(it))  # 2

Un générateur, en revanche, est créé avec une fonction qui utilise le mot-clé yield, il permet de produire des valeurs une à une tout en conservant l'état entre les appels. C'est une solution légère et élégante :

def compte_jusqua(n):
    i = 0
    while i < n:
        yield i
        i += 1

gen = compte_jusqua(3)

print(next(gen))  # 0
print(next(gen))  # 1
print(next(gen))  # 2

Donc ici, yield revient à faire return i pour chaque itération.

Les générateurs sont particulièrement adaptés quand nous devons traiter de grandes quantités de données sans tout stocker en mémoire. Ils nous permettent d'écrire du code concis, efficace et plus lisible, tout en optimisant l'usage des ressources.

Pratiquer ces concepts au quotidien nous permettra d'en faire des réflexes naturels. Plus nous les utilisons, plus notre code gagne en clarté, en puissance et en performance.