Classi e Oggetti
Padroneggia la programmazione orientata agli oggetti in Python: scopri come definire classi e istanze, applicare i pilastri dell'OOP e utilizzare strumenti moderni come @property, dataclasses e __slots__ per scrivere codice modulare ed efficiente.
Le classi in Python fungono da blueprint per la creazione di oggetti, definendo stato (attributi) e comportamento (metodi) condivisi o specifici delle istanze. Queste istanze, o oggetti, sono entità concrete modellate dalle classi, che permettono un'organizzazione modulare del codice attraverso la programmazione orientata agli oggetti.
Introduzione
Le classi in Python sono il pilastro della programmazione orientata agli oggetti (OOP). Esse rappresentano un modello, un "progetto" o "blueprint" per creare strutture dati complesse che legano insieme informazioni (dati) e comportamenti (funzionalità). Questo approccio consente di modellare il mondo reale in codice, organizzando i programmi in componenti modulari, riutilizzabili e manutenibili.
Classi e oggetti, il modello e le istanze
Una classe è una descrizione astratta. Pensatela come il progetto di una casa: definisce la struttura, le stanze e le porte, ma non è una casa in sé. Per avere una casa concreta, dovete costruirla seguendo quel progetto. Allo stesso modo, per utilizzare una classe, dovete crearne delle istanze, cioè degli oggetti concreti.
class Automobile:
"""Un modello per rappresentare un'automobile."""
pass
In questo frammento abbiamo definito una classe Automobile. Tuttavia non abbiamo ancora un'automobile. Per creare un'istanza dobbiamo "chiamare" la classe come una funzione.
auto_rossa = Automobile()
auto_blu = Automobile()
Le variabili auto_rossa e auto_blu sono due oggetti indipendenti, creati a partire dallo stesso modello. Ognuno avrà la sua identità e, come vedremo, il suo stato specifico.
Lo stato e il comportamento, attributi e metodi
Ogni oggetto è caratterizzato da due elementi fondamentali.
- Stato (dati): sono i valori che descrivono l'oggetto, memorizzati in attributi (o variabili di istanza).
- Comportamento (operazioni): sono le azioni che l'oggetto può compiere, definite come metodi (funzioni definite all'interno della classe).
Attributi di classe contro attributi di istanza
Una distinzione fondamentale in Python è tra attributi definiti a livello di classe e quelli definiti a livello di istanza.
- Attributi di classe: sono condivisi da tutte le istanze della classe. Se lo modifichi, il cambiamento si riflette su tutti gli oggetti.
- Attributi di istanza: sono specifici per ogni singolo oggetto. Ogni istanza può avere valori diversi per questi attributi.
class Cane:
specie = "Mammifero"
def __init__(self, nome):
self.nome = nome
def abbaia(self):
print(f"{self.nome} dice, Bau!")
# Creo due istanze della classe Cane
fido = Cane("Fido")
rex = Cane("Rex")
print(fido.specie) # Mammifero
print(rex.specie) # Mammifero
print(fido.nome) # Fido
print(rex.nome) # Rex
fido.abbaia() # Fido dice, Bau!
rex.abbaia() # Rex dice, Bau!
# Modifico l'attributo della classe Cane
Cane.specie = "Canide"
print(fido.specie) # Canide
print(rex.specie) # Canide
Metodi di classe e metodi statici
Oltre agli attributi, anche i metodi possono operare a diversi livelli. Python offre due decoratori dedicati a questo scopo, che completano il quadro visto sopra per gli attributi.
@classmethod: riceve come primo argomento la classe stessa (convenzionalmente chiamatocls) invece dell'istanza. È utile per creare metodi "factory", cioè costruttori alternativi.@staticmethod: non riceve automaticamente né l'istanza né la classe. È essenzialmente una funzione normale collocata dentro la classe per motivi di organizzazione logica.
class Cane:
specie = "mammifero"
def __init__(self, nome, eta):
self.nome = nome
self.eta = eta
@classmethod
def da_eta_in_mesi(cls, nome, eta_in_mesi):
return cls(nome, eta_in_mesi // 12)
@staticmethod
def e_specie_valida(specie):
return specie in ("mammifero", "canide")
cucciolo = Cane.da_eta_in_mesi("Bella", 18)
print(cucciolo.eta) # 1
print(Cane.e_specie_valida("mammifero")) # True
Per orientarsi rapidamente tra i tre tipi di metodo, ecco un riepilogo.
| Tipo di metodo | Primo parametro | Caso d'uso tipico |
|---|---|---|
| Metodo di istanza | self | Operare sui dati specifici di un oggetto |
| Metodo di classe | cls | Costruttori alternativi, logica legata alla classe |
| Metodo statico | nessuno | Funzioni di utilità logicamente correlate alla classe |
Il metodo costruttore __init__
Il metodo __init__ è un metodo speciale che Python esegue automaticamente al momento della creazione di un nuovo oggetto (istanza). Il suo scopo è inizializzare lo stato dell'oggetto, ovvero impostare i valori iniziali degli attributi di istanza.
class Persona:
def __init__(self, nome, eta):
self.nome = nome
self.eta = eta
self.anno_corrente = 2026
def descriviti(self):
return f"Mi chiamo {self.nome} e ho {self.eta} anni."
anna = Persona("Anna", 25)
print(anna.nome)
print(anna.eta)
print(anna.anno_corrente)
print(anna.descriviti())
Il parametro self è una convenzione (ma non una parola chiave) che rappresenta l'istanza corrente dell'oggetto. Quando si chiama un metodo come anna.descriviti(), Python traduce automaticamente questa chiamata in Persona.descriviti(anna), passando l'oggetto anna come primo argomento al metodo.
Il metodo __str__ per una rappresentazione chiara
Per migliorare la leggibilità del codice, soprattutto durante il debugging, è buona prassi definire il metodo __str__. Questo metodo restituisce una rappresentazione dell'oggetto come stringa, che viene utilizzata dalle funzioni print() e str().
class Persona:
def __init__(self, nome, eta):
self.nome = nome
self.eta = eta
def __str__(self):
return f"Persona, {self.nome} ({self.eta} anni)"
anna = Persona("Anna", 25)
print(anna)
Principi fondamentali della POO
Le classi sono il mezzo attraverso cui si realizzano i quattro pilastri della programmazione orientata agli oggetti.
Incapsulamento
L'incapsulamento è il principio che consiste nel raggruppare dati e metodi che operano su quei dati all'interno di un'unica entità, la classe, nascondendo i dettagli implementativi interni. In Python non esistono meccanismi di "private" o "protected" rigidi come in altri linguaggi (Java, C++). Tuttavia esiste una convenzione di naming per indicare che un attributo o metodo è "privato" e non dovrebbe essere accessibile dall'esterno: usare un underscore _ come prefisso.
class ContoBancario:
def __init__(self, saldo_iniziale):
self._saldo = saldo_iniziale
def deposita(self, importo):
if importo > 0:
self._saldo += importo
return True
return False
def preleva(self, importo):
if 0 < importo <= self._saldo:
self._saldo -= importo
return True
return False
def mostra_saldo(self):
return f"Saldo disponibile, {self._saldo}€"
In questo esempio, l'attributo _saldo è "protetto". Un utente della classe ContoBancario non dovrebbe accedervi direttamente, ma è incoraggiato a usare i metodi deposita, preleva e mostra_saldo. Questo incapsula la logica di business e previene operazioni non valide, come prelievi che andrebbero in negativo.
Ereditarietà
L'ereditarietà permette di creare una nuova classe (sottoclasse o classe figlia) che eredita attributi e metodi da una classe esistente (superclasse o classe genitore). È un potente meccanismo per il riutilizzo del codice e per creare gerarchie concettuali, ad esempio un "Cane" è un "Animale".
class Animale:
def __init__(self, nome):
self.nome = nome
def fai_suono(self):
return "..."
class Cane(Animale):
def fai_suono(self):
return "Bau! Bau!"
class Gatto(Animale):
def fai_suono(self):
return "Miao! Miao!"
animale = Animale("Bestia")
cane = Cane("Fido")
gatto = Gatto("Felix")
print(animale.fai_suono()) # ...
print(cane.fai_suono()) # Bau! Bau!
print(gatto.fai_suono()) # Miao! Miao!
Quando si progetta una gerarchia, vale la pena tenere presente il principio "composizione sopra ereditarietà": se la relazione tra due classi non è chiaramente di tipo "è un", spesso è più chiaro e manutenibile che una classe contenga un'istanza dell'altra come attributo, invece di ereditare da essa.
Polimorfismo
Il polimorfismo è la capacità di oggetti di classi diverse di rispondere allo stesso messaggio, cioè alla stessa chiamata di metodo, in modi specifici per la loro classe. Nell'esempio precedente, il polimorfismo si manifesta nel fatto che il metodo fai_suono() viene chiamato su oggetti di tipo Animale, Cane e Gatto, e ogni oggetto esegue la sua implementazione specifica.
Astrazione
L'astrazione è la pratica di nascondere la complessità implementativa e di esporre all'utente solo le funzionalità essenziali. Nell'esempio di ContoBancario, l'utente non ha bisogno di sapere come viene gestito il saldo internamente: gli basta sapere che può depositare, prelevare e visualizzare il saldo. Un'astrazione più avanzata in Python si ottiene con le classi astratte, tramite il modulo abc, che definiscono interfacce che le sottoclassi sono obbligate a implementare.
from abc import ABC, abstractmethod
class Veicolo(ABC):
@abstractmethod
def avvia(self):
"""Metodo astratto, deve essere implementato dalle sottoclassi."""
pass
class Auto(Veicolo):
def avvia(self):
return "Motore dell'auto avviato."
class Moto(Veicolo):
def avvia(self):
return "Motore della moto avviato."
Un'alternativa più moderna e flessibile alle classi astratte è typing.Protocol, che permette di definire un'interfaccia basata sul cosiddetto "duck typing strutturale": una classe soddisfa il protocollo semplicemente implementando i metodi richiesti, senza doverlo ereditare esplicitamente. Questo strumento è utile quando si vuole tipizzare un'interfaccia senza imporre una relazione di ereditarietà rigida tra le classi coinvolte.
Best practice e strumenti moderni per classi Python
Per scrivere codice Python moderno, efficiente e manutenibile, è importante adottare alcune best practice.
Type hints, annotazioni di tipo
Le type hints migliorano la leggibilità e la manutenibilità del codice, specialmente in progetti di grandi dimensioni. Permettono agli IDE di offrire un autocompletamento più efficace e a strumenti come mypy di effettuare un'analisi statica del codice, rilevando potenziali bug di tipo.
class Persona:
def __init__(self, nome: str, eta: int) -> None:
self.nome: str = nome
self.eta: int = eta
def descriviti(self) -> str:
return f"Mi chiamo {self.nome} e ho {self.eta} anni."
def compleanno(self) -> None:
self.eta += 1
Data classes
In Python 3.7, il modulo dataclasses è stato introdotto per semplificare la creazione di classi il cui scopo principale è memorizzare dati. Una dataclass genera automaticamente metodi come __init__, __repr__ (simile a __str__), __eq__ e altri, riducendo drasticamente il boilerplate.
from dataclasses import dataclass
@dataclass
class Prodotto:
nome: str
prezzo: float
quantita: int = 0
prodotto = Prodotto("Laptop", 1200.50, 5)
print(prodotto) # Prodotto(nome='Laptop', prezzo=1200.5, quantita=5)
Il decoratore @property
Il decoratore @property permette di definire metodi che possono essere acceduti come se fossero attributi. È utile per aggiungere logica di controllo, come una validazione, all'accesso o alla modifica di un attributo, senza cambiare l'interfaccia pubblica della classe.
import math
class Cerchio:
def __init__(self, raggio: float):
self._raggio = raggio
@property
def raggio(self) -> float:
"""Getter per il raggio."""
return self._raggio
@raggio.setter
def raggio(self, valore: float) -> None:
"""Setter per il raggio, che garantisce un valore positivo."""
if valore < 0:
raise ValueError("Il raggio non può essere negativo")
self._raggio = valore
@property
def area(self) -> float:
"""Proprietà calcolata, non ha un setter."""
return math.pi * (self._raggio ** 2)
c = Cerchio(5)
print(c.raggio) # 5
c.raggio = 10
print(c.area) # 314.159
Ottimizzare la memoria con __slots__
Per impostazione predefinita, ogni istanza di una classe Python mantiene un dizionario interno (__dict__) per memorizzare i propri attributi, il che è flessibile ma consuma memoria. Quando si creano molte istanze della stessa classe, ad esempio in applicazioni che gestiscono grandi volumi di dati, definire __slots__ permette di dichiarare esplicitamente quali attributi sono ammessi, eliminando il dizionario interno e riducendo l'overhead di memoria.
class Punto:
__slots__ = ("x", "y")
def __init__(self, x: float, y: float):
self.x = x
self.y = y
p = Punto(1.0, 2.0)
print(p.x, p.y) # 1.0 2.0
Attenzione agli argomenti di default mutabili
Un errore molto comune, anche tra sviluppatori con esperienza, è utilizzare un oggetto mutabile (come una lista o un dizionario) come valore predefinito di un parametro. In Python, l'oggetto di default viene creato una sola volta, alla definizione della funzione, e condiviso tra tutte le chiamate successive: questo può portare a comportamenti inattesi.
class Carrello:
def __init__(self, articoli=None):
self.articoli = articoli if articoli is not None else []
def aggiungi(self, articolo):
self.articoli.append(articolo)
Usare None come valore predefinito e assegnare la lista vuota all'interno del metodo, come mostrato sopra, è la pratica corretta per evitare che tutte le istanze condividano involontariamente la stessa lista.
Classi come modelli riutilizzabili
La vera potenza delle classi risiede nella loro capacità di essere riutilizzate per creare innumerevoli oggetti, ognuno con il proprio stato indipendente, garantendo modularità e organizzazione.
persona1 = Persona("Luca", 30)
persona2 = Persona("Maria", 28)
persona3 = Persona("Marco", 40)
Tutti e tre gli oggetti condividono la stessa struttura e gli stessi metodi, ma ognuno mantiene i propri dati personali. Questa separazione e questo incapsulamento sono il fondamento per la scrittura di software robusto, scalabile e manutenibile, capace di modellare con efficacia tanto le entità concrete del mondo reale quanto i concetti astratti della logica di business.