Ti sei mai chiesto come Python faccia funzionare gli oggetti con operatori come +
o -
? O come sa come visualizzare gli oggetti quando li stampi? La risposta si trova nei metodi magici di Python, noti anche come metodi dunder (doppio sotto).
I metodi magici sono metodi speciali che ti permettono di definire come si comportano i tuoi oggetti in risposta a varie operazioni e funzioni integrate. Sono ciò che rende la programmazione orientata agli oggetti di Python così potente e intuitiva.
In questa guida, imparerai come utilizzare i metodi magici per creare codice più elegante e potente. Vedrai esempi pratici che mostrano come funzionano questi metodi in scenari reali.
Requisiti
-
Comprensione di base della sintassi di Python e dei concetti di programmazione orientata agli oggetti.
-
Familiarità con classi, oggetti e ereditarietà.
-
Conoscenza dei tipi di dati integrati di Python (liste, dizionari, ecc.).
-
È consigliata un’installazione funzionante di Python 3 per interagire attivamente con gli esempi qui presenti.
Indice
Cosa sono i Metodi Magici?
I metodi magici in Python sono metodi speciali che iniziano e finiscono con doppi trattini bassi (__
). Quando si utilizzano determinate operazioni o funzioni sugli oggetti, Python chiama automaticamente questi metodi.
Ad esempio, quando si usa l’operatore +
su due oggetti, Python cerca il metodo __add__
nell’operando di sinistra. Se lo trova, chiama quel metodo con l’operando di destra come argomento.
Ecco un semplice esempio che mostra come funziona:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
return Point(self.x + other.x, self.y + other.y)
p1 = Point(1, 2)
p2 = Point(3, 4)
p3 = p1 + p2 # Questo chiama p1.__add__(p2)
print(p3.x, p3.y) # Output: 4 6
Analizziamo cosa sta succedendo qui:
-
Creiamo una classe
Point
che rappresenta un punto nello spazio 2D -
Il metodo
__init__
inizializza le coordinate x e y -
Il metodo
__add__
definisce cosa succede quando sommiamo due punti -
Quando scriviamo
p1 + p2
, Python chiama automaticamentep1.__add__(p2)
-
Il risultato è un nuovo
Point
con coordinate (4, 6)
Questo è solo l’inizio. Python ha molti metodi magici che ti permettono di personalizzare il comportamento dei tuoi oggetti in diverse situazioni. Esploriamo alcuni dei più utili.
Rappresentazione dell’oggetto
Quando lavori con oggetti in Python, spesso devi convertirli in stringhe. Questo accade quando stampi un oggetto o cerchi di visualizzarlo nella console interattiva. Python fornisce due metodi magici per questo scopo: __str__
e __repr__
.
str vs repr
I metodi __str__
e __repr__
servono a scopi diversi:
-
__str__
: Chiamato dalla funzionestr()
e dalla funzioneprint()
. Dovrebbe restituire una stringa leggibile per gli utenti finali. -
__repr__
: Chiamato dalla funzionerepr()
e usato nella console interattiva. Dovrebbe restituire una stringa che, idealmente, potrebbe essere utilizzata per ricreare l’oggetto.
Ecco un esempio che mostra la differenza:
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
def __str__(self):
return f"{self.celsius}°C"
def __repr__(self):
return f"Temperature({self.celsius})"
temp = Temperature(25)
print(str(temp)) # Output: 25°C
print(repr(temp)) # Output: Temperature(25)
In questo esempio:
-
__str__
restituisce una stringa amichevole che mostra la temperatura con un simbolo di grado -
__repr__
restituisce una stringa che mostra come creare l’oggetto, utile per il debug
La differenza diventa chiara quando si utilizzano questi oggetti in contesti diversi:
-
Quando si stampa la temperatura, si vede la versione amichevole:
25°C
-
Quando si ispeziona l’oggetto nella console di Python, si vede la versione dettagliata:
Temperature(25)
Esempio pratico: Classe di Errore Personalizzata
Creiamo una classe di errore personalizzata che fornisce informazioni di debug migliori. Questo esempio mostra come è possibile utilizzare __str__
e __repr__
per rendere i messaggi di errore più utili:
class ValidationError(Exception):
def __init__(self, field, message, value=None):
self.field = field
self.message = message
self.value = value
super().__init__(self.message)
def __str__(self):
if self.value is not None:
return f"Error in field '{self.field}': {self.message} (got: {repr(self.value)})"
return f"Error in field '{self.field}': {self.message}"
def __repr__(self):
if self.value is not None:
return f"ValidationError(field='{self.field}', message='{self.message}', value={repr(self.value)})"
return f"ValidationError(field='{self.field}', message='{self.message}')"
# Utilizzo
try:
age = -5
if age < 0:
raise ValidationError("age", "Age must be positive", age)
except ValidationError as e:
print(e) # Output: Errore nel campo 'età': L'età deve essere positiva (valore: -5)
Questa classe di errore personalizzata fornisce diversi vantaggi:
-
Includere il nome del campo in cui si è verificato l’errore
-
Mostrare il valore effettivo che ha causato l’errore
-
Fornire messaggi di errore sia amichevoli che dettagliati
-
Facilitare il debug includendo tutte le informazioni rilevanti
Sovraccarico degli Operatori
Il sovraccarico degli operatori è una delle funzionalità più potenti dei metodi magici di Python. Ti consente di definire il comportamento dei tuoi oggetti quando vengono utilizzati con operatori come +
, -
, *
e ==
. Questo rende il tuo codice più intuitivo e leggibile.
Operatori Aritmetici
Python fornisce metodi magici per tutte le operazioni aritmetiche di base. Ecco una tabella che mostra quale metodo corrisponde a quale operatore:
Operatore | Metodo Magico | Descrizione |
+ |
__add__ |
Addizione |
- |
__sub__ |
Sottrazione |
* |
__mul__ |
Moltiplicazione |
/ |
__truediv__ |
Divisione |
// |
__floordiv__ |
Divisione intera |
% |
__mod__ |
Modulo |
** |
__pow__ |
Esponenziazione |
Operatori di Confronto
Allo stesso modo, puoi definire come i tuoi oggetti vengono confrontati usando questi metodi magici:
Operatore | Metodo Magico | Descrizione |
== |
__eq__ |
Uguale a |
!= |
__ne__ |
Diverso da |
< |
__lt__ |
Minore di |
> |
__gt__ |
Maggiore di |
<= |
__le__ |
Minore o uguale a |
>= |
__ge__ |
Maggiore o uguale a |
Esempio Pratico: Classe Money
Creiamo una classe Money
che gestisca correttamente le operazioni valutarie. Questo esempio mostra come implementare vari operatori e gestire casi limite:
from functools import total_ordering
from decimal import Decimal
@total_ordering # Implementa tutti i metodi di confronto basati su __eq__ e __lt__
class Money:
def __init__(self, amount, currency="USD"):
self.amount = Decimal(str(amount))
self.currency = currency
def __add__(self, other):
if not isinstance(other, Money):
return NotImplemented
if self.currency != other.currency:
raise ValueError(f"Cannot add different currencies: {self.currency} and {other.currency}")
return Money(self.amount + other.amount, self.currency)
def __sub__(self, other):
if not isinstance(other, Money):
return NotImplemented
if self.currency != other.currency:
raise ValueError(f"Cannot subtract different currencies: {self.currency} and {other.currency}")
return Money(self.amount - other.amount, self.currency)
def __mul__(self, other):
if isinstance(other, (int, float, Decimal)):
return Money(self.amount * Decimal(str(other)), self.currency)
return NotImplemented
def __truediv__(self, other):
if isinstance(other, (int, float, Decimal)):
return Money(self.amount / Decimal(str(other)), self.currency)
return NotImplemented
def __eq__(self, other):
if not isinstance(other, Money):
return NotImplemented
return self.currency == other.currency and self.amount == other.amount
def __lt__(self, other):
if not isinstance(other, Money):
return NotImplemented
if self.currency != other.currency:
raise ValueError(f"Cannot compare different currencies: {self.currency} and {other.currency}")
return self.amount < other.amount
def __str__(self):
return f"{self.currency} {self.amount:.2f}"
def __repr__(self):
return f"Money({repr(float(self.amount))}, {repr(self.currency)})"
Suddividiamo le principali caratteristiche di questa classe Money
:
-
Gestione della precisione: Utilizziamo
Decimal
invece difloat
per evitare problemi di precisione dei numeri in virgola mobile nei calcoli finanziari. -
Sicurezza valutaria: La classe impedisce operazioni tra valute diverse per evitare errori.
-
Controllo del tipo: Ogni metodo controlla se l’altro operando è del tipo corretto utilizzando
isinstance()
. -
NonImplementato: Quando un’operazione non ha senso, restituiamo
NotImplemented
per permettere a Python di provare l’operazione inversa. -
@total_ordering: Questo decoratore implementa automaticamente tutti i metodi di confronto basati su
__eq__
e__lt__
.
Ecco come utilizzare la classe Money
:
# Aritmetica di base
wallet = Money(100, "USD")
expense = Money(20, "USD")
remaining = wallet - expense
print(remaining) # Output: USD 80.00
# Lavorare con valute diverse
salary = Money(5000, "USD")
bonus = Money(1000, "USD")
total = salary + bonus
print(total) # Output: USD 6000.00
# Divisione per scalare
weekly_pay = salary / 4
print(weekly_pay) # Output: USD 1250.00
# Confronti
print(Money(100, "USD") > Money(50, "USD")) # Output: True
print(Money(100, "USD") == Money(100, "USD")) # Output: True
# Gestione degli errori
try:
Money(100, "USD") + Money(100, "EUR")
except ValueError as e:
print(e) # Output: Impossibile aggiungere valute diverse: USD ed EUR
Questa classe Money
dimostra diversi concetti importanti:
-
Come gestire diversi tipi di operandi
-
Come implementare una corretta gestione degli errori
-
Come utilizzare il decoratore
@total_ordering
-
Come mantenere la precisione nei calcoli finanziari
-
Come fornire metodi per stringa e rappresentazione
Metodi di contenitore
I metodi di contenitore ti permettono di far comportare i tuoi oggetti come contenitori incorporati come liste, dizionari o insiemi. Questo è particolarmente utile quando hai bisogno di un comportamento personalizzato per memorizzare e recuperare dati.
Protocollo di sequenza
Per far comportare il tuo oggetto come una sequenza (come una lista o tupla), devi implementare questi metodi:
Metodo | Descrizione | Utilizzo Esempio |
__len__ |
Restituisce la lunghezza del contenitore | len(obj) |
__getitem__ |
Permette l’indicizzazione con obj[key] |
obj[0] |
__setitem__ |
Permette l’assegnazione con obj[key] = value |
obj[0] = 42 |
__delitem__ |
Permette la cancellazione con del obj[key] |
del obj[0] |
__iter__ |
Restituisce un iteratore per il contenitore | for item in obj: |
__contains__ |
Implementa l’operatore in |
42 in obj |
Protocollo di Mapping
Per un comportamento simile a un dizionario, vorrai implementare questi metodi:
Metodo | Descrizione | Esempio di utilizzo |
__getitem__ |
Ottieni valore per chiave | obj["chiave"] |
__setitem__ |
Imposta valore per chiave | obj["chiave"] = valore |
__delitem__ |
Elimina coppia chiave-valore | del obj["chiave"] |
__len__ |
Ottieni numero di coppie chiave-valore | len(obj) |
__iter__ |
Itera sulle chiavi | for chiave in obj: |
__contains__ |
Controlla se la chiave esiste | "chiave" in obj |
Esempio pratico: Cache personalizzata
Implementiamo una cache basata sul tempo che scade automaticamente le voci vecchie. Questo esempio mostra come creare un contenitore personalizzato che si comporta come un dizionario ma con funzionalità aggiuntive:
import time
from collections import OrderedDict
class ExpiringCache:
def __init__(self, max_age_seconds=60):
self.max_age = max_age_seconds
self._cache = OrderedDict() # {chiave: (valore, timestamp)}
def __getitem__(self, key):
if key not in self._cache:
raise KeyError(key)
value, timestamp = self._cache[key]
if time.time() - timestamp > self.max_age:
del self._cache[key]
raise KeyError(f"Key '{key}' has expired")
return value
def __setitem__(self, key, value):
self._cache[key] = (value, time.time())
self._cache.move_to_end(key) # Spostare alla fine per mantenere l'ordine di inserimento
def __delitem__(self, key):
del self._cache[key]
def __len__(self):
self._clean_expired() # Pulire gli elementi scaduti prima di riportare la lunghezza
return len(self._cache)
def __iter__(self):
self._clean_expired() # Pulire gli elementi scaduti prima della scansione
for key in self._cache:
yield key
def __contains__(self, key):
if key not in self._cache:
return False
_, timestamp = self._cache[key]
if time.time() - timestamp > self.max_age:
del self._cache[key]
return False
return True
def _clean_expired(self):
"""Remove all expired entries from the cache."""
now = time.time()
expired_keys = [
key for key, (_, timestamp) in self._cache.items()
if now - timestamp > self.max_age
]
for key in expired_keys:
del self._cache[key]
Scomponiamo come funziona questa cache:
-
Memoria: La cache utilizza un
OrderedDict
per memorizzare coppie chiave-valore insieme a timestamp. -
Scadenza: Ciascun valore è memorizzato come una tupla di
(valore, timestamp)
. Quando si accede a un valore, controlliamo se è scaduto. -
Metodi del contenitore: La classe implementa tutti i metodi necessari per comportarsi come un dizionario:
-
__getitem__
: Recupera i valori e controlla la scadenza -
__setitem__
: Memorizza i valori con timestamp attuale -
__delitem__
: Rimuove le voci -
__len__
: Restituisce il numero di voci non scadute -
__iter__
: Itera sulle chiavi non scadute -
__contains__
: Controlla se una chiave esiste
-
Ecco come utilizzare la cache:
# Crea una cache con scadenza di 2 secondi
cache = ExpiringCache(max_age_seconds=2)
# Memorizza alcuni valori
cache["name"] = "Vivek"
cache["age"] = 30
# Accedi ai valori
print("name" in cache) # Output: True
print(cache["name"]) # Output: Vivek
print(len(cache)) # Output: 2
# Aspetta la scadenza
print("Waiting for expiration...")
time.sleep(3)
# Controlla i valori scaduti
print("name" in cache) # Output: False
try:
print(cache["name"])
except KeyError as e:
print(f"KeyError: {e}") # Output: KeyError: 'name'
print(len(cache)) # Output: 0
Questa implementazione della cache fornisce diversi vantaggi:
-
Scadenza automatica delle voci vecchie
-
Interfaccia simile a un dizionario per un uso semplice
-
Efficienza della memoria rimuovendo le voci scadute
-
Operazioni sicure per i thread (assumendo accesso single-threaded)
-
Mantiene l’ordine di inserimento delle voci
Accesso agli attributi
I metodi di accesso agli attributi ti permettono di controllare come i tuoi oggetti gestiscono l’ottenimento, l’impostazione e la cancellazione degli attributi. Questo è particolarmente utile per implementare proprietà, convalida e registrazione.
getattr e getattribute
Python fornisce due metodi per controllare l’accesso agli attributi:
-
__getattr__
: Chiamato solo quando la ricerca di un attributo fallisce (cioè quando l’attributo non esiste) -
__getattribute__
: Chiamato per ogni accesso all’attributo, anche per gli attributi che esistono
La principale differenza è che __getattribute__
viene chiamato per tutti gli accessi agli attributi, mentre __getattr__
viene chiamato solo quando l’attributo non viene trovato tramite mezzi normali.
Ecco un semplice esempio che mostra la differenza:
class AttributeDemo:
def __init__(self):
self.name = "Vivek"
def __getattr__(self, name):
print(f"__getattr__ called for {name}")
return f"Default value for {name}"
def __getattribute__(self, name):
print(f"__getattribute__ called for {name}")
return super().__getattribute__(name)
demo = AttributeDemo()
print(demo.name) # Output: __getattribute__ chiamato per nome
# Vivek
print(demo.age) # Output: __getattribute__ chiamato per età
# __getattr__ chiamato per età
# Valore predefinito per età
setattr e delattr
Allo stesso modo, puoi controllare come gli attributi vengono impostati e cancellati:
-
__setattr__
: Chiamato quando un attributo viene impostato -
__delattr__
: Chiamato quando un attributo viene eliminato
Questi metodi ti permettono di implementare la validazione, il logging o comportamenti personalizzati quando gli attributi vengono modificati.
Esempio pratico: Proprietà di auto-logging
Creiamo una classe che registra automaticamente tutte le modifiche alle proprietà. Questo è utile per il debugging, l’audit o il monitoraggio delle modifiche allo stato dell’oggetto:
import logging
# Configura il logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
class LoggedObject:
def __init__(self, **kwargs):
self._data = {}
# Inizializza gli attributi senza attivare __setattr__
for key, value in kwargs.items():
self._data[key] = value
def __getattr__(self, name):
if name in self._data:
logging.debug(f"Accessing attribute {name}: {self._data[name]}")
return self._data[name]
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
def __setattr__(self, name, value):
if name == "_data":
# Consenti di impostare direttamente l'attributo _data
super().__setattr__(name, value)
else:
old_value = self._data.get(name, "<undefined>")
self._data[name] = value
logging.info(f"Changed {name}: {old_value} -> {value}")
def __delattr__(self, name):
if name in self._data:
old_value = self._data[name]
del self._data[name]
logging.info(f"Deleted {name} (was: {old_value})")
else:
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
Vediamo come funziona questa classe:
-
Archiviazione: La classe utilizza un dizionario privato
_data
per memorizzare i valori degli attributi. -
Accesso agli attributi:
-
__getattr__
: Restituisce valori da_data
e registra messaggi di debug -
__setattr__
: Memorizza valori in_data
e registra le modifiche -
__delattr__
: Rimuove valori da_data
e registra le eliminazioni
-
-
Gestione speciale: L’attributo
_data
stesso è gestito in modo diverso per evitare ricorsione infinita.
Ecco come usare la classe:
# Crea un oggetto registrato con valori iniziali
user = LoggedObject(name="Vivek", email="[email protected]")
# Modifica attributi
user.name = "Vivek" # Registri: Nome modificato: Vivek -> Vivek
user.age = 30 # Registri: Età modificata: <non definito> -> 30
# Accedi agli attributi
print(user.name) # Output: Vivek
# Elimina attributi
del user.email # Registri: Email eliminata (era: [email protected])
# Prova ad accedere all'attributo eliminato
try:
print(user.email)
except AttributeError as e:
print(f"AttributeError: {e}") # Output: AttributeError: l'oggetto 'LoggedObject' non ha l'attributo 'email'
Questa implementazione fornisce diversi vantaggi:
-
Registrazione automatica di tutte le modifiche agli attributi
-
Registrazione di livello debug per l’accesso agli attributi
-
Messaggi di errore chiari per attributi mancanti
-
Facile tracciamento dei cambiamenti di stato dell’oggetto
-
Utile per il debugging e l’auditing
Manager di contesto
I gestori di contesto sono una funzione potente in Python che ti aiuta a gestire correttamente le risorse. Assicurano che le risorse siano correttamente acquisite e rilasciate, anche se si verifica un errore. L’istruzione with
è il modo più comune per utilizzare i gestori di contesto.
entrata e uscita
Per creare un gestore di contesto, è necessario implementare due metodi magici:
-
__enter__
: Chiamato all’ingresso del bloccowith
. Dovrebbe restituire la risorsa da gestire. -
__exit__
: Chiamato all’uscita del bloccowith
, anche se si verifica un’eccezione. Dovrebbe gestire la pulizia.
Il metodo __exit__
riceve tre argomenti:
-
exc_type
: Il tipo dell’eccezione (se presente) -
exc_val
: L’istanza dell’eccezione (se presente) -
exc_tb
: Il traceback (se presente)
Esempio pratico: Gestore della connessione al database
Creiamo un gestore di contesto per le connessioni al database. Questo esempio mostra come gestire correttamente le risorse del database e gestire le transazioni:
import sqlite3
import logging
# Impostare il logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
class DatabaseConnection:
def __init__(self, db_path):
self.db_path = db_path
self.connection = None
self.cursor = None
def __enter__(self):
logging.info(f"Connecting to database: {self.db_path}")
self.connection = sqlite3.connect(self.db_path)
self.cursor = self.connection.cursor()
return self.cursor
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None:
logging.error(f"An error occurred: {exc_val}")
self.connection.rollback()
logging.info("Transaction rolled back")
else:
self.connection.commit()
logging.info("Transaction committed")
if self.cursor:
self.cursor.close()
if self.connection:
self.connection.close()
logging.info("Database connection closed")
# Restituire False per propagare le eccezioni, True per sopprimerle
return False
Suddividiamo come funziona questo gestore di contesto:
-
Inizializzazione:
-
La classe prende un percorso del database
-
Inizializza la connessione e il cursore come None
-
-
Metodo Enter:
-
Crea una connessione al database
-
Crea un cursore
-
Restituisce il cursore per l’uso nel blocco
with
-
-
Metodo di uscita:
-
Gestisce la gestione delle transazioni (commit/rollback)
-
Chiude il cursore e la connessione
-
Registra tutte le operazioni
-
Ritorna False per propagare le eccezioni
-
Ecco come utilizzare il gestore di contesto:
# Crea un database di test in memoria
try:
# Transazione riuscita
with DatabaseConnection(":memory:") as cursor:
# Crea una tabella
cursor.execute("""
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT,
email TEXT
)
""")
# Inserisce dati
cursor.execute(
"INSERT INTO users (name, email) VALUES (?, ?)",
("Vivek", "[email protected]")
)
# Interroga i dati
cursor.execute("SELECT * FROM users")
print(cursor.fetchall()) # Output: [(1, 'Vivek', '[email protected]')]
# Dimostrazione rollback della transazione in caso di errore
with DatabaseConnection(":memory:") as cursor:
cursor.execute("""
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT,
email TEXT
)
""")
cursor.execute(
"INSERT INTO users (name, email) VALUES (?, ?)",
("Wewake", "[email protected]")
)
# Ciò causerà un errore - la tabella 'nonexistent' non esiste
cursor.execute("SELECT * FROM nonexistent")
except sqlite3.OperationalError as e:
print(f"Caught exception: {e}")
Questo gestore di contesto fornisce diversi vantaggi:
-
Le risorse sono gestite automaticamente (ad esempio, le connessioni vengono sempre chiuse).
-
Con la sicurezza delle transazioni, le modifiche vengono confermate o annullate in modo appropriato.
-
Le eccezioni vengono catturate e gestite in modo elegante
-
Tutte le operazioni vengono registrate per il debug
-
La dichiarazione
with
rende il codice chiaro e conciso
Oggetti chiamabili
Il metodo magico __call__
ti consente di far comportare le istanze della tua classe come funzioni. Questo è utile per creare oggetti che mantengono uno stato tra le chiamate o per implementare un comportamento simile a una funzione con funzionalità aggiuntive.
call
Il metodo __call__
viene chiamato quando provi a chiamare un’istanza della tua classe come se fosse una funzione. Ecco un semplice esempio:
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, x):
return x * self.factor
# Crea istanze che si comportano come funzioni
double = Multiplier(2)
triple = Multiplier(3)
print(double(5)) # Uscita: 10
print(triple(5)) # Uscita: 15
Questo esempio mostra come __call__
ti consenta di creare oggetti che mantengono uno stato (il fattore) pur essendo chiamabili come funzioni.
Esempio pratico: Decoratore di memoizzazione
Implementiamo un decoratore di memoizzazione utilizzando __call__
. Questo decoratore memorizzerà i risultati delle funzioni per evitare calcoli ridondanti:
import time
import functools
class Memoize:
def __init__(self, func):
self.func = func
self.cache = {}
# Mantieni i metadati della funzione (nome, docstring, ecc.)
functools.update_wrapper(self, func)
def __call__(self, *args, **kwargs):
# Crea una chiave dagli argomenti
# Per semplicità, assumiamo che tutti gli argomenti siano hashable
key = str(args) + str(sorted(kwargs.items()))
if key not in self.cache:
self.cache[key] = self.func(*args, **kwargs)
return self.cache[key]
# Utilizzo
@Memoize
def fibonacci(n):
"""Calculate the nth Fibonacci number recursively."""
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)
# Misura il tempo di esecuzione
def time_execution(func, *args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__}({args}, {kwargs}) took {end - start:.6f} seconds")
return result
# Senza memoizzazione, questo sarebbe estremamente lento
print("Calculating fibonacci(35)...")
result = time_execution(fibonacci, 35)
print(f"Result: {result}")
# La seconda chiamata è istantanea grazie alla memoizzazione
print("\nCalculating fibonacci(35) again...")
result = time_execution(fibonacci, 35)
print(f"Result: {result}")
Analizziamo come funziona questo decoratore di memoizzazione:
-
Inizializzazione:
-
Prende una funzione come argomento
-
Crea un dizionario cache per memorizzare i risultati
-
Mantiene i metadati della funzione usando
functools.update_wrapper
-
-
Chiamata del metodo:
-
Crea una chiave univoca dagli argomenti della funzione
-
Controlla se il risultato è nella cache
-
Se non presente, calcola il risultato e lo memorizza
-
Ritorna il risultato memorizzato
-
-
Utilizzo:
-
Applicato come decoratore a qualsiasi funzione
-
Memorizza automaticamente i risultati per chiamate ripetute
-
Preserva i metadati e il comportamento della funzione
-
I benefici di questa implementazione includono:
-
Miglior prestazione, poiché evita calcoli ridondanti
-
Migliore, trasparenza, poiché funziona senza modificare la funzione originale
-
È flessibile e può essere utilizzato con qualsiasi funzione
-
È efficiente in termini di memoria e memorizza i risultati per il riutilizzo
-
Mantiene la documentazione della funzione
Metodi Magici Avanzati
Ora esploriamo alcuni dei metodi magici più avanzati di Python. Questi metodi ti danno un controllo dettagliato sulla creazione degli oggetti, sull’uso della memoria e sul comportamento dei dizionari.
new per la Creazione degli Oggetti
Il metodo __new__
viene chiamato prima di __init__
ed è responsabile della creazione e del ritorno di una nuova istanza della classe. Questo è utile per implementare schemi come singleton o oggetti immutabili.
Ecco un esempio di uno schema singleton utilizzando __new__
:
class Singleton:
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, name=None):
# Questo sarà chiamato ogni volta che viene chiamato Singleton()
if name is not None:
self.name = name
# Utilizzo
s1 = Singleton("Vivek")
s2 = Singleton("Wewake")
print(s1 is s2) # Output: True
print(s1.name) # Output: Wewake (la seconda inizializzazione ha sovrascritto la prima)
Analizziamo come funziona questo singleton:
-
Variabile di classe:
_istanza
memorizza l’unica istanza della classe -
metodo new:
-
Controlla se esiste un’istanza
-
Crea una se non esiste
-
Restituisce l’istanza esistente se esiste
-
-
metodo init:
-
Chiamato ogni volta che il costruttore è utilizzato
-
Aggiorna gli attributi dell’istanza
-
slot per l’ottimizzazione della memoria
La variabile di classe __slots__
limita quali attributi un’istanza può avere, risparmiando memoria. Questo è particolarmente utile quando si hanno molte istanze di una classe con un insieme fisso di attributi.
Ecco un confronto tra classi regolari e classi con slot:
import sys
class RegularPerson:
def __init__(self, name, age, email):
self.name = name
self.age = age
self.email = email
class SlottedPerson:
__slots__ = ['name', 'age', 'email']
def __init__(self, name, age, email):
self.name = name
self.age = age
self.email = email
# Confronto dell'uso della memoria
regular_people = [RegularPerson("Vivek" + str(i), 30, "[email protected]") for i in range(1000)]
slotted_people = [SlottedPerson("Vivek" + str(i), 30, "[email protected]") for i in range(1000)]
print(f"Regular person size: {sys.getsizeof(regular_people[0])} bytes") # Output: Dimensione persona normale: 48 byte
print(f"Slotted person size: {sys.getsizeof(slotted_people[0])} bytes") # Output: Dimensione persona con slot: 56 byte
print(f"Memory saved per instance: {sys.getsizeof(regular_people[0]) - sys.getsizeof(slotted_people[0])} bytes") # Output: Memoria risparmiata per istanza: -8 byte
print(f"Total memory saved for 1000 instances: {(sys.getsizeof(regular_people[0]) - sys.getsizeof(slotted_people[0])) * 1000 / 1024:.2f} KB") # Output: Memoria totale risparmiata per 1000 istanze: -7,81 KB
Eseguendo questo codice si ottiene un risultato interessante:
Regular person size: 48 bytes
Slotted person size: 56 bytes
Memory saved per instance: -8 bytes
Total memory saved for 1000 instances: -7.81 KB
Sorprendentemente, in questo semplice esempio, l’istanza con gli slot è effettivamente più grande di 8 byte rispetto all’istanza normale! Questo sembra contraddire il comune consiglio sull’uso di __slots__
per risparmiare memoria.
Quindi cosa sta succedendo qui? Il reale risparmio di memoria da __slots__
proviene da:
-
Eliminazione dei dizionari: Gli oggetti Python regolari memorizzano i loro attributi in un dizionario (
__dict__
), che ha un overhead. La funzionesys.getsizeof()
non tiene conto delle dimensioni di questo dizionario. -
Memorizzazione degli attributi: Per oggetti piccoli con pochi attributi, il costo degli slot può superare il risparmio del dizionario.
-
Scalabilità: Il vero beneficio appare quando:
-
Hai molte istanze (migliaia o milioni)
-
I tuoi oggetti hanno molti attributi
-
Stai aggiungendo attributi dinamicamente
-
Vediamo un confronto più completo:
# Una misurazione della memoria più accurata
import sys
def get_size(obj):
"""Get a better estimate of the object's size in bytes."""
size = sys.getsizeof(obj)
if hasattr(obj, '__dict__'):
size += sys.getsizeof(obj.__dict__)
# Aggiungi la dimensione dei contenuti del dizionario
size += sum(sys.getsizeof(v) for v in obj.__dict__.values())
return size
class RegularPerson:
def __init__(self, name, age, email):
self.name = name
self.age = age
self.email = email
class SlottedPerson:
__slots__ = ['name', 'age', 'email']
def __init__(self, name, age, email):
self.name = name
self.age = age
self.email = email
regular = RegularPerson("Vivek", 30, "[email protected]")
slotted = SlottedPerson("Vivek", 30, "[email protected]")
print(f"Complete Regular person size: {get_size(regular)} bytes") # Output: Dimensione completa di una persona normale: 610 byte
print(f"Complete Slotted person size: {get_size(slotted)} bytes") # Output: Dimensione di una persona con slot completa: 56 byte
Con questa misurazione più accurata, vedrai che gli oggetti con slot di solito utilizzano meno memoria totale, specialmente quando si aggiungono più attributi.
Punti chiave su __slots__
:
-
Veri vantaggi in termini di memoria: I risparmi di memoria principali derivano dall’eliminazione di
__dict__
dell’istanza -
Restrizioni dinamiche: Non è possibile aggiungere attributi arbitrari agli oggetti inseriti
-
Considerazioni sull’ereditarietà: Utilizzare
__slots__
con l’ereditarietà richiede una pianificazione attenta -
Casi d’uso: Ideale per classi con molte istanze e attributi fissi
-
Bonus in termini di prestazioni: Può anche fornire un accesso più veloce agli attributi in alcuni casi
mancante per i Valori Predefiniti del Dizionario
Il metodo __missing__
viene chiamato dalle sottoclassi del dizionario quando una chiave non viene trovata. Questo è utile per implementare dizionari con valori predefiniti o creazione automatica delle chiavi.
Ecco un esempio di un dizionario che crea automaticamente liste vuote per chiavi mancanti:
class AutoKeyDict(dict):
def __missing__(self, key):
self[key] = []
return self[key]
# Utilizzo
groups = AutoKeyDict()
groups["team1"].append("Vivek")
groups["team1"].append("Wewake")
groups["team2"].append("Vibha")
print(groups) # Output: {'team1': ['Vivek', 'Wewake'], 'team2': ['Vibha']}
Questa implementazione fornisce diversi vantaggi:
-
Non c’è bisogno di controllare se una chiave esiste, il che è più conveniente.
-
L’inizializzazione automatica crea valori predefiniti secondo necessità.
-
Riduce il codice boilerplate per l’inizializzazione dei dizionari.
-
È più flessibile e può implementare qualsiasi logica di valore predefinito.
-
Creerà valori solo quando necessario, rendendolo più efficiente in termini di memoria.
Considerazioni sulle prestazioni
Anche se i metodi magici sono potenti, possono influenzare le prestazioni se non li si utilizza con attenzione. Esploriamo alcune considerazioni comuni sulle prestazioni e come misurarle.
Impatto dei metodi magici sulle prestazioni
Differenti metodi magici hanno diverse implicazioni sulle prestazioni:
Metodi di accesso agli attributi:
-
__getattr__
,__getattribute__
,__setattr__
e__delattr__
vengono chiamati frequentemente -
Operazioni complesse in questi metodi possono rallentare significativamente il tuo codice
Metodi dei contenitori:
-
__getitem__
,__setitem__
e__len__
sono spesso chiamati in loop -
Implementazioni inefficienti possono rendere il tuo contenitore molto più lento rispetto ai tipi incorporati
Sovraccarico degli operatori:
-
Gli operatori aritmetici e di confronto vengono utilizzati frequentemente
-
Implementazioni complesse possono rendere le operazioni semplici inaspettatamente lente
Misuriamo l’impatto sulle prestazioni di __getattr__
rispetto all’accesso diretto agli attributi:
import time
class DirectAccess:
def __init__(self):
self.value = 42
class GetAttrAccess:
def __init__(self):
self._value = 42
def __getattr__(self, name):
if name == "value":
return self._value
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
# Misura delle prestazioni
direct = DirectAccess()
getattr_obj = GetAttrAccess()
def benchmark(obj, iterations=1000000):
start = time.time()
for _ in range(iterations):
x = obj.value
end = time.time()
return end - start
direct_time = benchmark(direct)
getattr_time = benchmark(getattr_obj)
print(f"Direct access: {direct_time:.6f} seconds")
print(f"__getattr__ access: {getattr_time:.6f} seconds")
print(f"__getattr__ is {getattr_time / direct_time:.2f}x slower")
Eseguendo questo benchmark si evidenziano significative differenze di prestazioni:
Direct access: 0.027714 seconds
__getattr__ access: 0.060646 seconds
__getattr__ is 2.19x slower
Come puoi vedere, utilizzare __getattr__
è più di due volte più lento dell’accesso diretto agli attributi. Questo potrebbe non avere importanza per attributi accessibili occasionalmente, ma può diventare significativo nel codice critico in termini di prestazioni che accede agli attributi in loop stretti.
Strategie di ottimizzazione
Fortunatamente, ci sono vari modi per ottimizzare i metodi magici.
-
Usa gli slot per l’efficienza della memoria: Ciò riduce l’uso della memoria e migliora la velocità di accesso agli attributi. È ottimale per classi con molte istanze.
-
Memorizza i valori calcolati: Puoi memorizzare i risultati di operazioni costose e aggiornare la cache solo quando necessario. Usa
@property
per attributi calcolati. -
Minimizza le chiamate ai metodi: Assicurati di evitare chiamate ai metodi magici non necessarie e utilizza l’accesso diretto agli attributi quando possibile. Considera di usare
__slots__
per attributi frequentemente accessibili.
Best Practices
Quando utilizzi metodi magici, segui queste best practices per garantire che il tuo codice sia manutenibile, efficiente e affidabile.
1. Sii coerente
Quando implementi metodi magici correlati, mantieni la coerenza nel comportamento:
from functools import total_ordering
@total_ordering
class ConsistentNumber:
def __init__(self, value):
self.value = value
def __eq__(self, other):
if not isinstance(other, ConsistentNumber):
return NotImplemented
return self.value == other.value
def __lt__(self, other):
if not isinstance(other, ConsistentNumber):
return NotImplemented
return self.value < other.value
2. Restituisci NotImplemented
Quando un’operazione non ha senso, restituisci NotImplemented
per consentire a Python di provare l’operazione inversa:
class Money:
def __add__(self, other):
if not isinstance(other, Money):
return NotImplemented
# ... resto dell'implementazione
3. Mantienilo semplice
I metodi magici dovrebbero essere semplici e prevedibili. Evitare logiche complesse che potrebbero portare a comportamenti inaspettati:
# Buono: Semplice e prevedibile
class SimpleContainer:
def __init__(self):
self.items = []
def __getitem__(self, index):
return self.items[index]
# Cattivo: Complesso e potenzialmente confuso
class ComplexContainer:
def __init__(self):
self.items = []
self.access_count = 0
def __getitem__(self, index):
self.access_count += 1
if self.access_count > 100:
raise RuntimeError("Too many accesses")
return self.items[index]
4. Documentare il Comportamento
Documentare chiaramente come si comportano i tuoi metodi magici, specialmente se si discostano dalle aspettative standard:
class CustomDict(dict):
def __missing__(self, key):
"""
Called when a key is not found in the dictionary.
Creates a new list for the key and returns it.
This allows for automatic list creation when accessing
non-existent keys.
"""
self[key] = []
return self[key]
5. Considerare le Prestazioni
Essere consapevoli delle implicazioni sulle prestazioni, specialmente per i metodi chiamati frequentemente:
class OptimizedContainer:
__slots__ = ['items'] # Utilizzare __slots__ per migliori prestazioni
def __init__(self):
self.items = []
def __getitem__(self, index):
return self.items[index] # L'accesso diretto è più veloce
6. Gestire i Casi Limite
Considerare sempre i casi limite e gestirli in modo appropriato:
class SafeContainer:
def __getitem__(self, key):
if not isinstance(key, (int, slice)):
raise TypeError("Index must be integer or slice")
if key < 0:
raise ValueError("Index cannot be negative")
# ... resto dell'implementazione
Conclusione
I metodi magici di Python offrono un modo potente per far comportare le tue classi come tipi incorporati, consentendo un codice più intuitivo ed espressivo. In questa guida, abbiamo esplorato come funzionano questi metodi e come utilizzarli in modo efficace.
Concetti Chiave
-
Rappresentazione dell’oggetto:
-
Usa
__str__
per un output user-friendly -
Usa
__repr__
per il debugging e lo sviluppo
-
-
Sovraccarico degli operatori:
-
Implementa operatori aritmetici e di confronto
-
Restituisci
NotImplemented
per operazioni non supportate -
Usa
@total_ordering
per confronti coerenti
-
-
Comportamento del contenitore:
-
Implementa protocolli di sequenza e mappatura
-
Considera le prestazioni per operazioni frequentemente utilizzate
-
Gestisci i casi limite in modo appropriato
-
-
Gestione delle risorse:
-
Usa i gestori di contesto per una corretta gestione delle risorse
-
Implementa
__enter__
e__exit__
per la pulizia -
Gestisci le eccezioni in
__exit__
-
-
OTTIMIZZAZIONE DELLE PRESTAZIONI:
-
Usa
__slots__
per l’efficienza della memoria -
Memorizza i valori calcolati quando appropriato
-
Minimizza le chiamate di metodo nel codice utilizzato frequentemente
-
Quando utilizzare i metodi magici
I metodi magici sono più utili quando è necessario:
-
Crea strutture dati personalizzate
-
Implementa tipi specifici del dominio
-
Gestisci correttamente le risorse
-
Aggiungi comportamenti speciali alle tue classi
-
Rendi il tuo codice più Pythonico
Quando evitare i metodi magici
Evita i metodi magici quando:
-
L’accesso agli attributi semplici è sufficiente
-
Il comportamento sarebbe confuso o inaspettato
-
Le prestazioni sono cruciali e i metodi magici aggiungerebbero overhead
-
L’implementazione sarebbe eccessivamente complessa
Ricorda che con grande potere arriva grande responsabilità. Utilizza i metodi magici con giudizio, tenendo presente le loro implicazioni sulle prestazioni e il principio della minima sorpresa. Quando usati in modo appropriato, i metodi magici possono migliorare significativamente la leggibilità e l’espressività del tuo codice.
Riferimenti e Approfondimenti
Documentazione ufficiale di Python
-
Modello Dati Python – Documentazione Ufficiale – Guida completa al modello dati di Python e ai metodi magici.
-
functools.total_ordering – Documentazione per il decoratore total_ordering che riempie automaticamente i metodi di confronto mancanti.
-
Nomi Metodi Speciali Python – Riferimento ufficiale per gli identificatori dei metodi speciali in Python.
-
Classi di base astratte delle collezioni – Scopri le classi di base astratte per i contenitori che definiscono le interfacce che le tue classi di contenitori possono implementare.
Risorse della comunità
- Guida ai metodi magici di Python – Rafe Kettler – Esempi pratici dei metodi magici e casi d’uso comuni.
Ulteriori letture
Se hai apprezzato questo articolo, potresti trovare utili questi articoli correlati a Python sul mio blog personale:
-
Esperimenti pratici per ottimizzazioni delle query ORM di Django – Scopri come ottimizzare le tue query ORM di Django con esempi pratici e esperimenti.
-
Il costo elevato di uWSGI sincrono – Comprendere le implicazioni sulle prestazioni del processo sincrono in uWSGI e come influisce sulle applicazioni web Python.
Source:
https://www.freecodecamp.org/news/python-magic-methods-practical-guide/