Générateurs Python : Améliorer les performances et simplifier le code

Imaginez que vous travaillez sur un projet de science des données, et que votre tâche consiste à traiter un ensemble de données si volumineux que le charger en mémoire fait planter votre machine. Ou vous traitez une séquence infinie, comme un flux de données en direct, où il est impossible de tout stocker simultanément. Ce sont des défis qui poussent les data scientists à se tourner vers la cafetière—et parfois, vers le bouton de réinitialisation. 

Dans cet article, nous allons apprendre sur les générateurs Python, et comment vous pouvez les utiliser pour simplifier votre code. Cette idée nécessite un peu de pratique donc, si vous êtes nouveau en Python et que vous vous sentez un peu perdu dans cet article, essayez notre Introduction à Python cours pour établir une base solide. 

Qu’est-ce que les générateurs Python ?

Au cœur de Python, les générateurs sont une sorte spéciale de fonction ou même une expression compacte qui produit une séquence de valeurs de manière paresseuse. Pensez aux générateurs comme à un tapis roulant dans une usine : au lieu d’empiler tous les produits au même endroit et de manquer d’espace, vous traitez chaque article au fur et à mesure qu’il arrive. Cela rend les générateurs efficaces en mémoire et constitue une extension naturelle du protocole iterator de Python, qui sous-tend de nombreux outils intégrés de Python comme les boucles for et les compréhensions.  

La magie derrière les générateurs réside dans le mot-clé yield. Contrairement à return, qui renvoie une seule valeur et quitte la fonction, yield produit une valeur, met en pause l’exécution de la fonction et sauvegarde son état. Lorsque le générateur est appelé à nouveau, il reprend là où il s’était arrêté. 

Par exemple, imaginez que vous lisez un immense fichier journal ligne par ligne. Un générateur peut traiter chaque ligne au fur et à mesure sans charger l’intégralité du fichier en mémoire. Cette « évaluation paresseuse » distingue les générateurs des fonctions traditionnelles et en fait un outil incontournable pour les tâches sensibles à la performance.  

Un exemple de générateur Python basique

Pratiquons un peu pour bien comprendre l’idée. Voici une fonction génératrice qui produit les premiers entiers n.

def generate_integers(n): for i in range(n): yield i # Fait une pause ici et renvoie i # Utilisation de la générateur for num in generate_integers(5): print(num)
0 1 2 3 4

J’ai créé une visualisation pour vous montrer ce qui se passe sous le capot :

Syntaxe et modèles de générateurs Python

Les générateurs peuvent être implémentés de plusieurs manières. Cela dit, il existe deux principales façons : les fonctions génératrices et les expressions génératrices.

Fonctions génératrices

Une fonction génératrice est définie comme une fonction régulière mais utilise le mot-clé yield au lieu de return. Lorsqu’elle est appelée, elle renvoie un objet générateur sur lequel on peut itérer.

def count_up_to(n): count = 1 while count <= n: yield count count += 1 # Utilisation de la générateur counter = count_up_to(5) for num in counter: print(num)
1 2 3 4 5

À partir de l’exemple ci-dessus, nous pouvons voir que lorsque la fonction count_up_to est appelée, elle renvoie un objet générateur. Chaque fois que la boucle for demande une valeur, la fonction s’exécute jusqu’à ce qu’elle atteigne yield, produisant la valeur actuelle de count et préservant son état entre les itérations pour qu’elle puisse reprendre exactement là où elle s’était arrêtée.

Générateur d’expressions  

Les expressions de générateur sont un moyen compact de créer des générateurs. Elles sont similaires aux compréhensions de listes mais avec des parenthèses au lieu de crochets.

# Compréhension de liste (évaluation rapide) squares_list = [x**2 for x in range(5)] # [0, 1, 4, 9, 16] # Expression de générateur (évaluation paresseuse) squares_gen = (x**2 for x in range(5)) # Utilisation du générateur for square in squares_gen: print(square)
0 1 4 9 16

Alors, quelle est la différence entre une compréhension de liste et une expression de générateur? La compréhension de liste crée toute la liste en mémoire, tandis que l’expression de générateur produit des valeurs une à une, en économisant de la mémoire. Si vous n’êtes pas familier avec les compréhensions de liste, vous pouvez en apprendre davantage dans notre Tutoriel sur la Compréhension de Liste Python.

Générateur Python vs itérateur

