Vai al contenuto

1.3.4 - Decoratori e Generatori

In questa lezione esploreremo due funzionalità avanzate di Python che distinguono un programmatore principiante da un esperto: i Decoratori, per estendere le funzionalità delle funzioni, e i Generatori, per gestire flussi di dati in modo efficiente.

Decoratori

Un decoratore è un design pattern che permette di modificare o estendere il comportamento di una funzione (o metodo) senza modificarne il codice sorgente. In Python, i decoratori sono funzioni che accettano un'altra funzione come argomento e restituiscono una nuova funzione "arricchita".

Si applicano usando la sintassi @nome_decoratore sopra la definizione della funzione.

Creare un Decoratore: Il Pattern Standard

Il caso d'uso più comune per un decoratore è eseguire del codice prima e dopo la funzione target (es. logging, timing, controllo accessi).

Per scrivere un decoratore, usiamo una struttura a "matrioska": una funzione esterna (il decoratore) che definisce una funzione interna (il wrapper).

import functools
import time

def timer(func):
    """Stampa il tempo di esecuzione della funzione decorata."""

    # @functools.wraps preserva il nome e la docstring della funzione originale
    @functools.wraps(func) 
    def wrapper(*args, **kwargs):
        start_time = time.perf_counter()

        # 1. Eseguiamo la funzione originale
        value = func(*args, **kwargs)

        end_time = time.perf_counter()
        run_time = end_time - start_time

        # 2. Aggiungiamo il comportamento extra (logging)
        print(f"La funzione {func.__name__!r} ha impiegato {run_time:.4f} secondi")

        # 3. Restituiamo il valore originale
        return value

    return wrapper

Applicare il Decoratore

Ora possiamo applicare @timer a qualsiasi funzione per misurarne le performance senza toccare la logica interna:

@timer
def waste_time(num_times: int):
    for _ in range(num_times):
        sum([i**2 for i in range(10_000)])

waste_time(100)
# Output: La funzione 'waste_time' ha impiegato 0.3201 secondi

Best Practice: functools.wraps

Notate l'uso di @functools.wraps(func) dentro il decoratore. Senza di esso, la funzione decorata perderebbe la sua identità (il nome __name__ diventerebbe wrapper invece di waste_time), rendendo difficile il debugging.

Decoratori built-in utili

Python offre decoratori potentissimi nella libreria standard. In Data Science, il più importante è @lru_cache (Least Recently Used Cache) dal modulo functools. Serve per la memoization: salva i risultati delle funzioni costose e li restituisce istantaneamente se gli stessi argomenti vengono ripassati.

from functools import lru_cache

@lru_cache(maxsize=None)
def fibonacci(n: int) -> int:
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

# La prima esecuzione calcola, le successive leggono dalla cache
print(fibonacci(30)) 

Generatori

I generatori sono funzioni speciali che permettono di creare iteratori in modo semplice. A differenza delle funzioni normali che restituiscono un valore e terminano (return), i generatori producono una sequenza di valori nel tempo, sospendendo l'esecuzione tra un valore e l'altro (yield).

Lazy Evaluation (Valutazione Pigra)

Il vantaggio principale è l'efficienza della memoria. Una lista carica tutti i dati in RAM. Un generatore calcola il valore successivo solo quando richiesto (Lazy Evaluation).

Immaginiamo di dover processare una sequenza di un milione di numeri:

# Approccio con LISTA (Eager): Occupa molta RAM
def get_squares_list(n):
    result = []
    for i in range(n):
        result.append(i ** 2)
    return result

# Approccio con GENERATORE (Lazy): Memoria quasi zero
def get_squares_gen(n):
    for i in range(n):
        yield i ** 2  # Mette in pausa la funzione e restituisce il valore

Utilizzo:

# Creiamo il generatore (non calcola nulla ancora)
squares = get_squares_gen(5)

# Otteniamo i valori uno alla volta
print(next(squares)) # 0
print(next(squares)) # 1

# O iteriamo con un ciclo for
for num in get_squares_gen(5):
    print(num)

Generator Expressions

Esattamente come le List Comprehension, Python offre le Generator Expressions. La sintassi è identica ma usa le parentesi tonde () invece delle quadre [].

import sys

# List Comprehension: Crea subito una lista di 10.000 elementi
lista = [i * 2 for i in range(10000)]

# Generator Expression: Crea un oggetto generatore (leggerissimo)
generatore = (i * 2 for i in range(10000))

print(f"Dimensione Lista: {sys.getsizeof(lista)} bytes")
# Output: ~85 KB
print(f"Dimensione Generatore: {sys.getsizeof(generatore)} bytes")
# Output: ~100-200 bytes (costante, indipendentemente da n!)

Caso d'uso reale: Data Pipelines

In progetti di Machine Learning, spesso i dataset sono troppo grandi per stare nella RAM (es. file di log da 50GB o dataset di immagini). I generatori sono la soluzione standard per creare Data Pipelines.

Esempio di lettura file "pigra":

def read_large_file(file_path: str):
    """Generatore che legge un file una riga alla volta."""
    with open(file_path, "r") as file:
        for line in file:
            yield line.strip()

# Pipeline di processamento dati
log_lines = read_large_file("server_logs.txt")

# Possiamo processare file infiniti senza crashare la RAM
for line in log_lines:
    if "ERROR" in line:
        print(f"Trovato errore: {line}")

Yield vs Return

Caratteristica return yield
Comportamento Termina la funzione e restituisce il risultato finale. Sospende la funzione, restituisce un valore e salva lo stato.
Memoria Deve costruire l'intero output in memoria. Produce un elemento alla volta (memory efficient).
Uso Funzioni standard, calcoli immediati. Stream di dati, file grandi, sequenze infinite.