Salta al contenuto principale

10 - Python per i dati 3: funzioni, file, eccezioni, oggetti

Funzioni ben progettate, testo e byte con le codifiche, lettura di JSON e CSV, eccezioni e classi: come scrivere Python che non si rompe in silenzio.

Cosa ne pensi di questo articolo?

10 - Python per i dati 3: funzioni, file, eccezioni, oggetti
Machine Learning Engineerfedericocalo.dev

In Breve

Funzioni ben progettate, testo e byte con le codifiche, lettura di JSON e CSV, eccezioni e classi: come scrivere Python che non si rompe in silenzio.

Un file di testo non contiene testo: contiene byte, e il testo nasce solo quando scegli una codifica per leggerli. È la ragione per cui è diventa è quando apri un CSV nel modo sbagliato. Questa lezione del corso Machine Learning Engineer (modulo «Python per i dati») ti porta da righe sparse a codice organizzato: funzioni piccole e testabili, file letti e scritti senza corrompere gli accenti, errori gestiti invece che ignorati, e classi usate solo dove servono. Servono Python 3 e la libreria standard; nulla da installare.

Funzioni

Cos'è, perché esiste

Una funzione è una procedura con un nome, ingressi e un'uscita. Analogia: una ricetta con un titolo: non la riscrivi ogni volta, la chiami per nome con gli ingredienti del giorno.

Perché esiste: eliminare la ripetizione, dare un nome a un'idea, poter testare un pezzo isolato. Una funzione che fa una cosa sola e ha un nome onesto è la prima forma di documentazione.

Il metodo: per ogni funzione

problema → firma → input → output → implementazione → spiegazione → edge case → test.

def media(valori, *, ignora_none=False):
    """Media aritmetica di una sequenza di numeri.

    Se ignora_none è True scarta i None. Solleva ValueError se non resta nulla.
    """
    if ignora_none:
        valori = [v for v in valori if v is not None]
    if not valori:
        raise ValueError("serve almeno un valore")
    return sum(valori) / len(valori)

print(media([1, 2, 3]), media([1, None, 3], ignora_none=True))
try:
    media([])
except ValueError as e:
    print("ValueError:", e)

Output ottenuto (due esecuzioni identiche): 2.0 2.0 e ValueError: serve almeno un valore.

  • Firma. Il * prima di ignora_none rende l'argomento solo per nome: si scrive media(x, ignora_none=True), mai media(x, True). Un booleano passato per posizione è illeggibile.
  • Edge case. Lista vuota: la media non è definita (0/0), quindi errore esplicito. Restituire 0 farebbe sembrare sensato un risultato che non lo è.
  • Docstring (PEP 257): descrive cosa fa, non come.
  • Test: il codice stesso mostra due chiamate e un errore atteso. In un progetto vero diventano assert o test automatici.

Le quattro voci sono il metodo applicato a media: la firma decide come si chiama la funzione, l'edge case decide che cosa fa quando i dati sono sbagliati, la docstring lo dichiara, il test lo prova. Quella che pesa di più è l'edge case: una media che restituisce 0 su una lista vuota non dà errore e produce numeri plausibili e falsi, che è il peggior difetto in un'analisi di dati.

Funzioni che producono funzioni: closure e decoratori

In Python una funzione è un oggetto come gli altri: può essere passata a un'altra funzione, restituita da una funzione, assegnata a un nome. Questa idea apparentemente astratta ha un uso pratico quotidiano, il decoratore: una funzione che riceve una funzione e ne restituisce una versione arricchita (che conta le chiamate, ne misura il tempo, ne registra gli argomenti) senza toccarne il codice. L'analogia è la cornice con cui incornici un quadro: il quadro (la funzione originale) resta intatto, la cornice aggiunge qualcosa attorno.

import functools

def conta_chiamate(f):
    @functools.wraps(f)                       # copia nome e documentazione della funzione originale
    def interno(*args, **kwargs):
        interno.chiamate += 1                 # la funzione «ricorda» il contatore: è una closure
        return f(*args, **kwargs)             # delega alla funzione originale, senza alterarla
    interno.chiamate = 0
    return interno

@conta_chiamate
def quadrato(x):
    """Restituisce x al quadrato."""
    return x * x

print([quadrato(n) for n in range(5)])
print("chiamate:", quadrato.chiamate, "| nome:", quadrato.__name__, "| doc:", quadrato.__doc__)

Output ottenuto:

[0, 1, 4, 9, 16]
chiamate: 5 | nome: quadrato | doc: Restituisce x al quadrato.