Les itérateurs traditionnels en Python nécessitaient des classes avec des méthodes explicites __iter__() et __next__(), ce qui impliquait beaucoup de code redondant et de gestion manuelle de l’état, tandis que les fonctions générateur simplifient le processus en préservant automatiquement l’état et en éliminant le besoin de ces méthodes, comme le montre une simple fonction qui renvoie le carré de chaque nombre jusqu’à n.

Pourquoi nous utilisons des générateurs Python

En expliquant ce que sont les générateurs Python, j’ai également transmis une partie de l’idée de pourquoi ils sont utilisés. Dans cette section, je souhaite entrer dans un peu plus de détails. Parce que les générateurs ne sont pas seulement une fonctionnalité élégante de Python, mais ils résolvent réellement des problèmes concrets.

Efficacité mémoire  

Contrairement aux listes ou tableaux, qui stockent tous leurs éléments en mémoire simultanément, les générateurs produisent des valeurs à la volée, donc ils ne conservent qu’un seul élément en mémoire à la fois.  

Par exemple, considérons la différence entre range() et xrange() de Python 2 :  

  •  range() créait une liste en mémoire, ce qui pouvait poser problème pour de grandes plages.

  • La fonction xrange() agissait comme un générateur, produisant des valeurs de manière paresseuse.

Comme le comportement de xrange() était plus utile, maintenant, en Python 3, range() se comporte également comme un générateur, évitant ainsi les frais de mémoire liés au stockage simultané de toutes les valeurs.  

Pour illustrer l’idée, comparons l’utilisation de la mémoire lors de la génération d’une séquence de 10 millions de nombres:  

import sys # En utilisant une liste numbers_list = [x for x in range(10_000_000)] print(f"Memory used by list: {sys.getsizeof(numbers_list) / 1_000_000:.2f} MB") # En utilisant un générateur numbers_gen = (x for x in range(10_000_000)) print(f"Memory used by generator: {sys.getsizeof(numbers_gen)} bytes")
Memory used by list: 89.48 MB Memory used by the generator: 112 bytes

Comme vous pouvez le constater, le générateur utilise presque pas de mémoire par rapport à la liste, et cette différence est significative.

Améliorations des performances  

Grâce à l’évaluation paresseuse, les valeurs ne sont calculées que lorsque nécessaire. Cela signifie que vous pouvez commencer le traitement des données immédiatement sans attendre que l’ensemble de la séquence soit généré.

Par exemple, imaginez que vous devez calculer la somme des carrés des premiers 1 million de nombres :

# En utilisant une liste (évaluation immédiate) sum_of_squares_list = sum([x**2 for x in range(1_000_000)]) # En utilisant un générateur (évaluation paresseuse) sum_of_squares_gen = sum(x**2 for x in range(1_000_000))

Alors que les deux approches donnent le même résultat, la version générateur évite de créer une liste massive, nous obtenons donc le résultat plus rapidement.

Simplicité et lisibilité  

Les générateurs simplifient la mise en œuvre des itérateurs en éliminant le code standard. Comparez un itérateur basé sur une classe avec une fonction génératrice :  

Voici l’itérateur basé sur une classe :

class SquaresIterator: def __init__(self, n): self.n = n self.current = 0 def __iter__(self): return self def __next__(self): if self.current >= self.n: raise StopIteration result = self.current ** 2 self.current += 1 return result # Utilisation squares = SquaresIterator(5) for square in squares: print(square)

Voici la fonction génératrice :

def squares_generator(n): for i in range(n): yield i ** 2 # Utilisation squares = squares_generator(5) for square in squares: print(square)

La version génératrice est plus courte, plus facile à lire et ne nécessite pas de code standard. C’est un parfait exemple de la philosophie de Python : simple est mieux.  

Gestion des séquences infinies  

Enfin, je veux dire que les générateurs sont particulièrement adaptés pour représenter des séquences infinies, quelque chose qui est tout simplement impossible avec des listes. Par exemple, considérons la séquence de Fibonacci :  

def fibonacci(): a, b = 0, 1 while True: yield a a, b = b, a + b # Utilisation fib = fibonacci() for _ in range(10): print(next(fib))
0 1 1 2 3 5 8 13 21 34

Ce générateur peut produire des nombres de Fibonacci indéfiniment sans manquer de mémoire. D’autres exemples incluent le traitement de flux de données en direct ou le travail avec des données de séries temporelles.  

