Classi e OOP in Python: __init__, self, metodi, ereditarietà e dataclass
La OOP in Python si basa sulle classi: una classe descrive campi e metodi, mentre gli oggetti (istanze) si creano chiamando la classe; __init__ inizializza i campi e self indica l'oggetto corrente. L'ereditarietà permette di riusare il comportamento di un genitore ridefinendo alcuni metodi, mentre il decoratore @dataclass genera __init__, __repr__ e __eq__ per le classi che contengono soprattutto dati.
Una classe in Python è un modello: descrive quali dati conserva un oggetto e che cosa si può fare con essi. L'oggetto (istanza) è un esemplare concreto costruito secondo quel modello. La programmazione orientata agli oggetti in Python poggia su tre elementi: le classi con i loro metodi, l'ereditarietà e i metodi speciali come __init__ e __repr__. Vediamo tutto in ordine, con un unico esempio che ricorre: la gestione delle spese.
Perché servono le classi se esistono i dizionari
I dati si possono conservare in un dizionario: {"title": "Caffè", "amount": 250}. Il problema è che il dizionario non sa quali chiavi siano corrette. Un refuso come expense.get("amout", 0) non genera alcun errore: restituisce zero, e il report inizia a mentire in silenzio.
Un oggetto di classe ha campi dichiarati in anticipo. L'accesso a un attributo inesistente produce subito AttributeError, e Python moderno suggerisce anche il nome corretto. Inoltre la classe raccoglie in un solo punto i dati e le azioni su di essi: una spesa sa calcolare da sola l'importo con l'IVA, un report calcola da sé il totale.
Come creare una classe in Python
La classe si dichiara con la parola class, e il nome secondo la PEP 8 si scrive in CamelCase. Un esempio minimo:
class Expense:
def __init__(self, title, amount):
self.title = title
self.amount = amount
coffee = Expense("Caffè", 250)
taxi = Expense("Taxi", 300)
print(coffee.title, coffee.amount) # Caffè 250
La chiamata Expense("Caffè", 250) crea una nuova istanza della classe. coffee e taxi sono due oggetti diversi dello stesso tipo, ciascuno con i propri valori dei campi. Verificare l'appartenenza si può con isinstance(coffee, Expense), che restituisce True.
Costruttore __init__: inizializzazione della classe
__init__ viene spesso chiamato costruttore, anche se in senso stretto è un inizializzatore. L'oggetto viene creato dal metodo __new__, mentre __init__ viene eseguito subito dopo e distribuisce gli argomenti ricevuti sui campi. Non deve restituire nulla (per la precisione deve restituire None).
In __init__ è comodo verificare i dati in ingresso, per evitare che nel programma compaiano oggetti evidentemente sbagliati:
class Expense:
def __init__(self, title, amount):
if amount < 0:
raise ValueError("L'importo non può essere negativo")
self.title = title
self.amount = amount
Che cos'è self in Python
self è il riferimento all'oggetto su cui sta lavorando il metodo in quel momento. Quando scrivete coffee.with_tax(), Python chiama in realtà Expense.with_tax(coffee) e passa l'oggetto come primo argomento. Per questo nella dichiarazione del metodo self sta per primo, mentre nella chiamata non si scrive.
self non è una parola chiave ma una convenzione: tecnicamente il parametro può avere qualsiasi nome, ma nessuno lo fa. L'errore tipico dei principianti è dimenticare self nella dichiarazione del metodo. Allora la chiamata genera TypeError: Expense.with_tax() takes 0 positional arguments but 1 was given.
Metodi di classe e metodi speciali
Un metodo è una funzione dichiarata dentro la classe. Vede i campi dell'oggetto tramite self:
class Expense:
def __init__(self, title, amount):
self.title = title
self.amount = amount
def with_tax(self, rate=0.2):
return self.amount * (1 + rate)
def __repr__(self):
return f"Expense({self.title!r}, {self.amount})"
coffee = Expense("Caffè", 250)
print(coffee.with_tax()) # 300.0
print(coffee) # Expense('Caffè', 250)
Senza __repr__ la stampa mostrerebbe <__main__.Expense object at 0x7f...>: inutile per il debug. I metodi con due trattini bassi su entrambi i lati si chiamano speciali (dunder, da double underscore): Python li invoca da solo. __str__ si occupa dell'output «leggibile» per l'utente, __eq__ del confronto con ==, __len__ di len(obj).
@classmethod e @staticmethod
Un metodo normale lavora con l'istanza. Un metodo di classe, con il decoratore @classmethod, riceve come primo argomento la classe stessa (cls): spesso serve come costruttore alternativo. Un metodo statico, con @staticmethod, non riceve né l'oggetto né la classe: è una normale funzione che logicamente appartiene alla classe.
class Expense:
def __init__(self, title, amount):
self.title = title
self.amount = amount
@classmethod
def from_csv(cls, line):
title, amount = line.split(";")
return cls(title, float(amount))
@staticmethod
def is_valid_amount(value):
return value >= 0
e = Expense.from_csv("Pranzo;520")
print(e.amount) # 520.0
print(Expense.is_valid_amount(-1)) # False
Distruttore di classe: __del__
In Python esiste il metodo __del__, che viene chiamato prima che l'oggetto venga distrutto dal garbage collector. Non conviene affidarsi a esso per liberare risorse: il momento della chiamata non è garantito. Per file e connessioni usate il gestore di contesto with oppure il metodo esplicito close().
Attributi di classe e attributi di istanza
I campi assegnati con self.nome = ... appartengono al singolo oggetto. Le variabili dichiarate direttamente nel corpo della classe sono attributi di classe: sono condivisi da tutte le istanze. Di solito in questo modo si conservano le costanti di classe.
class Expense:
CURRENCY = "EUR" # attributo di classe, costante
def __init__(self, title, amount):
self.title = title # attributi di istanza
self.amount = amount
a = Expense("Caffè", 250)
b = Expense("Tè", 150)
print(a.CURRENCY, Expense.CURRENCY) # EUR EUR
Una trappola pericolosa è l'attributo di classe mutabile. Se nel corpo della classe scrivete rows = [] e aggiungete elementi con self.rows.append(...), la lista diventa comune a tutti gli oggetti. I campi mutabili vanno sempre creati dentro __init__.
I campi di un oggetto si vedono con vars(obj) oppure obj.__dict__, mentre un attributo si legge per nome da una stringa con getattr(obj, "amount").
Proprietà di classe: @property
Una proprietà è un metodo che dall'esterno si legge come un campo normale, senza parentesi. È comoda per i valori calcolati: il totale del report non va memorizzato a parte e non rischia di non essere aggiornato.
class Report:
def __init__(self):
self.rows = []
def add(self, expense):
self.rows.append(expense)
@property
def total(self):
return sum(e.amount for e in self.rows)
r = Report()
r.add(Expense("Caffè", 250))
r.add(Expense("Taxi", 300))
print(r.total) # 550
Se serve controllare la scrittura, alla proprietà si aggiunge un setter con @total.setter. Senza setter l'assegnazione r.total = 0 genera AttributeError.
Una nota sull'incapsulamento. In Python non esistono campi privati in senso stretto. Un trattino basso (_cache) è una convenzione del tipo «non toccate dall'esterno», due trattini bassi (__segreto) attivano il name mangling, così che un discendente non sovrascriva il campo per errore.
Ereditarietà delle classi in Python
L'ereditarietà permette di creare una classe figlia a partire da una classe genitore: il figlio riceve tutti i campi e i metodi, e può ridefinire quelli che servono. La classe genitore si indica tra parentesi.
class MonthlyReport(Report):
def __init__(self, month):
super().__init__() # richiama __init__ del genitore
self.month = month
def add(self, expense): # ridefinizione del metodo
print(f"[{self.month}] + {expense}")
super().add(expense) # mantiene il comportamento del genitore
m = MonthlyReport("ottobre")
m.add(Expense("Caffè", 250)) # [ottobre] + Expense('Caffè', 250)
print(m.total) # 250 — la proprietà è ereditata
super() restituisce un oggetto intermediario per richiamare i metodi del genitore. Dimenticare super().__init__() nel __init__ del figlio è un errore classico: il campo rows non viene creato, e il primo add genera AttributeError.
Per verificare la parentela si usa issubclass(MonthlyReport, Report). In Python 3 tutte le classi ereditano implicitamente da object, da cui arrivano __repr__, __eq__ e gli altri metodi di base. Python supporta anche l'ereditarietà multipla (class C(A, B)); l'ordine di ricerca dei metodi si vede in C.__mro__.
Classe astratta in Python
La classe astratta definisce un'interfaccia: quali metodi devono essere implementati dalle sottoclassi. Non si può creare un'istanza di una classe astratta. Serve il modulo abc:
from abc import ABC, abstractmethod
class Exporter(ABC):
@abstractmethod
def export(self, report): ...
class CsvExporter(Exporter):
def export(self, report):
return "\n".join(f"{e.title};{e.amount}" for e in report.rows)
Exporter() # TypeError: Can't instantiate abstract class Exporter ...
Una sottoclasse che non implementa tutti i metodi astratti resta anch'essa astratta e genera lo stesso errore alla creazione dell'oggetto.
Composizione al posto delle gerarchie profonde
Dopo aver conosciuto l'ereditarietà viene voglia di costruire scale: spesa base, spesa in denaro, spesa in euro, e così via per cinque livelli. In Python lo si fa raramente. L'ereditarietà descrive il rapporto «è un», la composizione quello «contiene»: un report non è una lista di spese, la contiene nel campo rows.
Regola pratica: se serve solo usare una capacità di un altro oggetto, mettetelo in un campo invece di ereditare. Per i dati spesso basta un dataclass, mentre la logica può restare in normali funzioni.
Dataclass in Python: classe senza codice ripetitivo
Per le classi che contengono soprattutto dati, scrivere a mano __init__, __repr__ e __eq__ è noioso. Il decoratore @dataclass del modulo dataclasses li genera a partire dalle annotazioni dei campi:
from dataclasses import dataclass, field
@dataclass
class Expense:
title: str
amount: float
tags: list[str] = field(default_factory=list)
def __post_init__(self):
if self.amount < 0:
raise ValueError("L'importo non può essere negativo")
e = Expense("Caffè", 250)
print(e) # Expense(title='Caffè', amount=250, tags=[])
print(e == Expense("Caffè", 250)) # True
La verifica dei valori che prima si scriveva in __init__ si sposta in __post_init__, che viene chiamato subito dopo l'inizializzatore generato. I valori predefiniti mutabili si indicano solo tramite field(default_factory=list): la scrittura tags: list = [] viene rifiutata dal dataclass con ValueError.
frozen e slots
Il parametro frozen=True rende l'oggetto immutabile, mentre slots=True (dalla versione 3.10) crea un __slots__ e fa risparmiare memoria. Il tentativo di modificare un campo di un oggetto congelato genera FrozenInstanceError. Per ottenere un oggetto con un nuovo valore si crea una copia:
from dataclasses import dataclass, replace
@dataclass(frozen=True, slots=True)
class Expense:
title: str
amount: float
e = Expense("Caffè", 250)
# e.amount = 300 -> dataclasses.FrozenInstanceError
e2 = replace(e, amount=300)
print(e, e2) # Expense(title='Caffè', amount=250) Expense(title='Caffè', amount=300)
Dalla versione 3.13 la stessa operazione è disponibile anche con la funzione universale copy.replace(). Gli oggetti dataclass congelati si possono inserire nei set e usare come chiavi di dizionario, perché hanno un __hash__.
Novità nelle classi: Self, @override, __slots__
Per la tipizzazione delle classi ci sono due strumenti utili. typing.Self (Python 3.11, PEP 673) indica «verrà restituito un oggetto della stessa classe»: funziona anche nei metodi ereditati. Il decoratore @override (Python 3.12, PEP 698) segnala un metodo che deve ridefinire quello del genitore: se avete sbagliato il nome, mypy o pyright segnalano che nella classe base non esiste un metodo con quel nome.
from typing import Self, override
class Report:
def __init__(self):
self.rows = []
def add(self, e) -> Self:
self.rows.append(e)
return self # si può concatenare le chiamate
class MonthlyReport(Report):
@override
def add(self, e) -> Self:
return super().add(e)
In Python 3.15 è stata eliminata una limitazione di nicchia: alle sottoclassi di tuple si possono assegnare __slots__ non vuoti, e __dict__ e __weakref__ si possono indicare in __slots__ di qualsiasi classe. Nella 3.14 una dichiarazione del genere in una sottoclasse della tupla generava TypeError: nonempty __slots__ not supported for subtype of 'tuple'.
Errori frequenti con le classi
| Errore | Sintomo | Come correggere |
|---|---|---|
Dimenticato self nel metodo | TypeError: ... takes 0 positional arguments but 1 was given | Il primo parametro del metodo è sempre self |
Campo senza self. in __init__ | Il valore scompare dopo __init__ | Scrivere self.amount = amount |
| Lista come attributo di classe | I dati «passano» da un oggetto all'altro | Creare le liste in __init__ o tramite default_factory |
Manca super().__init__() | AttributeError sui campi del genitore | Richiamare l'inizializzatore del genitore |
| Proprietà chiamata con le parentesi | TypeError: 'int' object is not callable | Leggere la proprietà senza parentesi: r.total |
Conclusioni
La OOP in Python parte da una cosa semplice: la classe descrive campi e metodi, __init__ riempie i campi, self indica l'oggetto corrente. Ereditarietà e super() permettono di estendere il comportamento, le classi astratte definiscono un'interfaccia, @property rende calcolabili i campi. Per le classi-dati usate @dataclass, mentre per le gerarchie profonde preferite la composizione. Classi e dataclass sono trattati in pratica nella lezione del corso di Python sull'esempio del tracker delle spese.
Domande frequenti
In che cosa una classe si differenzia da un oggetto in Python?
La classe è la descrizione del tipo: quali campi e metodi ha. L'oggetto, cioè l'istanza, si crea chiamando la classe, per esempio Expense("Caffè", 250), e conserva valori concreti per i campi.
È obbligatorio scrivere __init__ nella classe?
No. Se __init__ non è dichiarato, si usa quello ereditato da object, che non accetta argomenti. Nel dataclass lo genera il decoratore.
In che cosa __str__ si differenzia da __repr__?
__repr__ è una rappresentazione univoca per lo sviluppatore e per il debugger, __str__ è l'output leggibile per l'utente nelle print e nelle f-string. Se __str__ manca, Python usa __repr__.
Quando scegliere un dataclass e quando una classe normale?
Il dataclass va bene quando la classe conserva soprattutto dati e servono __init__, __repr__ e il confronto già pronti. La classe normale è più adatta quando conta una logica di inizializzazione complessa o il comportamento è più importante dei dati.
Ci sono campi privati in Python?
Non c'è una privatezza rigorosa. Un trattino basso iniziale è una convenzione «non per uso esterno», due trattini bassi attivano il name mangling, che protegge il campo da una sovrascrittura accidentale nella sottoclasse.
Questo argomento è trattato in modo approfondito, con pratica, nel corso «Python 3.15: от новичка до профи».
Vai al corso