OOP in Python: classi, attributi e metodi (e perché model.fit() funziona)

Dati e funzioni che li manipolano sparsi ovunque? Le classi mettono ordine: impari a scrivere la tua prima classe Python, capisci cosa fa davvero self e scopri perché ogni modello scikit-learn è un oggetto con fit e predict.

Ti è mai capitato? Stai analizzando i dati di alcuni sensori e ti ritrovi con un dizionario per le letture, una lista per i nomi, una funzione calcola_media(letture, nome), una aggiungi_lettura(letture, nome, valore)… e ogni funzione vuole gli stessi due argomenti, sempre, in quell’ordine. Al terzo bug causato da un argomento passato al posto sbagliato, il sospetto diventa certezza: questi dati e queste funzioni appartengono gli uni alle altre. La programmazione a oggetti è esattamente questo: il modo di dichiararlo al linguaggio.

Tipi, strutture dati, controllo di flusso e funzioni: le basi di Python sono nel libro, in un percorso che si legge in ordine.

Leggi «Le basi di Python» nel libro →

In questa lezione costruiamo la tua prima classe, capiamo una volta per tutte cosa sia self, e chiudiamo con una rivelazione: hai già usato l’OOP ogni volta che hai scritto model.fit(X, y). Prerequisiti: sapere scrivere una funzione con def e conoscere i dizionari. Ambiente: Python 3.12 in un virtualenv (python -m venv .venv o uv venv), editor a scelta tra VS Code e PyCharm.

La classe è lo stampo, l’istanza è il biscotto

L’analogia più onesta è quella dello stampo per biscotti. La classe è lo stampo: definisce la forma — quali dati contiene un oggetto e cosa sa fare — ma non è un biscotto, non si mangia. Le istanze sono i biscotti: ne sforni quante ne vuoi dallo stesso stampo, tutte con la stessa forma ma ognuna con la sua glassa, cioè con i propri valori nei dati.

# Il nostro stampo: una classe che rappresenta un sensore di temperatura
class Sensore:
    """Un sensore che accumula letture di temperatura."""

    def __init__(self, nome, posizione):
        # __init__ è il "montaggio" dell'istanza: viene chiamato
        # automaticamente quando scrivi Sensore(...)
        self.nome = nome            # attributo di istanza
        self.posizione = posizione  # ogni sensore ha la sua
        self.letture = []           # parte vuoto, si riempirà

# Due biscotti dallo stesso stampo: istanze indipendenti
cucina = Sensore("s-01", "cucina")
cantina = Sensore("s-02", "cantina")

print(cucina.posizione)   # cucina
print(cantina.posizione)  # cantina
print(cucina.letture)     # []  -> ognuno ha la SUA lista, separata

Due cose da fissare. Primo: __init__ (due underscore per lato, si legge «dunder init») è il metodo che Python chiama da solo alla creazione dell’oggetto — è lì che dai un valore iniziale agli attributi. Secondo: cucina e cantina sono oggetti completamente indipendenti. Modificare le letture dell’uno non tocca l’altro, ed è proprio questo l’ordine che cercavamo.

self: chi sta parlando?

self spaventa tutti i principianti, quindi mettiamolo in chiaro con un’analogia. Lo stampo dei biscotti ha scritto sopra le istruzioni: «decora questo biscotto con la glassa». Ma quale biscotto? Quello su cui stai lavorando in quel momento. self è esattamente questo: dentro la classe, è il segnaposto per «l’istanza specifica su cui il metodo è stato chiamato».

class Sensore:
    def __init__(self, nome, posizione):
        self.nome = nome
        self.posizione = posizione
        self.letture = []

    def aggiungi(self, valore):
        # self = il sensore su cui hai chiamato .aggiungi()
        self.letture.append(valore)

    def media(self):
        if not self.letture:          # onestà: gestiamo il caso vuoto
            return None
        return sum(self.letture) / len(self.letture)

cucina = Sensore("s-01", "cucina")
cucina.aggiungi(21.5)
cucina.aggiungi(22.3)

# Queste due righe sono EQUIVALENTI: la prima è zucchero per la seconda
print(cucina.media())          # 21.9
print(Sensore.media(cucina))   # 21.9 -> ecco chi è self!

L’ultima riga è il trucco che scioglie il mistero: cucina.media() è solo una scrittura comoda per Sensore.media(cucina). Python prende l’oggetto prima del punto e lo infila come primo argomento del metodo. self non è una parola magica — è un normale parametro, che per convenzione (fortissima: non violarla) si chiama così.

Una classe non è altro che dati e funzioni che hanno smesso di fingere di non conoscersi.

Attributi di istanza e attributi di classe

Gli attributi visti finora — self.nome, self.letture — sono di istanza: ogni biscotto ha i suoi. Esistono anche attributi di classe, scritti direttamente nel corpo della classe: sono condivisi da tutte le istanze, come la scritta stampata sullo stampo stesso, identica su ogni biscotto.

class Sensore:
    unita = "°C"        # attributo di CLASSE: uguale per tutti
    creati = 0          # contatore condiviso

    def __init__(self, nome):
        self.nome = nome              # attributo di ISTANZA
        self.letture = []
        Sensore.creati += 1           # aggiorno il contatore condiviso