Concepts avancés de générateur Python  

Maintenant, examinons des idées plus difficiles. Dans cette section, nous explorerons comment composer des générateurs et utiliser des méthodes de générateur uniques comme .send(), .throw() et .close().  

Chaîner des générateurs ensemble

Les générateurs peuvent être combinés. Vous pouvez transformer, filtrer et traiter des données de manière modulaire en chaînant des générateurs ensemble. 

Disons que vous avez une séquence infinie de nombres et que vous souhaitez élever chaque nombre au carré et filtrer les résultats impairs :

def infinite_sequence(): num = 0 while True: yield num num += 1 def square_numbers(sequence): for num in sequence: yield num ** 2 def filter_evens(sequence): for num in sequence: if num % 2 == 0: yield num # Composer les générateurs numbers = infinite_sequence() squared = square_numbers(numbers) evens = filter_evens(squared) # Imprimer les 10 premiers carrés pairs for _ in range(10): print(next(evens))
0 4 16 36 64 100 144 196 256 324

Le processus implique la fonction infinite_sequence générant des nombres indéfiniment, tandis que la fonction square_numbers produit le carré de chaque nombre, et ensuite filter_evens filtre les nombres impairs pour ne produire que des carrés pairs. Notre piste de carrière Associate Python Developer aborde ce type de choses, donc vous pouvez voir comment construire et déboguer des pipelines complexes en utilisant des générateurs, ainsi que des itérateurs et des compréhensions de listes. 

Méthodes spéciales de générateur  

Les générateurs sont accompagnés de méthodes avancées permettant une communication bidirectionnelle et une terminaison contrôlée.

send()

La méthode .send() vous permet de transmettre des valeurs de retour dans un générateur, le transformant en coroutine. Cela est utile pour créer des générateurs interactifs ou stateful.  

def accumulator(): total = 0 while True: value = yield total if value is not None: total += value # Utilisation du générateur acc = accumulator() next(acc) # Démarrer le générateur print(acc.send(10)) # Sortie : 10 print(acc.send(5)) # Sortie : 15 print(acc.send(20)) # Sortie : 35

Voici comment ça marche :

  • Le générateur démarre avec next(acc) pour l’initialiser.

  • Chaque appel à .send(valeur) passe une valeur dans le générateur, qui est assignée à valeur dans l’instruction yield.

  • Le générateur met à jour son état (total) et renvoie le nouveau résultat.

throw()

La méthode .throw() vous permet de déclencher une exception à l’intérieur du générateur, ce qui peut être utile pour la gestion des erreurs ou pour signaler des conditions spécifiques.

def resilient_generator(): try: for i in range(5): yield i except ValueError: yield "Error occurred!" # Utilisation du générateur gen = resilient_generator() print(next(gen)) # Sortie : 0 print(next(gen)) # Sortie : 1 print(gen.throw(ValueError)) # Sortie : "Une erreur s'est produite!"

Voici comment celui-ci fonctionne :

  • Le générateur s’exécute généralement jusqu’à ce que .throw() soit appelé.

  • L’exception est levée à l’intérieur du générateur, qui peut la gérer en utilisant un bloc try-except.

close()

La méthode .close() arrête un générateur en levant une exception GeneratorExit. Cela est utile pour nettoyer les ressources ou arrêter les générateurs infinis.

def infinite_counter(): count = 0 try: while True: yield count count += 1 except GeneratorExit: print("Generator closed!") # Utilisation du générateur counter = infinite_counter() print(next(counter)) # Sortie : 0 print(next(counter)) # Sortie : 1 counter.close() # Sortie : "Générateur fermé!"

Et voici comment cela fonctionne :

  • Le générateur s’exécute jusqu’à ce que .close() soit appelé.

  • L’exception GeneratorExit est levée, permettant au générateur de nettoyer ou d’enregistrer un message avant de se terminer.

Applications du monde réel en science des données

J’espère que vous commencez à apprécier que les générateurs sont utiles. Dans cette section, je vais essayer de faire ressortir les cas d’utilisation afin que vous puissiez imaginer comment ils fonctionnent réellement pour vous au quotidien.

Traitement de grands ensembles de données

Un des défis les plus courants en science des données est de travailler avec des ensembles de données trop volumineux pour tenir en mémoire. Les générateurs fournissent un moyen de traiter ces données ligne par ligne.

