Generator Python: Miglioramento delle prestazioni e semplificazione del codice

Immagina di lavorare a un progetto di data science, e il tuo compito è elaborare un dataset così grande che caricarlo in memoria fa andare in crash la tua macchina. Oppure stai gestendo una sequenza infinita, come un flusso di dati in tempo reale, dove non puoi assolutamente memorizzare tutto contemporaneamente. Queste sono le sfide che portano i data scientist a cercare la caffettiera—e a volte, il pulsante di reset. 

In questo articolo, impareremo a conoscere i generatori di Python e come puoi usarli per semplificare il tuo codice. Questa idea richiede un po’ di pratica, quindi, se sei nuovo a Python e ti senti un po’ perso in questo articolo, prova il nostro corso di Introduzione a Python per costruire una solida base. 

Cosa Sono i Generatori di Python?

Al loro nucleo, i generatori Python sono un tipo speciale di funzione o addirittura un’espressione compatta che produce in modo pigro una sequenza di valori. Pensate ai generatori come a un nastro trasportatore in una fabbrica: invece di impilare tutti i prodotti in un unico posto e finire lo spazio, si elabora ogni elemento man mano che scende lungo la linea. Ciò rende i generatori efficienti in memoria e un’estensione naturale del protocollo iterator di Python, che sottende a molti degli strumenti integrati di Python come i cicli for e le comprensioni.  

La magia dei generatori risiede nella parola chiave yield. A differenza di return, che restituisce un singolo valore ed esce dalla funzione, yield produce un valore, mette in pausa l’esecuzione della funzione e salva il suo stato. Quando il generatore viene chiamato di nuovo, riprende da dove si era interrotto. 

Ad esempio, immaginate di leggere un enorme file di log riga per riga. Un generatore può elaborare ogni riga letta senza caricare l’intero file in memoria. Questa “valutazione pigra” distingue i generatori dalle funzioni tradizionali e li rende uno strumento indispensabile per compiti sensibili alle prestazioni.  

Esempio di base di un generatore Python

Pratichiamo un po’ per comprendere meglio l’idea. Ecco una funzione generatrice che produce i primi n interi.  

def generate_integers(n): for i in range(n): yield i # Si ferma qui e restituisce i # Utilizzando il generatore for num in generate_integers(5): print(num)
0 1 2 3 4

Ho creato un visivo per aiutarti a vedere cosa sta succedendo sotto il cofano:  

Sintassi e modelli dei generatori Python  

I generatori possono essere implementati in diversi modi. Detto ciò, ci sono due modi principali: funzioni generatrici ed espressioni generatrici.

Funzioni generatrici  

Una funzione generatrice è definita come una funzione normale ma utilizza la parola chiave yield invece di return. Quando viene chiamata, restituisce un oggetto generatore che può essere iterato.  

def count_up_to(n): count = 1 while count <= n: yield count count += 1 # Utilizzando il generatore counter = count_up_to(5) for num in counter: print(num)
1 2 3 4 5

Dall’esempio sopra, possiamo vedere che quando la funzione count_up_to viene chiamata, restituisce un oggetto generatore. Ogni volta che il ciclo for richiede un valore, la funzione viene eseguita fino a quando non incontra yield, producendo il valore attuale di count e preservando il suo stato tra le iterazioni in modo da poter riprendere esattamente da dove si era interrotta.

Espressioni generatore  

Le espressioni generatore sono un modo compatto per creare generatori. Sono simili alle comprensioni di lista ma con parentesi invece di parentesi quadre.

# Comprensione di lista (valutazione avida) squares_list = [x**2 for x in range(5)] # [0, 1, 4, 9, 16] # Espressione generatore (valutazione pigra) squares_gen = (x**2 for x in range(5)) # Utilizzando il generatore for square in squares_gen: print(square)
0 1 4 9 16

Quindi, qual è la differenza tra una comprensione di lista e un’espressione generatore? La comprensione di lista crea l’intera lista in memoria, mentre l’espressione generatore produce valori uno alla volta, risparmiando memoria. Se non sei familiare con le comprensioni di lista, puoi leggerne nel nostro Tutorial sulle Comprensioni di Lista in Python.

Generatore Python vs. iteratore

Gli iteratori tradizionali in Python richiedevano classi con metodi espliciti __iter__() e __next__(), il che comportava molto codice ripetitivo e gestione manuale dello stato, mentre le funzioni generatore semplificano il processo preservando automaticamente lo stato ed eliminando la necessità di questi metodi—come dimostrato da una semplice funzione che restituisce il quadrato di ogni numero fino a n.

Perché Utilizziamo i Generatori Python

