Basi

Classi e OOP in Python: __init__, self, metodi, ereditarietà e dataclass

Aggiornato: · Skillaria.Top

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

ErroreSintomoCome correggere
Dimenticato self nel metodoTypeError: ... takes 0 positional arguments but 1 was givenIl primo parametro del metodo è sempre self
Campo senza self. in __init__Il valore scompare dopo __init__Scrivere self.amount = amount
Lista come attributo di classeI dati «passano» da un oggetto all'altroCreare le liste in __init__ o tramite default_factory
Manca super().__init__()AttributeError sui campi del genitoreRichiamare l'inizializzatore del genitore
Proprietà chiamata con le parentesiTypeError: 'int' object is not callableLeggere 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.

Impara l’argomento in video

Questo argomento è trattato in modo approfondito, con pratica, nel corso «Python 3.15: от новичка до профи».

Vai al corso