PYTHON → ROBOTIQUE · 17

Séance 17 · Partie C · Python avancé

Itérateurs, générateurs,
compréhensions.

Un capteur produit des mesures sans fin. Un fichier de 10 Go ne tient pas en mémoire. Python a une réponse élégante : produire les valeurs une à la fois, à la demande.

Durée : 90 min · Objectifs : compréhensions de liste/dict/ensemble, le protocole d’itération, yield et les générateurs, expressions génératrices, itertools, fonctions d’ordre supérieur et décorateurs.

Ce que vous saurez faire à la fin
  • Écrire du Python compact et lisible avec les compréhensions.
  • Traiter un flux infini ou un fichier énorme sans le charger en mémoire.
  • Comprendre ce que fait vraiment for.
  • Écrire un décorateur simple (chronométrage, cache).

01 / Compréhensions

Liste, dictionnaire, ensemble : la même syntaxe

Lisibilité avant tout

Une compréhension se lit « la liste des f(x) pour x dans … si … ». Elle remplace 4 lignes de boucle + append. Au-delà de deux for ou d’une condition compliquée, revenez à une boucle : la compréhension n’est pas plus rapide au point de justifier l’illisibilité. Un ensemble (set) teste l’appartenance en O(1), comme un dictionnaire sans valeurs : x in ensemble est instantané, x in liste est O(n).

02 / Itération

Ce que fait vraiment un for

Le protocole

for x in obj fait : it = iter(obj) puis répète x = next(it) jusqu’à StopIteration. Un objet itérable a __iter__ ; un itérateur a en plus __next__. Une liste crée un nouvel itérateur à chaque for (on peut la parcourir deux fois) ; un itérateur, lui, s’épuise. Ce protocole unique explique pourquoi sum, max, list, ", ".join acceptent tous n’importe quelle source.

02 / Itération

yield : écrire un itérateur en trois lignes

Un générateur, c’est une fonction qui se souvient

Une fonction ordinaire s’exécute d’un bloc et oublie tout. Une fonction contenant yield renvoie un générateur : chaque next() exécute le code jusqu’au prochain yield, renvoie la valeur, et gèle l’état (variables locales, position). Rien n’est calculé avant qu’on le demande : c’est l’évaluation paresseuse. On peut donc décrire une suite infinie et n’en consommer que ce dont on a besoin. C’est ce que fait range.

02 / Itération

Traiter un flux : la chaîne de générateurs

Pourquoi c’est puissant

Quatre étapes indépendantes, chacune testable seule, assemblées comme des tuyaux. Aucune liste intermédiaire : la mémoire est constante, quelle que soit la durée du flux. Sur un robot, chaque étage devient un filtre temps réel. C’est aussi l’architecture des outils Unix (cat | grep | sort) et des frameworks de traitement de données. Le vocabulaire : pipeline, stream processing.

02 / Itération

Expressions génératrices et itertools

pairwise et accumulate en robotique

pairwise donne les couples consécutifs : parfait pour calculer des vitesses à partir de positions ((b − a) / dt), ou des distances entre points d’une trajectoire. accumulate intègre : des vitesses vers des positions. groupby détecte les plages consécutives d’un même état (« le robot a été bloqué pendant 12 cycles »).

03 / Fonctions

Les fonctions sont des valeurs

Fermetures

f se souvient de k même après que multiplicateur a terminé : on dit qu’elle capture son environnement. C’est le mécanisme derrière les contrôleurs de la séance 15 (proportionnel(kp) renvoyait une lambda capturant kp). Une fermeture est une alternative légère à un objet quand on n’a qu’une méthode. map/filter sont souvent remplacés par des compréhensions, plus lisibles.

03 / Fonctions

Décorateurs : modifier une fonction sans la toucher

*args, **kwargs

*args capture tous les arguments positionnels dans un tuple, **kwargs les arguments nommés dans un dictionnaire : l’enveloppe accepte donc n’importe quelle signature et la transmet. Un décorateur est une fonction qui prend une fonction et en renvoie une autre ; le @ n’est que du sucre. Usages courants : chronométrer, mettre en cache, vérifier des droits, réessayer en cas d’erreur, enregistrer les appels. @dataclass et @property (séance 16) sont des décorateurs.

04 / Défis

Défi ★ — Réécrire avec des compréhensions

Consigne

Réécrire chaque bloc en une seule expression (compréhension ou générateur) : 1. les longueurs des mots d’une phrase ; 2. le dictionnaire mot → longueur, seulement pour les mots de plus de 3 lettres ; 3. la somme des carrés des impairs de 1 à 99 ; 4. la matrice identité 4×4 ; 5. la transposée d’une matrice (liste de listes).

Correction
longueurs = [len(mot) for mot in phrase.split()]
d = {mot: len(mot) for mot in phrase.split() if len(mot) > 3}
s = sum(n * n for n in range(1, 100, 2))
identite = [[1 if i == j else 0 for j in range(4)] for i in range(4)]
transposee = [[ligne[j] for ligne in M] for j in range(len(M[0]))]
# ou : transposee = [list(col) for col in zip(*M)]