Imaginez que vous ayez un fichier CSV de 10 Go contenant des données de ventes et que vous ayez besoin de filtrer les enregistrements pour une région spécifique. Voici comment vous pouvez utiliser un pipeline de générateurs pour y parvenir :

import csv def read_large_csv(file_path): """ Generator to read a large CSV file line by line.""" with open(file_path, mode="r") as file: reader = csv.DictReader(file) for row in reader: yield row def filter_by_region(data, region): """ Generator to filter rows by a specific region.""" for row in data: if row["Region"] == region: yield row # Pipeline de générateurs file_path = "sales_data.csv" region = "North America" data = read_large_csv(file_path) filtered_data = filter_by_region(data, region) # Traiter les données filtrées for record in filtered_data: print(record)

Voici ce qui se passe :

  1. read_large_csv lit le fichier ligne par ligne, renvoyant chaque ligne sous forme de dictionnaire.

  2. filter_by_region filtre les lignes en fonction de la région spécifiée.

  3. Le pipeline traite les données de manière incrémentielle, évitant la surcharge de mémoire.

Cette approche profite aux flux de travail d’extraction, de transformation et de chargement, où les données doivent être nettoyées et transformées avant l’analyse. Vous verrez ce type de chose dans notre cours ETL et ELT en Python.

Le streaming et les pipelines

Parfois, les données arrivent sous forme de flux continu. Pensez aux données des capteurs, aux flux en direct ou aux médias sociaux.

Supposons que vous travaillez avec des appareils IoT qui génèrent des relevés de température toutes les secondes. Vous voulez calculer la température moyenne sur une fenêtre glissante de 10 relevés :

def sensor_data_stream(): """Simulate an infinite stream of sensor data.""" import random while True: yield random.uniform(0, 100) # Simuler des données de capteur def sliding_window_average(stream, window_size): """ Calculate the average over a sliding window of readings.""" window = [] for value in stream: window.append(value) if len(window) > window_size: window.pop(0) if len(window) == window_size: yield sum(window) / window_size # Pipeline de génération sensor_stream = sensor_data_stream() averages = sliding_window_average(sensor_stream, window_size=10) # Afficher la moyenne chaque seconde for avg in averages: print(f"Average temperature: {avg:.2f}")

Voici l’explication :

  1. sensor_data_stream simule un flux infini de relevés de capteur.

  2. sliding_window_average maintient une fenêtre glissante des 10 dernières lectures et produit leur moyenne.  

  3. Le pipeline traite les données en temps réel, ce qui le rend idéal pour la surveillance et l’analyse.

Utilisations supplémentaires

Les générateurs sont également utilisés dans des situations où la taille des données est imprévisible ou lorsqu’elle ne cesse d’arriver/est infinie.  

Le web scraping

Lorsque vous scrapez des sites web, vous ne savez souvent pas combien de pages ou d’éléments vous devrez traiter. Les générateurs vous permettent de gérer cette imprévisibilité de manière élégante:

def scrape_website(url): """ Generator to scrape a website page by page.""" while url: # Simuler le téléchargement et l'analyse d'une page print(f"Scraping {url}") data = f"Data from {url}" yield data url = get_next_page(url) # Fonction hypothétique pour obtenir la page suivante # Utilisation scraper = scrape_website("https://example.com/page1") for data in scraper: print(data)

Tâches de simulation

Dans les simulations, comme les méthodes de Monte Carlo ou le développement de jeux, les générateurs peuvent représenter des séquences infinies ou dynamiques:

def monte_carlo_simulation(): """ Generator to simulate random events for Monte Carlo analysis.""" import random while True: yield random.random() # Utilisation simulation = monte_carlo_simulation() for _ in range(10): print(next(simulation))

Benchmarks de mémoire et de vitesse

En raison de leur fonctionnement, les générateurs excellent dans les scénarios où l’efficacité mémoire est cruciale, mais (vous pourriez être surpris de le savoir) ils ne sont pas toujours l’option la plus rapide. Comparons les générateurs avec les listes pour comprendre leurs compromis.

Auparavant, nous avons montré que les générateurs étaient meilleurs que les listes en termes de mémoire. C’était la partie où nous avons comparé l’utilisation de la mémoire lors de la génération d’une séquence de 10 millions de nombres. Faisons maintenant une comparaison de vitesse :