a = Sensore("s-01")
b = Sensore("s-02")
print(a.unita, b.unita)   # °C °C  -> condiviso
print(Sensore.creati)     # 2      -> la classe "sa" quanti sensori esistono

Una trappola classica da conoscere: non usare mai un oggetto mutabile (una lista, un dizionario) come attributo di classe pensando che sia «il default di ogni istanza» — verrebbe condiviso da tutte, e i dati di un sensore finirebbero negli altri. I contenitori mutabili si inizializzano sempre dentro __init__, come abbiamo fatto con self.letture.

Metodi speciali: insegnare a Python a leggere i tuoi oggetti

Se stampi print(a) adesso ottieni qualcosa come <__main__.Sensore object at 0x7f...>: tecnicamente vero, umanamente inutile. I metodi «dunder» come __repr__ ti permettono di agganciarti ai meccanismi del linguaggio:

class Sensore:
    def __init__(self, nome):
        self.nome = nome
        self.letture = []

    def __repr__(self):
        # Come l'oggetto si presenta: usato da print() e dal debugger
        return f"Sensore({self.nome!r}, letture={len(self.letture)})"

    def __len__(self):
        # Ora len(sensore) funziona!
        return len(self.letture)

s = Sensore("s-01")
s.letture.extend([21.5, 22.3, 20.9])
print(s)       # Sensore('s-01', letture=3)
print(len(s))  # 3

Definire un buon __repr__ è il regalo più economico che puoi fare al te stesso che farà debugging alle undici di sera.

Il colpo di scena: usi l’OOP da sempre

Ora la rivelazione promessa. Ogni modello di scikit-learn è un’istanza di una classe: LinearRegression() sforna un biscotto, fit e predict sono metodi, e i coefficienti imparati sono attributi di istanza (con la convenzione dell’underscore finale: coef_). Per dimostrartelo, scriviamo un «modello» minimale che rispetta la stessa interfaccia:

class PredittoreMedia:
    """Il modello più umile del mondo: predice sempre la media
    dei target visti in addestramento. In scikit-learn esiste
    davvero e si chiama DummyRegressor."""

    def fit(self, X, y):
        # "Addestramento": memorizzo la media dei target
        self.media_ = sum(y) / len(y)   # underscore finale = imparato dai dati
        return self                     # consente model.fit(X, y).predict(X)

    def predict(self, X):
        # Predico la stessa media per ogni riga di X
        return [self.media_ for _ in X]

X = [[50], [80], [120]]   # metri quadri di tre case
y = [150, 210, 320]       # prezzi in migliaia di euro

modello = PredittoreMedia()       # istanza (il biscotto)
modello.fit(X, y)                 # metodo che scrive un attributo
print(modello.media_)             # 226.66... -> stato imparato
print(modello.predict([[95]]))    # [226.66...] -> lo stato viene riusato

Ecco perché l’OOP è la lingua madre del machine learning in Python: un modello è dati (i parametri imparati) più comportamento (addestrarsi, predire) che viaggiano insieme. Esattamente il problema da cui siamo partiti con i sensori.

Una classe, tante istanze class Sensore attributi: nome, posizione, letture metodi: aggiungi(), media() lo stampo: definisce la forma Sensore(…) cucina = Sensore(«s-01») letture: [21.5, 22.3] cantina = Sensore(«s-02») letture: [12.1] soffitta = Sensore(«s-03») letture: [] i biscotti: stessa forma, dati propri e indipendenti

La classe (a sinistra) definisce forma e comportamento; ogni chiamata a Sensore(…) sforna un’istanza indipendente, con i suoi valori negli attributi.

Un’ultima dose di onestà: non tutto merita una classe. Se hai una trasformazione senza stato — dati che entrano, risultato che esce — una semplice funzione è più chiara, più testabile e più Pythonic. La classe serve quando dati e comportamento devono vivere insieme nel tempo: un sensore che accumula letture, un modello che ricorda i suoi parametri, una connessione che va aperta e chiusa. Una classe con un solo metodo oltre a __init__ è quasi sempre una funzione travestita.

Prova tu

Scrivi una classe CarrelloSpesa con: un attributo di istanza articoli (dizionario nome → prezzo), un metodo aggiungi(nome, prezzo), un metodo totale() che somma i prezzi, e un __repr__ leggibile tipo Carrello(3 articoli, 27.50€). Bonus: aggiungi un attributo di classe valuta = "€" e un __len__ che restituisce il numero di articoli. Poi crea due carrelli e verifica che aggiungere un articolo al primo non tocchi il secondo — se lo fa, hai messo un mutabile come attributo di classe: rileggi la sezione sulla trappola.

Per approfondire: il capitolo Classes del tutorial ufficiale di Python è la referenza da tenere aperta, mentre Fluent Python di Luciano Ramalho (2ª edizione, capitoli su «Pythonic objects») ti mostra come portare le tue classi dal livello «funziona» al livello «sembra scritta da chi ha creato il linguaggio». Nella prossima lezione della serie vedremo ereditarietà e polimorfismo: come uno stampo può nascere da un altro stampo.

I commenti sono riservati agli iscritti.

Accedi per commentare