Come leggere il codice. conta_chiamate riceve f e definisce una funzione interno che, a ogni chiamata, incrementa un contatore e poi chiama davvero f con gli stessi argomenti (*args e **kwargs inoltrano qualunque combinazione di argomenti). interno ricorda f anche dopo che conta_chiamate è terminata: una funzione che si porta dietro i nomi dell'ambiente in cui è stata definita si chiama closure. La riga @conta_chiamate sopra def quadrato è una scorciatoia per quadrato = conta_chiamate(quadrato). functools.wraps è importante: senza di esso quadrato.__name__ diventerebbe "interno" e la documentazione andrebbe persa, rendendo il debug confuso. Quello che ti serve ricordare è il modello: funzione dentro funzione = una funzione costruita a runtime con memoria propria. Lo ritroverai nelle librerie (per esempio per il caching, functools.lru_cache) e nel codice di scikit-learn e di Pandas.

Argomenti variabili, e scope

def somma_tutti(*args, **kwargs):
    return sum(args), kwargs

print(somma_tutti(1, 2, 3, a=1))      # (6, {'a': 1})

conta = 0

def incrementa():
    conta += 1        # conta è considerata locale perché la assegni qui

try:
    incrementa()
except UnboundLocalError as e:
    print("UnboundLocalError:", e)
# Output: UnboundLocalError: cannot access local variable 'conta' where it is not associated with a value

*args raccoglie gli argomenti posizionali extra in una tuple, **kwargs quelli per nome in un dizionario. Il messaggio esatto è quello di Python 3.14.4 e cambia tra versioni. Lo scope segue la regola LEGB (Local, Enclosing, Global, Built-in). Se in una funzione assegni a un nome, Python lo tratta come locale in tutta la funzione, quindi leggerlo prima dell'assegnazione dà UnboundLocalError. Il rimedio non è global: è passare il valore come argomento e restituire il nuovo.

Perché conta per i dati: le funzioni pure (stessi ingressi → stessa uscita, nessun effetto collaterale) sono facili da testare e da eseguire in parallelo; sono il modello mentale di NumPy e Pandas.

Type hints (PEP 484). def media(valori: list[float]) -> float: non cambia l'esecuzione ma documenta e permette a strumenti di analisi di trovare errori. Il corso li usa dove chiariscono.

Testo, byte e codifica

L'analogia: la lettera in codice

Un file è una striscia di numeri da 0 a 255 (byte). Il «testo» è ciò che ottieni quando applichi una codifica: una tabella che dice quale carattere corrisponde a quali byte. Se leggi con la tabella sbagliata ottieni caratteri senza senso, come decifrare un messaggio con il codice sbagliato.

Testo, byte e codificaIl testo diventa punti di codice poi byte UTF-8; leggerli con la codifica sbagliata produce caratteri senza senso.Testoè €Punti di codiceU+00E8 U+20ACUTF-8: c3 a8 e2 82 ac(2 byte + 3 byte)encodedecode con la codifica giustaletti come Latin-1:è â ¬ (mojibake)i byte sono gli stessi: cambia solo la regola di lettura
Figura: l'origine del mojibake.

UTF-8

Unicode assegna a ogni carattere un numero (punto di codice). UTF-8 (RFC 3629) è un modo di scrivere quei numeri in byte, a lunghezza variabile: i caratteri ASCII usano 1 byte, la è ne usa 2, il simbolo € ne usa 3. È la codifica predefinita del web e dei sistemi moderni.

s = "è€"
b = s.encode("utf-8")
print(len(s), len(b), b)          # 2 caratteri, 5 byte
print(b.decode("utf-8"))          # è€
print(b.decode("latin-1"))        # mojibake: i byte sono letti con la tabella sbagliata

Output: 2 5 b'\xc3\xa8\xe2\x82\xac', poi è€, poi una sequenza senza senso che inizia con èâ (questo è il mojibake: il byte 0xC3 0xA8 di è letto come due caratteri di Latin-1, à e ¨). Decodificare quegli stessi byte come ascii solleva UnicodeDecodeError, perché ASCII conosce solo i valori fino a 127.

Regola d'oro: specifica sempre encoding="utf-8" quando apri file di testo. Il valore predefinito dipende dal sistema operativo e dalla configurazione, quindi lo stesso codice può funzionare sul tuo computer e rompersi su un altro.

Trappola dei dati: un CSV esportato da un foglio di calcolo italiano può essere in cp1252 (Windows) o in UTF-8 con BOM. Se vedi è nelle tue colonne, il problema è la codifica di lettura, non i dati.

Un controllo sperimentale: tre file, tre letture

