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

  1. Cosa sono i Metodi Magici?

  2. Rappresentazione dell’oggetto

  3. Operator Overloading

  4. Metodi del Contenitore

  5. Accesso agli Attributi

  6. Gestori di Contesto

  7. Oggetti chiamabili

  8. Metodi Magici Avanzati

  9. Considerazioni sulle prestazioni

  10. Linee guida migliori

  11. Conclusione

  12. Riferimenti

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:

  1. Creiamo una classe Point che rappresenta un punto nello spazio 2D

  2. Il metodo __init__ inizializza le coordinate x e y

  3. Il metodo __add__ definisce cosa succede quando sommiamo due punti

  4. Quando scriviamo p1 + p2, Python chiama automaticamente p1.__add__(p2)

  5. 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 funzione str() e dalla funzione print(). Dovrebbe restituire una stringa leggibile per gli utenti finali.

  • __repr__: Chiamato dalla funzione repr() 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:

  1. Includere il nome del campo in cui si è verificato l’errore

  2. Mostrare il valore effettivo che ha causato l’errore

  3. Fornire messaggi di errore sia amichevoli che dettagliati

  4. 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:

  1. Gestione della precisione: Utilizziamo Decimal invece di float per evitare problemi di precisione dei numeri in virgola mobile nei calcoli finanziari.

  2. Sicurezza valutaria: La classe impedisce operazioni tra valute diverse per evitare errori.

  3. Controllo del tipo: Ogni metodo controlla se l’altro operando è del tipo corretto utilizzando isinstance().

  4. NonImplementato: Quando un’operazione non ha senso, restituiamo NotImplemented per permettere a Python di provare l’operazione inversa.

  5. @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:

  1. Come gestire diversi tipi di operandi

  2. Come implementare una corretta gestione degli errori

  3. Come utilizzare il decoratore @total_ordering

  4. Come mantenere la precisione nei calcoli finanziari

  5. 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:

  1. Memoria: La cache utilizza un OrderedDict per memorizzare coppie chiave-valore insieme a timestamp.

  2. Scadenza: Ciascun valore è memorizzato come una tupla di (valore, timestamp). Quando si accede a un valore, controlliamo se è scaduto.

  3. 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:

  1. Scadenza automatica delle voci vecchie

  2. Interfaccia simile a un dizionario per un uso semplice

  3. Efficienza della memoria rimuovendo le voci scadute

  4. Operazioni sicure per i thread (assumendo accesso single-threaded)

  5. 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:

  1. __getattr__: Chiamato solo quando la ricerca di un attributo fallisce (cioè quando l’attributo non esiste)

  2. __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:

  1. __setattr__: Chiamato quando un attributo viene impostato

  2. __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:

  1. Archiviazione: La classe utilizza un dizionario privato _data per memorizzare i valori degli attributi.

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

  3. 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:

  1. Registrazione automatica di tutte le modifiche agli attributi

  2. Registrazione di livello debug per l’accesso agli attributi

  3. Messaggi di errore chiari per attributi mancanti

  4. Facile tracciamento dei cambiamenti di stato dell’oggetto

  5. 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:

  1. __enter__: Chiamato all’ingresso del blocco with. Dovrebbe restituire la risorsa da gestire.

  2. __exit__: Chiamato all’uscita del blocco with, 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:

  1. Inizializzazione:

    • La classe prende un percorso del database

    • Inizializza la connessione e il cursore come None

  2. Metodo Enter:

    • Crea una connessione al database

    • Crea un cursore

    • Restituisce il cursore per l’uso nel blocco with

  3. 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:

  1. Le risorse sono gestite automaticamente (ad esempio, le connessioni vengono sempre chiuse).

  2. Con la sicurezza delle transazioni, le modifiche vengono confermate o annullate in modo appropriato.

  3. Le eccezioni vengono catturate e gestite in modo elegante

  4. Tutte le operazioni vengono registrate per il debug

  5. 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:

  1. Inizializzazione:

    • Prende una funzione come argomento

    • Crea un dizionario cache per memorizzare i risultati

    • Mantiene i metadati della funzione usando functools.update_wrapper

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

  3. 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:

  1. Miglior prestazione, poiché evita calcoli ridondanti

  2. Migliore, trasparenza, poiché funziona senza modificare la funzione originale

  3. È flessibile e può essere utilizzato con qualsiasi funzione

  4. È efficiente in termini di memoria e memorizza i risultati per il riutilizzo

  5. 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:

  1. Variabile di classe: _istanza memorizza l’unica istanza della classe

  2. metodo new:

    • Controlla se esiste un’istanza

    • Crea una se non esiste

    • Restituisce l’istanza esistente se esiste

  3. 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:

  1. Eliminazione dei dizionari: Gli oggetti Python regolari memorizzano i loro attributi in un dizionario (__dict__), che ha un overhead. La funzione sys.getsizeof() non tiene conto delle dimensioni di questo dizionario.

  2. Memorizzazione degli attributi: Per oggetti piccoli con pochi attributi, il costo degli slot può superare il risparmio del dizionario.

  3. 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__:

  1. Veri vantaggi in termini di memoria: I risparmi di memoria principali derivano dall’eliminazione di __dict__ dell’istanza

  2. Restrizioni dinamiche: Non è possibile aggiungere attributi arbitrari agli oggetti inseriti

  3. Considerazioni sull’ereditarietà: Utilizzare __slots__ con l’ereditarietà richiede una pianificazione attenta

  4. Casi d’uso: Ideale per classi con molte istanze e attributi fissi

  5. 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:

  1. Non c’è bisogno di controllare se una chiave esiste, il che è più conveniente.

  2. L’inizializzazione automatica crea valori predefiniti secondo necessità.

  3. Riduce il codice boilerplate per l’inizializzazione dei dizionari.

  4. È più flessibile e può implementare qualsiasi logica di valore predefinito.

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

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

  2. Memorizza i valori calcolati: Puoi memorizzare i risultati di operazioni costose e aggiornare la cache solo quando necessario. Usa @property per attributi calcolati.

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

  1. Rappresentazione dell’oggetto:

    • Usa __str__ per un output user-friendly

    • Usa __repr__ per il debugging e lo sviluppo

  2. Sovraccarico degli operatori:

    • Implementa operatori aritmetici e di confronto

    • Restituisci NotImplemented per operazioni non supportate

    • Usa @total_ordering per confronti coerenti

  3. Comportamento del contenitore:

    • Implementa protocolli di sequenza e mappatura

    • Considera le prestazioni per operazioni frequentemente utilizzate

    • Gestisci i casi limite in modo appropriato

  4. 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__

  5. 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:

  1. Crea strutture dati personalizzate

  2. Implementa tipi specifici del dominio

  3. Gestisci correttamente le risorse

  4. Aggiungi comportamenti speciali alle tue classi

  5. Rendi il tuo codice più Pythonico

Quando evitare i metodi magici

Evita i metodi magici quando:

  1. L’accesso agli attributi semplici è sufficiente

  2. Il comportamento sarebbe confuso o inaspettato

  3. Le prestazioni sono cruciali e i metodi magici aggiungerebbero overhead

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

  1. Modello Dati Python – Documentazione Ufficiale – Guida completa al modello dati di Python e ai metodi magici.

  2. functools.total_ordering – Documentazione per il decoratore total_ordering che riempie automaticamente i metodi di confronto mancanti.

  3. Nomi Metodi Speciali Python – Riferimento ufficiale per gli identificatori dei metodi speciali in Python.

  4. 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à

  1. 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:

  1. Esperimenti pratici per ottimizzazioni delle query ORM di Django – Scopri come ottimizzare le tue query ORM di Django con esempi pratici e esperimenti.

  2. Il costo elevato di uWSGI sincrono – Comprendere le implicazioni sulle prestazioni del processo sincrono in uWSGI e come influisce sulle applicazioni web Python.