Nel spiegare cosa sono i generatori di Python, ho anche trasmesso alcune idee su perché vengono utilizzati. In questa sezione, voglio entrare in maggiori dettagli. Perché i generatori non sono solo una caratteristica elegante di Python, ma risolvono realmente problemi concreti.

Efficienza della memoria  

A differenza delle liste o degli array, che memorizzano tutti i loro elementi in memoria contemporaneamente, i generatori producono valori al volo, quindi tengono solo un elemento in memoria alla volta.  

Ad esempio, considera la differenza tra range() e xrange() di Python 2:  

  •  range() creava una lista in memoria, il che poteva essere problematico per intervalli ampi.

  • La funzione xrange() agisce come un generatore, producendo valori in modo pigro.

Dato che il comportamento di xrange() era più utile, ora, in Python 3, range() si comporta anche come un generatore, evitando così il sovraccarico di memoria di memorizzare contemporaneamente tutti i valori.

Per mostrare l’idea, confrontiamo l’uso della memoria nella generazione di una sequenza di 10 milioni di numeri:

import sys # Usando una lista 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") # Usando un generatore 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

Come puoi vedere, il generatore utilizza quasi nessuna memoria rispetto alla lista, e questa differenza è significativa.

Miglioramenti delle prestazioni

Grazie alla valutazione pigra, i valori vengono calcolati solo quando necessario. Ciò significa che puoi iniziare a elaborare i dati immediatamente senza dover attendere che l’intera sequenza venga generata.

Ad esempio, immagina di calcolare la somma dei quadrati dei primi 1 milione di numeri:

# Utilizzando una lista (valutazione anticipata) sum_of_squares_list = sum([x**2 for x in range(1_000_000)]) # Utilizzando un generatore (valutazione pigra) sum_of_squares_gen = sum(x**2 for x in range(1_000_000))

Pur ottenendo lo stesso risultato, la versione con generatore evita di creare una lista massiccia, consentendo di ottenere il risultato più rapidamente.

Semplicità e leggibilità

I generatori semplificano l’implementazione degli iteratori eliminando il codice di base. Confronta un iteratore basato su classe con una funzione generatore:

Ecco l’iteratore basato su 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 # Utilizzo squares = SquaresIterator(5) for square in squares: print(square)

Ecco la funzione generatore:

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

La versione del generatore è più breve, più facile da leggere e non richiede codice di base. È un perfetto esempio della filosofia di Python: semplice è meglio.

Gestione di sequenze infinite

Infine, voglio dire che i generatori sono particolarmente adatti per rappresentare sequenze infinite, qualcosa che è semplicemente impossibile con le liste. Ad esempio, considera la sequenza di Fibonacci:

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

Questo generatore può produrre numeri di Fibonacci indefinitamente senza esaurire la memoria. Altri esempi includono l’elaborazione di flussi di dati in tempo reale o il lavoro con dati di serie temporali.

Concetti avanzati dei generatori Python

Adesso, vediamo alcune idee più complesse. In questa sezione, esploreremo come comporre generatori e utilizzare metodi unici dei generatori come .send(), .throw() e .close().

Concatenazione di generatori

I generatori possono essere combinati. Puoi trasformare, filtrare e elaborare dati in modo modulare concatenando generatori insieme.

Immaginiamo di avere una sequenza infinita di numeri e voler elevare al quadrato ciascun numero e filtrare i risultati dispari:

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 # Comporre i generatori numbers = infinite_sequence() squared = square_numbers(numbers) evens = filter_evens(squared) # Stampare i primi 10 quadrati pari for _ in range(10): print(next(evens))
0 4 16 36 64 100 144 196 256 324

Il processo coinvolge la funzione infinite_sequence che genera numeri indefinitamente, mentre la funzione square_numbers restituisce il quadrato di ciascun numero, e poi filter_evens filtra i numeri dispari per produrre solo quadrati pari. La nostra carriera di Associate Python Developer si occupa di questo tipo di attività, quindi puoi vedere come costruire e debuggare pipeline complesse utilizzando generatori, nonché iteratori e comprensioni di liste.

Metodi speciali per generatori  

I generatori sono dotati di metodi avanzati che consentono la comunicazione bidirezionale e la terminazione controllata.

send()

Il metodo .send() ti consente di passare valori di nuovo a un generatore, trasformandolo in un coroutine. Questo è utile per creare generatori interattivi o con stato. 

def accumulator(): total = 0 while True: value = yield total if value is not None: total += value # Utilizzare il generatore acc = accumulator() next(acc) # Avviare il generatore print(acc.send(10)) # Uscita: 10 print(acc.send(5)) # Uscita: 15 print(acc.send(20)) # Uscita: 35