La parte teorica è chiara; il disagio nasce quando si hanno davvero in mano file di origine diversa. Proviamo tutte le combinazioni di tre codifiche di scrittura e tre di lettura: UTF-8, cp1252 (la codifica predefinita storica di Windows per le lingue occidentali) e utf-8-sig (UTF-8 con una firma iniziale di tre byte, il BOM, che alcuni programmi di foglio di calcolo aggiungono).

import tempfile
from pathlib import Path

cartella = Path(tempfile.mkdtemp())
testo = "prodotto,quantità\ncaffè,3\n"
codifiche = ("utf-8", "cp1252", "utf-8-sig")

for scrittura in codifiche:
    percorso = cartella / f"{scrittura}.csv"
    percorso.write_text(testo, encoding=scrittura)
    print(f"file scritto in {scrittura:<9}: primi byte = {percorso.read_bytes()[:6]!r}")
    for lettura in codifiche:
        try:
            prima_riga = percorso.read_text(encoding=lettura).splitlines()[0]
            esito = repr(prima_riga)
        except UnicodeDecodeError as errore:
            esito = f"ERRORE di decodifica ({errore.reason})"
        print(f"    letto come {lettura:<9}: {esito}")

Output ottenuto:

file scritto in utf-8    : primi byte = b'prodot'
    letto come utf-8    : 'prodotto,quantità'
    letto come cp1252   : 'prodotto,quantitÃ\xa0'
    letto come utf-8-sig: 'prodotto,quantità'
file scritto in cp1252   : primi byte = b'prodot'
    letto come utf-8    : ERRORE di decodifica (invalid continuation byte)
    letto come cp1252   : 'prodotto,quantità'
    letto come utf-8-sig: ERRORE di decodifica (invalid continuation byte)
file scritto in utf-8-sig: primi byte = b'\xef\xbb\xbfpro'
    letto come utf-8    : '\ufeffprodotto,quantità'
    letto come cp1252   : 'prodotto,quantitÃ\xa0'
    letto come utf-8-sig: 'prodotto,quantità'

Come leggere il codice. Per ciascuna delle tre codifiche il programma scrive lo stesso testo (con le lettere accentate à e è), mostra i primi byte del file, poi lo rilegge con ciascuna delle tre codifiche e stampa la prima riga con repr (che rende visibili anche i caratteri invisibili). Tre cose si vedono. Uno: UTF-8 letto come cp1252 produce il mojibake: Ã seguito da altri simboli al posto della lettera accentata, senza alcun errore, perché cp1252 accetta qualsiasi sequenza di byte. Due: cp1252 letto come UTF-8 invece fallisce con un errore di decodifica, perché il byte della à in cp1252 (0xE0) non è l'inizio di una sequenza UTF-8 valida (in UTF-8 quel valore annuncerebbe due byte di continuazione, che qui non ci sono).

Tre: un file con la firma BOM, letto come utf-8 semplice, conserva un carattere invisibile (\ufeff) all'inizio del primo nome di colonna: «\ufeffprodotto» non è uguale a "prodotto", e il famigerato KeyError: 'prodotto' su una colonna che «c'è» ha questa origine. Si cura leggendo con utf-8-sig, che scarta la firma se presente. La lezione operativa: quando i caratteri sono sbagliati o una colonna «sparisce», guarda i byte (read_bytes()[:20]) prima di incolpare i dati.

Esercizio 1

Quanti byte occupa una destinazione

Un corriere consegna a Forlì, Lecce e Reggio Emilia. I bollettini sono scritti in UTF-8. Rispondi contando a mano, ricordando che in UTF-8 i caratteri ASCII occupano un byte e la ì ne occupa due.

  1. Quanti caratteri e quanti byte occupa ciascuno dei tre nomi?
  2. I byte di «Forlì» vengono riletti con la tabella latin-1. Il programma solleva un errore? Che cosa compare a schermo, e quanto è lunga la stringa che si ottiene?
  3. Gli stessi byte vengono riletti come ascii. Che cosa succede, e perché il comportamento è diverso da quello del punto 2?

Le tre domande isolano le due cose che possono andare storte con una codifica: contare male (il punto 1, dove caratteri e byte non coincidono) e leggere male (i punti 2 e 3, dove la stessa sequenza di byte dà un dato sbagliato in silenzio oppure un errore rumoroso). Rispondi prima a mano e poi controlla con len() e .encode().

Mostra la soluzione

1. «Forlì»: 5 caratteri, 6 byte (quattro lettere ASCII da un byte più la ì da due, 0xC3 0xAC). «Lecce»: 5 e 5, tutto ASCII. «Reggio Emilia»: 13 e 13 — anche lo spazio è un carattere ASCII da un byte. La regola generale: in UTF-8 numero di byte e numero di caratteri coincidono solo se il testo è tutto ASCII.

