Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 4

Immutable Data Patterns in Python

Recommended resources

Ciao! Nella lezione precedente abbiamo aggiunto al nostro arsenale due strumenti fondamentali per la programmazione funzionale in Python: tuple e frozenset. Abbiamo visto come usarli per creare strutture dati che, per loro natura, non possono essere modificate, e abbiamo discusso l'importante sottigliezza dell'immutabilità "superficiale".

Oggi, mettiamo in pratica tutto ciò che abbiamo appreso in questo primo modulo. L'obiettivo è imparare a rinnovare (refactoring) codice imperativo per utilizzare pattern di dati immutabili. Passeremo da uno stile che dice al computer "come" fare le cose, passo dopo passo, modificando dati lungo il percorso, a uno stile funzionale che dichiara "cosa" vogliamo ottenere, trasformando dati immutabili in nuovi dati immutabili.

Questa lezione conclude il nostro modulo sulle fondamenta del pensiero funzionale e ci prepara per il prossimo grande argomento: le funzioni di ordine superiore.

1. Il principio cardine: non modificare, crea!

Lo spostamento più significativo dal pensiero imperativo a quello funzionale è questo: invece di alterare una struttura dati esistente, si crea una nuova struttura dati che rappresenta il risultato della trasformazione. Una funzione che modifica il suo input è detta avere un effetto collaterale (side effect). Una funzione che non lo fa è più vicina all'ideale di una funzione pura.

Questo approccio ha enormi vantaggi in termini di prevedibilità e debug, come abbiamo discusso. Ma come si traduce in pratica nel codice Python?

Il video seguente introduce perfettamente questo cambiamento di mentalità.

7 Functional Programming Techniques EVERY Developer Should Know

Questo video di ArjanCodes mostra il passaggio fondamentale dal modificare dati in-place alla creazione di nuovi dati, che è il cuore del refactoring verso l'immutabilità.

Guarda i segmenti dal 06:14 al 08:38. Presta attenzione a: La spiegazione dei benefici dell'immutabilità (prevedibilità, concorrenza). L'esempio pratico di refactoring: la funzione do_operations viene modificata per non alterare più l'argomento data, ma per restituire una nuova lista creata con una list comprehension. Lo stesso principio viene applicato alla funzione bubble_sort creando una copia interna.

Il messaggio chiave è chiaro: se una funzione deve cambiare qualcosa, dovrebbe restituire una nuova versione dei dati con le modifiche applicate, lasciando l'originale intatto.


2. Refactoring del pattern accumulatore

Uno dei costrutti più comuni nel codice imperativo è il "pattern accumulatore": inizializzare una variabile, iterare su una collezione e aggiornare la variabile ad ogni passo. Vediamo come trasformarlo.

L'esempio classico è calcolare un punteggio.

Stile imperativo:

def scrabble_score_imperative(word, scoretable):
    score = 0  # 1. Inizializza l'accumulatore
    for letter in word: # 2. Itera
        if letter in scoretable:
            score += scoretable[letter] # 3. Modifica l'accumulatore
    return score

Questo codice è una serie di istruzioni dettagliate. Ora, confrontiamolo con la sua controparte funzionale.

Stile funzionale:

def scrabble_score_functional(word, scoretable):
    return sum(scoretable.get(letter, 0) for letter in word)

Qui non ci sono passaggi intermedi o variabili che cambiano stato. Stiamo dichiarando il risultato: è la somma di una serie di valori, dove ogni valore è ottenuto cercando una lettera nella scoretable. Questa espressione (una generator expression in questo caso) crea una sequenza di punteggi al volo, e sum li consuma. L'originale word e scoretable non vengono mai toccati.

La leggibilità è un tema che ti interessa. Quale dei due stili comunica meglio l'intento?

Functional vs. imperative style in Python

Questa discussione su Stack Overflow analizza proprio questo esempio, confrontando la leggibilità dei due stili. È un'ottima lettura per capire come programmatori esperti ragionano su questi compromessi.

Leggi la domanda originale e le prime due risposte. Concentrati su come la versione funzionale, sebbene più compatta, possa essere scomposta mentalmente e su come la versione imperativa richieda di tenere traccia di variabili che cambiano stato (score). Nota anche il commento finale: la leggibilità dipende anche dall'esperienza e dal contesto del team.

Come evidenziato nel post, il passaggio a uno stile funzionale non significa scrivere codice "intelligente" e illeggibile. Significa sfruttare astrazioni più potenti (come sum e le list/generator comprehension) per esprimere l'intento in modo più diretto.


3. Refactoring di strutture dati: verso oggetti immutabili

Nella lezione precedente, abbiamo fatto un refactoring da una struttura dati mutabile (list[dict]) a una completamente immutabile (tuple[namedtuple]). Rivediamo l'essenza di quella trasformazione.

Functional Programming in Python: Immutable Data Structures

Questo è un breve ripasso dell'esempio pratico della lezione precedente, che è un caso perfetto di refactoring di una struttura dati.

Riguarda rapidamente i momenti chiave del video: la definizione del problema con list[dict] (05:30), l'introduzione di namedtuple per i singoli record (09:47), e l'uso finale di una tuple per contenere il tutto (13:33). Questo consolida il pattern di refactoring.

Oltre a namedtuple, Python moderno offre uno strumento ancora più potente e flessibile per creare oggetti-dati: i dataclasses. Con un semplice decoratore, possiamo ottenere classi concise e, soprattutto, immutabili.

Creating Immutable Classes In Python

Questo articolo spiega come creare classi immutabili in Python, focalizzandosi sul decoratore @dataclass.

Leggi la sezione "Using the dataclass Decorator". Osserva come l'argomento frozen=True trasformi una semplice definizione di classe in un contenitore di dati immutabile che solleva un'eccezione (FrozenInstanceError) se si tenta di modificarlo.

Vediamo un refactoring da una classe Python standard a un dataclass immutabile.

Prima: Classe mutabile standard




# Un oggetto che può essere modificato in qualsiasi momento
class Product:
    def __init__(self, product_id, name, price):
        self.product_id = product_id
        self.name = name
        self.price = price

    def __repr__(self):
        return f"Product(id={self.product_id}, name='{self.name}', price={self.price})"

p = Product(101, "Mouse", 25.00)



# Questo è permesso, ma potrebbe essere un bug non intenzionale
p.price = 20.00 
print(p) # Output: Product(id=101, name='Mouse', price=20.0)

Dopo: Dataclass immutabile

from dataclasses import dataclass

@dataclass(frozen=True)
class Product:
    product_id: int
    name: str
    price: float

p = Product(101, "Mouse", 25.00)



# Questo tentativo solleverà un'eccezione!
# p.price = 20.00 
# >> dataclasses.FrozenInstanceError: cannot assign to field 'price'




# Per 'modificare' il prezzo, dobbiamo creare un nuovo oggetto
p_updated = Product(p.product_id, p.name, 20.00)
print(p)          # Originale è intatto: Product(product_id=101, name='Mouse', price=25.0)
print(p_updated)  # Nuovo oggetto con il prezzo aggiornato: Product(product_id=101, name='Mouse', price=20.0)

Questo pattern è centrale in FP: le trasformazioni (come un cambio di prezzo) producono nuove istanze dello stato, non modificano quello vecchio.


Esercizio di Refactoring

Ora mettiamo tutto insieme. Ecco una semplice funzione imperativa che gestisce una lista di ordini. Il suo compito è filtrare gli ordini spediti e scontare del 10% quelli rimanenti che hanno un valore superiore a 50.

Codice Imperativo da Rinnovare:




# Lista di ordini, rappresentati come dizionari mutabili
orders = [
    {"id": "A123", "amount": 120.50, "status": "pending"},
    {"id": "B456", "amount": 45.00,  "status": "pending"},
    {"id": "C789", "amount": 200.00, "status": "shipped"},
    {"id": "D012", "amount": 75.00,  "status": "pending"},
]

def process_orders_imperative(order_list):



    # Rimuove gli ordini spediti, modificando la lista originale!
    # Iteriamo su una copia per evitare problemi durante la rimozione
    for order in list(order_list): 
        if order["status"] == "shipped":
            order_list.remove(order)




    # Applica lo sconto, modificando i dizionari in-place!
    for order in order_list:
        if order["amount"] > 50:
            order["amount"] *= 0.90 # Sconto del 10%




# Eseguiamo la funzione
process_orders_imperative(orders)
print(orders)

Questo codice funziona, ma ha due problemi principali:

  1. La funzione process_orders_imperative modifica la lista che riceve in input (un side effect).
  2. I dizionari all'interno della lista vengono anch'essi modificati.

Il tuo compito è scrivere una versione funzionale, process_orders_functional, che non abbia effetti collaterali. Segui questi passi:

  1. Definisci un @dataclass(frozen=True) chiamato Order per rappresentare i dati in modo immutabile.
  2. La tua nuova funzione dovrebbe accettare una lista (o tupla) di oggetti Order.
  3. Usa una list comprehension (o una generator expression) per costruire una nuova lista che contenga solo gli ordini trasformati.
  4. La trasformazione dovrebbe:
    a. Filtrare via gli ordini con status == 'shipped'.
    b. Per gli ordini rimanenti, creare un nuovo oggetto Order con il prezzo scontato se l'importo originale è > 50, altrimenti creare una copia identica.

Prova a scrivere la soluzione prima di guardare quella qui sotto.

Clicca per vedere la soluzione funzionale
from dataclasses import dataclass, replace

@dataclass(frozen=True)
class Order:
    id: str
    amount: float
    status: str

def process_orders_functional(orders: list[Order]) -> list[Order]:
    processed_orders = []
    for order in orders:



        # 1. Filtra
        if order.status != 'shipped':



            # 2. Trasforma
            if order.amount > 50:



                # Crea un nuovo oggetto Order con l'amount aggiornato
                # `replace` è un'utility dei dataclass molto comoda per questo!
                new_order = replace(order, amount=round(order.amount * 0.9, 2))
                processed_orders.append(new_order)
            else:



                # L'ordine non cambia, lo aggiungiamo così com'è
                processed_orders.append(order)
    return processed_orders




# Una versione ancora più concisa con una list comprehension
def process_orders_functional_comp(orders: list[Order]) -> list[Order]:
    return [
        replace(order, amount=round(order.amount * 0.9, 2)) if order.amount > 50 else order
        for order in orders
        if order.status != 'shipped'
    ]




# Dati iniziali, ora come oggetti immutabili
immutable_orders = [
    Order("A123", 120.50, "pending"),
    Order("B456", 45.00,  "pending"),
    Order("C789", 200.00, "shipped"),
    Order("D012", 75.00,  "pending"),
]




# Eseguiamo la versione funzionale
new_orders = process_orders_functional_comp(immutable_orders)

print("Original orders (untouched):")
print(immutable_orders)
print("\nNew, processed orders:")
print(new_orders)

Nota come la lista immutable_orders originale rimanga perfettamente intatta dopo la chiamata alla funzione. La funzione process_orders_functional_comp non ha prodotto alcun effetto collaterale, ma ha restituito un nuovo valore che rappresenta il risultato della computazione. Questo è il cuore della programmazione funzionale.


Conclusione

In questa lezione abbiamo fatto il passo cruciale dalla conoscenza alla pratica, trasformando codice imperativo in codice funzionale basato sull'immutabilità.

Punti chiave da ricordare:

  • La regola fondamentale del refactoring funzionale è: crea nuovi valori invece di modificare quelli esistenti.
  • I pattern imperativi comuni come i cicli con accumulatori possono essere elegantemente sostituiti da espressioni dichiarative come le list comprehension e funzioni aggregate (sum, min, max, etc.).
  • Per rendere le funzioni prive di effetti collaterali, assicurati che operino su copie dei dati o restituiscano sempre nuove istanze delle strutture dati.
  • Usa @dataclass(frozen=True) come strumento moderno e robusto per definire i tuoi contenitori di dati immutabili, preferendolo a classi mutabili o a semplici dizionari.

Abbiamo completato il primo modulo e gettato le fondamenta del pensiero funzionale. Abbiamo imparato a vedere il codice in termini di dati e trasformazioni, non di procedure e stato mutevole.

Nella prossima lezione, inizieremo un nuovo capitolo entusiasmante: le funzioni di ordine superiore. Impareremo a trattare le funzioni stesse come dati, passandole come argomenti ad altre funzioni. Questo sbloccherà un livello di espressività e astrazione completamente nuovo.