Ecco come funziona: 

  • Il generatore inizia con next(acc) per inizializzarlo.  

  • Ogni chiamata a .send(value) passa un valore al generatore, che viene assegnato a value nella dichiarazione yield.  

  • Il generatore aggiorna il suo stato (totale) e restituisce il nuovo risultato.

throw()

Il metodo .throw() ti consente di generare un’eccezione all’interno del generatore, utile per la gestione degli errori o per segnalare condizioni specifiche.

def resilient_generator(): try: for i in range(5): yield i except ValueError: yield "Error occurred!" # Utilizzo del generatore gen = resilient_generator() print(next(gen)) # Output: 0 print(next(gen)) # Output: 1 print(gen.throw(ValueError)) # Output: "Si è verificato un errore!"

Ecco come funziona questo: 

  • Di solito il generatore continua a funzionare fino a quando viene chiamato .throw()

  •  L’eccezione viene sollevata all’interno del generatore, che può gestirla utilizzando un blocco try-except.

close()

Il metodo .close() ferma un generatore generando un’eccezione GeneratorExit. Questo è utile per pulire le risorse o fermare i generatori infiniti.

def infinite_counter(): count = 0 try: while True: yield count count += 1 except GeneratorExit: print("Generator closed!") # Utilizzo del generatore counter = infinite_counter() print(next(counter)) # Output: 0 print(next(counter)) # Output: 1 counter.close() # Output: "Generatore chiuso!"

Ecco come funziona:

  • Il generatore continua ad eseguirsi finché non viene chiamato .close().

  •  L’eccezione GeneratorExit viene sollevata, consentendo al generatore di eseguire operazioni di pulizia o di registrare un messaggio prima di terminare.

Applicazioni nel Mondo Reale nella Scienza dei Dati  

Spero che tu stia iniziando ad apprezzare che i generatori sono utili. In questa sezione, cercherò di far risaltare i casi d’uso in modo che tu possa immaginare come funzionano effettivamente per te nella tua vita quotidiana.

Elaborazione di grandi dataset  

Una delle sfide più comuni nella scienza dei dati è lavorare con dataset troppo grandi per essere memorizzati in memoria. I generatori forniscono un modo per elaborare tali dati riga per riga.  

Immagina di avere un file CSV da 10 GB contenente dati sulle vendite e di dover filtrare i record per una regione specifica. Ecco come puoi utilizzare una pipeline di generatori per ottenere questo:

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 del generatore file_path = "sales_data.csv" region = "North America" data = read_large_csv(file_path) filtered_data = filter_by_region(data, region) # Elabora i dati filtrati for record in filtered_data: print(record)

Ecco cosa sta succedendo:

  1. read_large_csv legge il file riga per riga, restituendo ogni riga come un dizionario.

  2. filter_by_region filtra le righe in base alla regione specificata.

  3. Il pipeline elabora i dati in modo incrementale, evitando sovraccarichi di memoria.

Questo approccio beneficia i flussi di lavoro di estrazione, trasformazione e caricamento, dove i dati devono essere puliti e trasformati prima dell’analisi. Vedrai questo tipo di cose nel nostro corso ETL e ELT in Python.

Streaming e pipeline  

I dati a volte arrivano come un flusso continuo. Pensa ai dati dei sensori, ai feed dal vivo o ai social media.

Supponi di lavorare con dispositivi IoT che generano letture di temperatura ogni secondo. Vuoi calcolare la temperatura media su una finestra mobile di 10 letture:

def sensor_data_stream(): """Simulate an infinite stream of sensor data.""" import random while True: yield random.uniform(0, 100) # Simula i dati del sensore 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 del generatore sensor_stream = sensor_data_stream() averages = sliding_window_average(sensor_stream, window_size=10) # Stampa la media ogni secondo for avg in averages: print(f"Average temperature: {avg:.2f}")

Ecco la spiegazione:

  1. sensor_data_stream simula un flusso infinito di letture del sensore.

  2. sliding_window_average mantiene una finestra mobile degli ultimi 10 rilevamenti e restituisce la loro media.  

  3. Il pipeline elabora i dati in tempo reale, rendendolo ideale per il monitoraggio e l’analisi.

Ulteriori casi d’uso

I generatori sono utilizzati anche in situazioni in cui la dimensione dei dati è imprevedibile o quando continuano ad arrivare/infinite.  

Web scraping

Quando si estraggono dati dai siti web, spesso non si sa quante pagine o elementi sarà necessario elaborare. I generatori consentono di gestire questa imprevedibilità in modo elegante:

def scrape_website(url): """ Generator to scrape a website page by page.""" while url: # Simula il recupero e l'analisi di una pagina print(f"Scraping {url}") data = f"Data from {url}" yield data url = get_next_page(url) # Funzione ipotetica per ottenere la pagina successiva # Utilizzo scraper = scrape_website("https://example.com/page1") for data in scraper: print(data)

Attività di simulazione

Nei modelli, come i metodi di Monte Carlo o nello sviluppo di giochi, i generatori possono rappresentare sequenze infinite o dinamiche:

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

Benchmark di Memoria e Velocità

Grazie al loro funzionamento, i generatori eccellono in scenari in cui l’efficienza della memoria è critica, ma (potresti essere sorpreso di saperlo) potrebbero non essere sempre l’opzione più veloce. Confrontiamo i generatori con le liste per comprendere i loro compromessi.

Precedentemente, abbiamo mostrato come i generatori fossero migliori delle liste in termini di memoria. Questa era la parte in cui abbiamo confrontato l’uso della memoria nella generazione di una sequenza di 10 milioni di numeri. Ora facciamo una cosa diversa, un confronto di velocità:

import time # Comprehension della lista 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") # Espressione del generatore 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

Anche se un generatore risparmia memoria, in questo caso è effettivamente più lento della lista. Questo perché, per questo set di dati più piccolo, c’è il sovraccarico di mettere in pausa e riprendere l’esecuzione.

La differenza di prestazioni è trascurabile per set di dati piccoli, ma per set di dati grandi, il risparmio di memoria dei generatori spesso supera il leggero penalità di velocità.

Problemi Che Emergono

Infine, esaminiamo alcuni errori comuni o problemi:

I generatori sono esauribili

Una volta esaurito, un generatore non può essere riutilizzato. Dovrai ricrearlo se vuoi iterare di nuovo.

gen = (x for x in range(5)) print(list(gen)) # Output: [0, 1, 2, 3, 4] print(list(gen)) # Output: [] (il generatore è esaurito)

La valutazione pigra può essere complicata

Poiché i generatori producono valori su richiesta, errori o effetti collaterali potrebbero non apparire fino a quando il generatore non viene iterato.  

Puoi abusare dei generatori

Per piccoli set di dati o compiti semplici, il sovraccarico di usare un generatore potrebbe non valere il risparmio di memoria. Considera questo esempio in cui sto materializzando dati per più iterazioni.

# Espressione del generatore gen = (x**2 for x in range(10)) # Materializza in una lista squares = list(gen) # Riutilizza la lista print(sum(squares)) # Output: 285 print(max(squares)) # Output: 81

Scegliere quando usare i generatori

Per riassumere, fornirò alcune regole molto generali su quando utilizzare i generatori. Usali per:

  • Insiemi di dati di grandi dimensioni: Usa i generatori quando lavori con insiemi di dati troppo grandi per stare in memoria.  
  • Sequenze infinite: Usa i generatori per rappresentare sequenze infinite, come flussi di dati in tempo reale o simulazioni.
  • Pipeline: Usa i generatori per costruire pipeline modulari di elaborazione dati che trasformano e filtrano i dati incrementalmente.  

Quando materializzare i dati invece (convertirli in una lista)

  • Piccoli set di dati: Non utilizzare i generatori se la memoria non è un problema e hai bisogno di un accesso veloce a tutti gli elementi; invece utilizza una lista.
  • Iterazioni multiple: Non utilizzare i generatori se hai bisogno di iterare più volte sugli stessi dati; invece, materializzali in una lista per evitare di ricreare il generatore.

Conclusione e punti chiave

In tutto questo articolo, abbiamo esplorato come i generatori possono aiutarti ad affrontare sfide reali nella scienza dei dati, dalla lavorazione di grandi set di dati alla creazione di pipeline di dati in tempo reale. Continua a praticare. Il modo migliore per padroneggiare i generatori è utilizzarli nel tuo lavoro. Per iniziare, prova a sostituire una comprensione di lista con un’espressione generatore o a rifattorizzare un ciclo in una funzione generatore.

Una volta che hai padroneggiato le basi, puoi esplorare nuovi argomenti più avanzati che si basano sul concetto di generatore:

  • Coroutines: Usa .send() e .throw() per creare generatori che possono ricevere e processare dati, abilitando una comunicazione bidirezionale.

  • Programmazione asincrona: Combina i generatori con la libreria asyncio di Python per costruire applicazioni efficienti e non bloccanti.

  • Concorrenza: Scopri come i generatori possono implementare un multitasking cooperativo e una concorrenza leggera.

Continua a imparare e diventa un esperto. Segui il nostro percorso di carriera Sviluppatore Python o il nostro percorso di abilità Programmazione Python oggi stesso. Clicca sul link qui sotto per iniziare.

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