2. Nessun errore. latin-1 assegna un carattere a ogni valore di byte da 0 a 255, quindi qualunque sequenza è «valida» per lui. I due byte della ì diventano due caratteri separati e si legge Forlì, lunga 6 caratteri invece di 5. È il mojibake: un errore completamente silenzioso, che si vede solo guardando i dati.

3. Solleva UnicodeDecodeError. ASCII conosce solo i valori da 0 a 127, e il byte 0xC3 non ha nessun significato nella sua tabella: la decodifica non può nemmeno inventarsi un carattere. Il contrasto è la lezione da portare a casa: fra un errore rumoroso e un dato sbagliato silenzioso, l'errore è il comportamento migliore. Per questo si dichiara sempre encoding="utf-8" aprendo un file di testo, invece di affidarsi al valore predefinito del sistema operativo.

File: pathlib, json, csv

from pathlib import Path
import csv
import json
import tempfile

cartella = Path(tempfile.mkdtemp())           # cartella temporanea per l'esempio

# JSON: strutture annidate (dizionari, liste, numeri, stringhe)
f = cartella / "dati.json"
f.write_text(json.dumps({"nome": "Zoë", "voti": [30, 28]}, ensure_ascii=False), encoding="utf-8")
print(json.loads(f.read_text(encoding="utf-8")))

# CSV: tabelle. Attenzione: ogni valore letto è una STRINGA.
c = cartella / "t.csv"
with c.open("w", newline="", encoding="utf-8") as fh:
    w = csv.writer(fh)
    w.writerow(["id", "importo"])
    w.writerow([1, "10.5"])
with c.open(newline="", encoding="utf-8") as fh:
    righe = list(csv.DictReader(fh))
print(righe, type(righe[0]["importo"]))

Output: {'nome': 'Zoë', 'voti': [30, 28]} e [{'id': '1', 'importo': '10.5'}] <class 'str'>.

  • pathlib.Path sostituisce le stringhe per i percorsi: cartella / "file" compone con il separatore giusto per il sistema operativo.
  • with apre il file e lo chiude sempre, anche se nel blocco c'è un errore. Dimenticare di chiudere un file in scrittura può lasciare dati non salvati.
  • ensure_ascii=False in json.dumps scrive ë come carattere, non come la sequenza di escape \u00eb; leggibile e più compatto.
  • newline="" per il modulo csv è richiesto dalla documentazione per gestire correttamente le interruzioni di riga.
  • csv restituisce stringhe. "10.5" non è un numero finché non lo converti. Nell'articolo su Pandas read_csv deduce i tipi per te, ma conoscere il comportamento sottostante spiega gli errori.

I cinque punti sono altrettante cause di bug silenziosi: percorsi che si rompono tra sistemi operativi, file lasciati aperti, accenti scritti in modo illeggibile, righe vuote doppie, numeri che sono stringhe. Il terzo e il quinto sono i più insidiosi, perché il programma non si ferma e produce un risultato che sembra giusto.

Quando JSON, quando CSV. JSON: dati annidati, configurazioni, risposte di servizi. CSV: tabelle piatte, interoperabili con fogli di calcolo. Per grandi tabelle usate molte volte esistono formati colonnari come Parquet, che non copriamo in questa lezione.

Il pericolo di pickle

pickle serializza oggetti Python arbitrari in byte. È comodo (salvi un modello addestrato con una riga). La documentazione ufficiale avverte che il modulo non è sicuro: caricare dati pickle da una fonte non fidata può eseguire codice arbitrario, perché la deserializzazione può invocare funzioni a scelta di chi ha creato il file. Regola: non fare mai pickle.load su file che non hai prodotto tu o di cui non ti fidi pienamente. Per i modelli esistono alternative più sicure e portabili, che dipendono dalla libreria usata (la documentazione di pickle elenca le alternative per i dati).

Eccezioni

L'analogia: il piano B

Un cuoco sa che l'uovo può essere marcio. Non cuoce comunque la torta: prevede un piano B per quel caso specifico. Le eccezioni sono il meccanismo per dichiarare «se questo specifico problema accade, fai quest'altro».

def leggi_importi(testi):
    """Converte stringhe in float; restituisce (validi, scartati)."""
    ok, scartati = [], []
    for t in testi:
        try:
            ok.append(float(t))
        except ValueError:                 # intercetta SOLO l'errore atteso
            scartati.append(t)
    return ok, scartati

print(leggi_importi(["1.5", "abc", "2", ""]))

Output: ([1.5, 2.0], ['abc', '']). Gli scartati sono conservati, non buttati: puoi segnalare quante righe erano malformate.

Struttura completa:

try:
    int("x")