zip(*M) : l’étoile « déballe » les lignes comme arguments séparés, et zip les regroupe colonne par colonne. Idiome à connaître.

04 / Défis

Défi ★★ — Générateurs pour un fichier énorme

Consigne

Le code crée un fichier de 200 000 lignes « temps,distance ». Écrire, avec des générateurs et sans jamais charger tout le fichier en mémoire : lire_mesures(chemin) qui produit des tuples (t, d) ; detecter_chutes(flux, seuil) qui produit les instants où la distance chute de plus de seuil entre deux mesures consécutives ; puis compter ces chutes et afficher les 5 premières.

Correction
def lire_mesures(chemin):
    with open(chemin) as f:
        for ligne in f:
            t, d = ligne.split(",")
            yield int(t), float(d)

def detecter_chutes(flux, seuil=20):
    precedent = None
    for t, d in flux:
        if precedent is not None and precedent - d > seuil:
            yield t, precedent - d
        precedent = d

chutes = list(detecter_chutes(lire_mesures("gros.csv")))
print(len(chutes), "chutes ; premières :", chutes[:5])
# Variante avec itertools.pairwise :
# chutes = [(t2, d1 - d2) for (t1, d1), (t2, d2) in itertools.pairwise(lire_mesures("gros.csv")) if d1 - d2 > 20]

Le with à l’intérieur du générateur : le fichier reste ouvert tant que le générateur est consommé et se ferme quand il est épuisé. Environ 200 chutes détectées. Ce programme traiterait un fichier de 100 Go avec la même mémoire.

04 / Défis

Défi ★★★ — Esprit prépa : le crible paresseux et le décorateur de réessai

Consigne

1. Écrire premiers(), un générateur infini de nombres premiers, en gardant la liste des premiers déjà trouvés et en ne testant que la divisibilité par ceux ≤ √n. Afficher le 1000ᵉ premier. Comparer le temps avec le crible de la séance 06 pour la même cible.

2. Écrire un décorateur reessayer(tentatives=3, exceptions=(Exception,)) qui relance la fonction si elle lève une de ces exceptions, jusqu’à tentatives fois, puis propage l’erreur. Le tester avec une fonction qui échoue aléatoirement 60 % du temps. (C’est exactement ce qu’on fait pour lire un capteur capricieux.)

Correction et ouverture
def premiers():
    trouves = []
    n = 2
    while True:
        if all(n % p for p in itertools.takewhile(lambda p: p * p <= n, trouves)):
            trouves.append(n)
            yield n
        n += 1

import itertools
print(next(itertools.islice(premiers(), 999, None)))     # 7919

def reessayer(tentatives=3, exceptions=(Exception,)):
    def decorateur(f):
        @functools.wraps(f)
        def enveloppe(*a, **k):
            for i in range(1, tentatives + 1):
                try:
                    return f(*a, **k)
                except exceptions as e:
                    print(f"  tentative {i} échouée : {e}")
                    if i == tentatives:
                        raise
        return enveloppe
    return decorateur

@reessayer(tentatives=5, exceptions=(IOError,))
def lire_capteur():
    if random.random() < 0.6:
        raise IOError("pas de réponse")
    return 42

print(lire_capteur())

Un décorateur avec paramètres est une fonction qui renvoie un décorateur : trois niveaux de def. takewhile s’arrête au premier premier > √n : on ne parcourt pas toute la liste. Le générateur de premiers garde une mémoire croissante mais ne fixe aucune limite à l’avance, contrairement au crible ; il est plus lent (quelques dizaines de ms pour le 1000ᵉ) mais infini. Ouverture : cherchez « crible d’Ératosthène incrémental » (D. O’Neill, 2009), une version paresseuse du crible avec un dictionnaire de multiples, plus rapide que celle-ci.

05 / Vérification

Quelle différence entre [x*x for x in range(10**7)] et (x*x for x in range(10**7)) ?

Deux questions supplémentaires

1. Peut-on parcourir deux fois un générateur ? Non : une fois épuisé, il ne produit plus rien. Il faut le recréer.

2. Que fait @f au-dessus de def g ? g = f(g) : remplace g par ce que f renvoie.

Référence

Les mots à retenir

MotDéfinition
Compréhension[f(x) for x in it if c], aussi pour dict et set.
Itérable / itérateurA __iter__ / a aussi __next__.
GénérateurFonction avec yield ; produit à la demande.
Évaluation paresseuseCalculer seulement quand la valeur est demandée.
FermetureFonction qui capture des variables de son contexte.
DécorateurFonction qui transforme une fonction ; syntaxe @.
PipelineChaîne de générateurs, chacun transformant le flux.

Pour continuer

Votre Python est devenu élégant

Séance suivante : les structures de données qui font réfléchir un robot — piles, files, graphes — et le plus court chemin.

À faire chez soi

Documentation

Générateurs · itertools · functools

← Séance 16SommaireSéance 18 : Piles, files et graphes →