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

URL: https://skillaria.top/it/guides/python/classi-python

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.

## FAQ

**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.


---
Source: Skillaria.Top (https://skillaria.top)