except ValueError:
    print("a")        # eseguito se c'è l'errore
else:
    print("else")     # eseguito SOLO se non c'è stato errore
finally:
    print("finally")  # eseguito SEMPRE (pulizia)

Output: a e finally.

Regole.

  1. Intercetta eccezioni specifiche (ValueError), mai un generico except: o except Exception: a meno che tu sappia perché: nasconde bug (anche un errore di battitura nel codice).
  2. Mai silenziare: except: pass è il modo in cui i problemi diventano misteri. Registra o rilancia.
  3. Fail fast: valida gli ingressi all'inizio e solleva ValueError con un messaggio che include il valore incriminato.
  4. Eccezioni per i casi eccezionali, non per il controllo di flusso normale quando un if è più chiaro.

Le quattro regole dicono la stessa cosa da angolazioni diverse: un errore deve essere visto da chi può correggerlo. Le prime due riguardano ciò che si intercetta e che cosa se ne fa; le ultime due dicono dove mettere il controllo, all'ingresso dei dati e non in fondo alla catena. Se ne applichi una sola, la seconda: un except: pass trasforma un guasto in un mistero.

Per i dati: un errore su 3 milioni di righe non deve fermare l'elaborazione, ma non deve nemmeno sparire. Contare e riportare gli scartati è una forma di qualità dei dati.

Errori che dicono dove e perché: eccezioni personalizzate

Un'eccezione generica come ValueError va bene per un controllo locale. Quando un'elaborazione attraversa più strati (lettura del file, validazione, calcolo) conviene un errore specifico del dominio, che porti con sé il contesto: quale riga, quale campo, quale valore. Python permette inoltre di collegare la causa originale con raise ... from ..., in modo che il traceback mostri entrambe.

class ErroreDati(Exception):
    """Errore nei dati in ingresso, con il numero di riga."""

def leggi_eta(riga, numero):
    try:
        eta = int(riga["eta"])
    except (ValueError, KeyError) as causa:
        raise ErroreDati(f"riga {numero}: campo 'eta' non valido ({riga.get('eta')!r})") from causa
    if not 0 <= eta <= 120:
        raise ErroreDati(f"riga {numero}: età fuori intervallo ({eta})")
    return eta

righe = [{"eta": "34"}, {"eta": "abc"}, {"eta": "230"}, {}]
for n, r in enumerate(righe, start=1):
    try:
        print(f"riga {n}: età = {leggi_eta(r, n)}")
    except ErroreDati as errore:
        causa = type(errore.__cause__).__name__ if errore.__cause__ else "nessuna"
        print(f"{errore} | causa: {causa}")

Output ottenuto:

riga 1: età = 34
riga 2: campo 'eta' non valido ('abc') | causa: ValueError
riga 3: età fuori intervallo (230) | causa: nessuna
riga 4: campo 'eta' non valido (None) | causa: KeyError

Come leggere il codice. ErroreDati è una classe che eredita da Exception: basta per creare un nuovo tipo di errore intercettabile da solo. leggi_eta converte il campo e, se la conversione fallisce (testo non numerico o chiave assente), solleva ErroreDati dal ValueError o KeyError originale (from causa), con un messaggio che contiene numero di riga e valore. Il secondo controllo, sull'intervallo, solleva direttamente l'errore. Il ciclo chiamante intercetta solo ErroreDati e stampa il messaggio e il tipo della causa. Il vantaggio è che l'errore racconta una storia completa («riga 2: campo 'eta' non valido 'abc'»), e il chiamante può decidere di scartare la riga, registrarla o fermarsi, senza dover capire dettagli interni.

Esercizio 2

In che ordine stampa

Il caricatore dei bollettini converte la colonna colli, che il modulo csv restituisce sempre come stringa. Il blocco è questo, riga per riga:

  1. try:   colli = int(riga["colli"])
  2. except ValueError:   print("A")
  3. else:   print("B")
  4. finally:   print("C")
  5. e dopo il blocco, fuori: print("D")

Le cinque righe sono i quattro rami di un blocco di gestione degli errori più una riga esterna: servono a vedere che cosa gira sempre e che cosa gira solo in certi casi. Tienile sotto gli occhi mentre rispondi alle tre domande che seguono.

Nell'export, la riga 6 ha colli uguale a x e la riga 2 ha colli uguale a 1.

  1. Quali lettere compaiono, e in che ordine, elaborando la riga 6?
  2. E la riga 2?
  3. perché il caricatore intercetta ValueError e non Exception?

Le prime due domande misurano se sai l'ordine di esecuzione; la terza chiede il giudizio, cioè quale errore è un dato sporco da contare e quale è un guasto da vedere subito. Prova a rispondere a mente prima di aprire la soluzione: il giudizio si allena solo così.