import time # Compréhension de liste start_time = time.time() sum([x**2 for x in range(1_000_000)]) print(f"List comprehension time: {time.time() - start_time:.4f} seconds") # Expression de générateur start_time = time.time() sum(x**2 for x in range(1_000_000)) print(f"Generator expression time: {time.time() - start_time:.4f} seconds")
List comprehension time: 0.1234 seconds Generator expression time: 0.1456 seconds

Alors qu’un générateur économise de la mémoire, dans ce cas, il est en fait plus lent que la liste. Cela est dû au fait que, pour cet ensemble de données plus petit, il y a des frais généraux de pause et de reprise de l’exécution.

La différence de performance est négligeable pour de petits ensembles de données, mais pour de grands ensembles de données, les économies de mémoire des générateurs l’emportent souvent sur la légère pénalité de vitesse.

Problèmes qui surviennent

Enfin, examinons quelques erreurs ou problèmes courants :

Les générateurs sont épuisables

Une fois qu’un générateur est épuisé, il ne peut pas être réutilisé. Vous devrez le recréer si vous souhaitez itérer à nouveau.

gen = (x for x in range(5)) print(list(gen)) # Sortie : [0, 1, 2, 3, 4] print(list(gen)) # Sortie : [] (le générateur est épuisé)

L’évaluation paresseuse peut être délicate

Comme les générateurs produisent des valeurs à la demande, les erreurs ou les effets secondaires peuvent ne pas apparaître avant que le générateur ne soit itéré.

Vous pouvez surutiliser les générateurs

Pour de petits ensembles de données ou des tâches simples, le surcoût d’utilisation d’un générateur peut ne pas valoir les économies de mémoire. Considérez cet exemple où je matérialise des données pour plusieurs itérations.

# Expression de générateur gen = (x**2 for x in range(10)) # Matérialiser dans une liste squares = list(gen) # Réutiliser la liste print(sum(squares)) # Sortie : 285 print(max(squares)) # Sortie : 81

Choisir quand utiliser des générateurs

Pour récapituler, je vais fournir quelques règles très générales sur quand utiliser les générateurs. Utilisez pour:

  • Grands ensembles de données: Utilisez des générateurs lors de travailler avec des ensembles de données trop grands pour tenir en mémoire.
  • Séquences infinies: Utilisez des générateurs pour représenter des séquences infinies, telles que des flux de données en direct ou des simulations.
  • Pipelines: Utilisez des générateurs pour construire des pipelines de traitement de données modulaires qui transforment et filtrent les données de manière incrementielle.

Quand matérialiser les données (convertir en liste)

  • Petits ensembles de données: N’utilisez pas les générateurs si la mémoire n’est pas un problème et que vous avez besoin d’un accès rapide à tous les éléments; utilisez plutôt une liste.  
  • Itérations multiples: N’utilisez pas les générateurs si vous avez besoin d’itérer plusieurs fois sur les mêmes données; au lieu de cela, matérialisez-les sous forme de liste pour éviter de recréer le générateur.  

Conclusion et points clés

Tout au long de cet article, nous avons exploré comment les générateurs peuvent vous aider à relever des défis du monde réel en science des données, du traitement de grands ensembles de données à la création de pipelines de données en temps réel. Continuez à pratiquer. La meilleure façon de maîtriser les générateurs est de les utiliser dans votre propre travail. Pour commencer, essayez de remplacer une compréhension de liste par une expression de générateur ou de refactoriser une boucle en une fonction génératrice.

Une fois que vous avez maîtrisé les bases, vous pouvez explorer de nouveaux sujets plus avancés qui s’appuient sur le concept de générateur:

  • Coroutines: Utilisez .send() et .throw() pour créer des générateurs qui peuvent recevoir et traiter des données, permettant une communication bidirectionnelle.

  • Programmation asynchrone: Combinez les générateurs avec la bibliothèque asyncio de Python pour construire des applications efficaces et non bloquantes.  

  • Concurrence: Apprenez comment les générateurs peuvent mettre en œuvre le multitâche coopératif et la concurrence légère.  

Continuez à apprendre et devenez un expert. Suivez notre parcours de carrière Développeur Python ou notre parcours de compétences Programmation Python dès aujourd’hui. Cliquez sur le lien ci-dessous pour commencer.

Source:
https://www.datacamp.com/tutorial/python-generators