Mostra la soluzione

1. A, C, D. int("x") solleva ValueError, quindi parte il ramo except; else viene saltato perché un'eccezione c'è stata; finally viene eseguito sempre; poi il programma prosegue.

2. B, C, D. Nessuna eccezione, quindi except non parte, else sì, e finally ancora una volta. Il ruolo di else è proprio questo: tenere fuori dal try il codice che deve girare solo se è andato tutto bene, così il try resta corto e non intercetta per sbaglio eccezioni di righe che non c'entrano.

3. Perché ValueError è esattamente ciò che sollevano int() e date.fromisoformat() su un dato malformato — cioè il caso che il caricatore sa gestire: conta la riga, registra il motivo, prosegue. Un except Exception catturerebbe anche un KeyError dovuto a una colonna rinominata nell'export: quello non è un dato sporco, è la struttura del file che è cambiata, e va vista subito invece di finire nel conteggio delle righe scartate. Catturare troppo nasconde anche i propri errori di battitura.

Oggetti: quando una classe aiuta

Analogia: una classe è lo stampo dei biscotti; gli oggetti sono i biscotti. Lo stampo definisce la forma (attributi) e cosa sai farci (metodi).

Quando usare una classe. Quando hai dati e comportamento che vanno insieme e vuoi garantire coerenza (un Punto con x, y e la sua distanza). Quando NO: per una funzione che ha un solo ingresso e un'uscita, per contenere funzioni senza stato (un modulo basta), per «organizzare» a priori. Una buona regola: parti dalle funzioni; introduci una classe quando noti di passare gli stessi tre argomenti a cinque funzioni.

dataclass

from dataclasses import dataclass

@dataclass(frozen=True)
class Punto:
    x: float
    y: float

    def distanza(self, altro: "Punto") -> float:
        return ((self.x - altro.x) ** 2 + (self.y - altro.y) ** 2) ** 0.5

p, q = Punto(0, 0), Punto(3, 4)
print(p, p.distanza(q), p == Punto(0, 0))
try:
    p.x = 1
except Exception as e:
    print(type(e).__name__)

Output: Punto(x=0, y=0) 5.0 True e FrozenInstanceError.

@dataclass genera __init__, __repr__ e __eq__ a partire dagli attributi annotati. Con frozen=True l'oggetto è immutabile (e quindi hashable): non puoi cambiare p.x. È la tua scelta di default per i dati. La distanza è il teorema di Pitagora: √(Δx² + Δy²), qui √(9 + 16) = 5.

Metodi speciali (dunder)

I metodi con doppio trattino basso permettono ai tuoi oggetti di comportarsi come i tipi integrati: __repr__ (come si stampa), __add__ (l'operatore +), __len__ (la funzione len), __eq__ e __hash__ (uguaglianza e uso come chiave).

class Vettore:
    def __init__(self, *componenti):
        self.c = tuple(componenti)

    def __repr__(self):
        return f"Vettore{self.c}"

    def __add__(self, altro):
        return Vettore(*(a + b for a, b in zip(self.c, altro.c, strict=True)))

    def __len__(self):
        return len(self.c)

    def __eq__(self, altro):
        return self.c == altro.c

    def __hash__(self):
        return hash(self.c)

print(Vettore(1, 2) + Vettore(3, 4), len(Vettore(1, 2, 3)), Vettore(1) == Vettore(1))

Output: Vettore(4, 6) 3 True. Note: __add__ restituisce un nuovo oggetto (immutabilità: non modifica gli operandi); strict=True fa fallire la somma di vettori di lunghezze diverse invece di troncare in silenzio; se definisci __eq__ devi definire anche __hash__ coerente, altrimenti l'oggetto non è usabile come chiave. NumPy e Pandas sono costruiti su questo meccanismo: è per questo che a + b funziona sugli array.

Attributi «privati». Python non ha attributi privati veri. Un nome con un solo trattino basso (_x) è un avviso per convenzione; con due (__x) subisce il name mangling: internamente diventa _Classe__x, per evitare collisioni nelle sottoclassi, non per nascondere. Non è sicurezza.

La soluzione commentata del mini-progetto: un caricatore robusto

Mettiamo insieme dataclass immutabili, csv, eccezioni e conteggio degli errori. Il programma scrive prima un piccolo CSV di prova (con accenti e con righe volutamente sbagliate), poi lo legge.

import csv
import tempfile
from collections import defaultdict
from dataclasses import dataclass
from datetime import date
from pathlib import Path

@dataclass(frozen=True)
class Vendita:
    data: date
    prodotto: str
    quantita: int
    prezzo: float

def carica_vendite(percorso):
    vendite, errori = [], []
    with Path(percorso).open(newline="", encoding="utf-8") as fh:
        for numero, riga in enumerate(csv.DictReader(fh), start=2):       # la riga 1 è l'intestazione
            try:
                quantita = int(riga["quantita"])
                prezzo = float(riga["prezzo"])
                giorno = date.fromisoformat(riga["data"])
                if prezzo < 0:
                    raise ValueError("prezzo negativo")
                vendite.append(Vendita(giorno, riga["prodotto"], quantita, prezzo))
            except (ValueError, KeyError) as e:
                errori.append((numero, f"{type(e).__name__}: {e}"))
    return vendite, errori

contenuto = """data,prodotto,quantita,prezzo
2026-03-01,caffè,3,1.20
2026-03-02,tè,2,2.50
2026-03-02,caffè,2.5,1.20
2026-03-03,biscotti,4,-3.00
2026-13-01,caffè,1,1.20
2026-03-05,caffè,5,1.20
"""
percorso = Path(tempfile.mkdtemp()) / "vendite.csv"
percorso.write_text(contenuto, encoding="utf-8")

vendite, errori = carica_vendite(percorso)
print("vendite valide:", len(vendite), "| scartate:", len(errori))
for numero, motivo in errori:
    print(f"  riga {numero}: {motivo}")

per_prodotto = defaultdict(float)
for v in vendite:
    per_prodotto[v.prodotto] += v.quantita * v.prezzo
print({p: round(totale, 2) for p, totale in per_prodotto.items()}, "| totale:", round(sum(per_prodotto.values()), 2))

Output ottenuto:

vendite valide: 3 | scartate: 3
  riga 4: ValueError: invalid literal for int() with base 10: '2.5'
  riga 5: ValueError: prezzo negativo
  riga 6: ValueError: month must be in 1..12, not 13
{'caffè': 9.6, 'tè': 5.0} | totale: 14.6

Come leggere la soluzione. La dataclass con frozen=True rappresenta una riga valida e non si può modificare dopo la creazione. carica_vendite apre il file con codifica esplicita e newline="", e scorre le righe con csv.DictReader (ogni riga è un dizionario che usa le intestazioni come chiavi); enumerate(..., start=2) numera le righe come le vede una persona che apre il file (la riga 1 è l'intestazione). Dentro il try, ogni conversione può fallire: int("2.5") fallisce (un intero non ha parte decimale), date.fromisoformat("2026-13-01") fallisce (il mese 13 non esiste), e il prezzo negativo viene rifiutato esplicitamente. Gli errori non fermano il caricamento: ciascuno viene registrato con numero di riga e motivo, e le tre righe valide vengono elaborate. Il totale si ottiene con un defaultdict(float) che parte da zero per ogni prodotto nuovo. L'utente finale vede quante righe sono state scartate e perché: è il comportamento che serve a una pipeline di dati reale.

Errori tipici

Errore Sintomo Causa Rimedio
è al posto di è Accenti rovinati Lettura con codifica sbagliata encoding="utf-8" (o quella reale del file)
Dati non salvati File vuoto o troncato File non chiuso with open(...)
UnboundLocalError Errore alla prima riga di una funzione Assegnazione a un nome globale dentro la funzione Passa e restituisci i valori
Numeri come stringhe "10" + "5" = "105" csv restituisce stringhe Converti esplicitamente
except: generico Bug invisibili Cattura troppo Eccezioni specifiche
Funzione con effetti nascosti Risultati che dipendono dall'ordine di chiamata Modifica di argomenti o globali Funzioni pure, restituisci nuovi oggetti
Caricare un pickle esterno Esecuzione di codice indesiderato Deserializzazione non sicura Solo file fidati; formati sicuri
__eq__ senza __hash__ Oggetto non utilizzabile in set/dict Hash rimosso quando si ridefinisce l'uguaglianza Definisci entrambi, coerenti

Dalla funzione lunga a funzioni piccole: come ragionare

Quando una funzione supera la trentina di righe, o quando ti accorgi di scrivere commenti per separare «le parti» al suo interno, di solito sta facendo più di una cosa. Il criterio per spezzarla è semplice: ogni parte che puoi nominare con un verbo (leggere, validare, convertire, aggregare, scrivere) diventa una funzione. Il caricatore delle vendite sopra, per esempio, mescola tre responsabilità: aprire e scorrere il file, convertire una riga in un oggetto, accumulare gli errori. Una versione più ordinata avrebbe una funzione converti_riga(riga) -> Vendita che solleva un errore specifico se la riga non è valida, e una funzione carica_vendite che si limita a scorrere il file e a decidere che cosa fare di ciascun errore.

I vantaggi sono tre e si vedono subito. Testabilità: converti_riga si prova con un dizionario scritto a mano, senza creare file. Riuso: la stessa funzione serve a leggere righe da un file, da un database o da una richiesta web. Leggibilità: il corpo di carica_vendite diventa quasi una frase («per ogni riga: convertila, e se non va, registra l'errore»). Il costo è dover scegliere bene i nomi e le firme; è un costo che si ripaga alla prima modifica. La regola pratica che molti team adottano è: se per capire che cosa fa una funzione devi scorrere lo schermo, è troppo lunga; se un nome come elabora_dati non dice nulla, la funzione fa troppe cose o il nome è pigro.

Un'ultima nota sullo stile con i dati: preferisci funzioni che restituiscono i risultati invece di stamparli. Una funzione che stampa è utilizzabile solo da un essere umano davanti a un terminale; una che restituisce un valore può essere testata, combinata con altre, scritta su file, mostrata in un grafico. La stampa va nel punto più esterno del programma, non dentro la logica.

Best practice

  • Funzioni piccole (meno di una cinquantina di righe) con un solo compito.
  • Docstring sulle funzioni pubbliche; type hints dove chiariscono.
  • Esplicito sulla codifica, sempre.
  • Valida agli ingressi del sistema (file, input utente), fidati all'interno.
  • Non mutare gli argomenti; restituisci nuovi oggetti.
  • Preferisci dataclass(frozen=True) per i record di dati.
  • Registra errori con contesto (quale file, quale riga, quale valore).

Le sette regole si riducono a due idee: rendere esplicito ciò che il linguaggio lascia implicito (codifica, tipi, errori) e non cambiare ciò che non è tuo (argomenti, globali). Se devi scegliere da dove cominciare in un codice esistente, comincia dalla codifica: costa una riga per file e previene la classe di bug più costosa da diagnosticare.

Mini-progetto

Scrivi carica_vendite(percorso) che legge un CSV con colonne data, prodotto, quantita, prezzo:

  1. Restituisce una lista di Vendita (dataclass immutabile) e una lista di errori con il numero di riga e il motivo.
  2. Scarta righe con quantità non intera, prezzo negativo o data malformata (usa datetime.date.fromisoformat).
  3. Calcola il ricavo totale e per prodotto con un defaultdict.
  4. Prova con un file che ha accenti nei nomi dei prodotti, scritto in UTF-8 e poi letto con la codifica sbagliata per vedere l'errore.

Il mini-progetto è la soluzione commentata sopra, riscritta da te. I quattro punti sono in ordine di difficoltà: il primo fissa le strutture, il secondo le regole di scarto, il terzo l'aggregazione, il quarto ti fa provocare di proposito il mojibake per riconoscerlo la prossima volta che lo incontri su dati veri.

Challenge

  • Easy. Scrivi mediana(valori) con gli stessi edge case di media.
  • Medium. Aggiungi a Vettore il prodotto per scalare (__mul__) e il prodotto scalare come metodo punto(altro).
  • Hard. Scrivi un decoratore @cronometra che stampa il tempo di esecuzione di una funzione. Che cosa deve restituire per non rompere la funzione decorata?
  • Expert. Dimostra con un esempio come pickle.loads può eseguire codice (con un oggetto che definisce __reduce__), solo in un ambiente di prova isolato, e riporta in che modo lo eviteresti.
  • Enterprise. Scrivi lo standard del tuo team per la lettura di file: codifica, gestione degli errori, log degli scartati, test minimi.

Le cinque sfide partono da una funzione di dieci righe e arrivano a una regola di squadra. Hard ed Expert sono quelle che insegnano di più: il decoratore ti obbliga a capire che cosa restituisce una closure, e la dimostrazione con pickle (solo in un ambiente di prova) rende concreto un rischio che altrimenti resta una riga di avvertenza.

Che cosa sai fare adesso

Sai scrivere una funzione con firma, docstring ed edge case espliciti; sai spiegare perché un file è fatto di byte e riconoscere il mojibake guardando i primi byte; sai leggere un CSV e un JSON senza perdere gli accenti e senza lasciare file aperti; sai definire un errore specifico che racconta riga e causa; e sai quando una dataclass immutabile basta al posto di una classe. Il passo dopo è capire perché i numeri decimali non sono quello che sembrano: virgola mobile e IEEE 754.

Nella stessa serie trovi anche: 09 - Python per i dati 2: liste, tuple, dizionari, insiemi (la lezione precedente), 14 - Visualizzare i dati con Matplotlib, 15 - scikit-learn: stimatori, pipeline e validazione (che richiamano questa lezione).

Fonti

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti