
I descrittori in Python
In Python un descrittore (descriptor) è un oggetto che ti permette di controllare cosa succede quando leggi, scrivi o cancelli un attributo in una classe.
In altre parole, ti permette di mettere delle "regole" su come funziona l'accesso, la modifica o la cancellazione degli attributi in una classe di oggetti.
Ad esempio, immagina di avere una classe `Persona` con un attributo `età`.
class Persona:
def __init__(self, nome, età):
self.nome = nome
self.età = età
Crea un oggetto della classe
p = Persona('Mario', 25)
Poi stampa il contenuto di un attributo.
print(p.età)
25
Adesso modifica il contenuto dell'attributo
p.età = 30
Infine cancella l'attributo 'età' dall'oggetto tramite l'istruzione del.
del p.età
Fin qui, tutto normale. Ma cosa succede se vuoi fare qualcosa di speciale ogni volta che l'età viene letta o cambiata? Qui entrano in gioco i descriptor.
Come funziona un descriptor?
Un descriptor è una classe descrittore creata appositamente per gestire il comportamento degli oggetti di una classe, quando accedi, modifichi o cancelli gli attributi.
Puoi definire una classe descrittore che gestisce i metodi:
- __get__()
Viene chiamato quando un attributo è letto. È utilizzato nei descriptor per personalizzare cosa succede quando si legge un attributo. - __set__()
Viene chiamato quando un attributo viene assegnato (scritto). Se definito, ti permette di controllare cosa succede quando si modifica il valore di un attributo. - __delete__()
Viene chiamato quando si cancella un attributo usando del.
Questi metodi non fanno parte della classe base object, quindi non sono ereditati da tutti gli oggetti.
Quindi, se vuoi usarli, devi creare una classe apposita che li definisca esplicitamente, come nel caso dei descriptor (o classe descrittore).
Ecco un esempio pratico:
class EtàDescriptor:
def __init__(self, valore_iniziale=0):
self.valore = valore_iniziale
def __get__(self, instance, owner):
return self.valore
def __set__(self, instance, valore):
if valore < 0:
raise ValueError("L'età non può essere negativa")
self.valore = valore
class Persona:
età = EtàDescriptor()
def __init__(self, nome, età):
self.nome = nome
self.età = età
In questo esempio hai creato un descriptor (classe descrittore) chiamato 'EtàDescriptor' e l'hai utilizzato nella classe 'Persona' sull'attributo 'età'.
Il metodo __set__() della classe descrittore impedisce l'assegnazione di valori negativi agli attributi.
Nota che la classe descrittore 'EtàDescriptor' è stata utilizzata nella classe 'Persona' solo per definire il comportamento dell'attributo 'età'. Quindi, non viene richiamata quando provi a modificare l'attributo 'nome'.
Crea una istanza della classe Persona
p = Persona('Mario', 25)
Ora prova ad assegnare un valore negativo all'attributo età.
p.età = - 5
Ogni volta che accedi a `età` in un oggetto di `Persona`, Python chiama i metodi speciali nel descriptor.
In questo caso, chiama il metodo __set__() della classe descrittore 'EtàDescriptor' che impedisce l'assegnazione di un valore negativo
L'età non può essere negativa
Esempio 2
Se vuoi creare un attributo in sola lettura (cioè che può essere letto ma non modificato), puoi farlo facendo in modo che il metodo `__set__()` lanci un errore.
Crea una classe descrittore chiamata 'ReadOnlyDescriptor'
class ReadOnlyDescriptor:
def __init__(self, valore_iniziale):
self.valore = valore_iniziale
def __get__(self, instance, owner):
return self.valore
def __set__(self, instance, valore):
raise AttributeError("Questo attributo è in sola lettura")
def __delete__(self, instance, valore):
raise AttributeError("Questo attributo non può essere cancellato")
Questa classe descrittore consente solo la lettura dell'attributo, impedisce la scrittura e la cancellazione.
Ora chiama la classe descrittore 'ReadOnlyDescriptor'' nella classe 'Persona' per rendere l'attributo 'tipo' di sola lettura.
class Persona:
tipo = ReadOnlyDescriptor("studente")
def __init__(self, nome, età):
self.nome = nome
self.età = età
Crea un'istanza della classe 'Persona'
p = Persona('Mario', 25)
Poi accedi all'attributo 'tipo' dell'oggetto
print(p.tipo)
studente
Ora prova a modificare l'attributo 'tipo'
p.tipo = "docente"
Quando provi a modificare l'attributo 'tipo', Python chiama il metodo '__set__()' della classe descrittore 'ReadOnlyDescriptor' che impedisce la modifica dell'attributo.
Questo attributo è in sola lettura
Viceversa se provi a modificare l'attributo 'età' o 'nome', la modifica avviene senza alcun problema.
p.età=30
Questo accade perché hai richiamato la classe descrittore 'ReadOnlyDescriptor' solo dall'attributo 'tipo'.
Quindi, gli altri attributi sono direttamente modificabili con i metodi predefiniti nella classe Persona.
In generale, esistono due tipi di classe descrittore
- Data Descriptor
Sono gli oggetti che implementano sia il metodo `__get__()` che il metodo `__set__()`. In questo modo può gestire sia la lettura che la scrittura dell'attributo. - Non-Data Descriptor
Sono gli oggetti che implementano solo il metodo `__get__()`, quindi può solo gestire la lettura.
Il lookup con i descrittori
Il lookup è il processo attraverso il quale Python cerca e trova il valore di un attributo (`foo`) quando viene richiesto tramite un oggetto (`obj.foo`).
In altre parole, è l'insieme di passaggi che Python segue per determinare dove trovare l'attributo (nell'istanza, nella classe o nelle classi base) e come restituire il suo valore.
Questo processo include anche il controllo di meccanismi speciali, come i descriptor, che possono modificare il comportamento standard di accesso agli attributi.
Il lookup di un attributo in Python (`obj.attributo`) segue questi passaggi:
- Descriptor. Verifica se l'attributo è un descriptor presente nel dizionario della classe. Se lo trova, viene chiamato il metodo `__get__` del descrittore.
- Dizionario dell'istanza (`obj.__dict__`). Se non è un descriptor, cerca l'attributo nel dizionario dell'istanza e lo restituisce se lo trova.
- Dizionario della classe (`type(obj).__dict__`). Se non lo trova nell'istanza, cerca l'attributo nel dizionario della classe e lo restituisce se è presente.
- Classi base (MRO): Se non l'attributo non è presente nella classe, viene cercato nelle classi base seguendo l'ordine MRO.
Quindi, i descriptor modificano il normale meccanismo di lookup e hanno la precedenza sugli attributi dell'istanza.
Se un descriptor esiste, anche aggiungendo un attributo con lo stesso nome nell'istanza, il descriptor avrà comunque la precedenza.
Ad esempio crea un descrittore chiamato 'MyDescriptor'.
class MyDescriptor:
def __init__(self, initial_value):
self.value = initial_value
def __get__(self, instance, owner):
print("In __get__()")
return self.value
def __set__(self, instance, value):
print("In __set__()")
self.value = value
Poi definisci una classe 'MyClass' con un attributo 'year' gestito dal descrittore.
class MyClass:
year = MyDescriptor(2020)
Crea un'istanza di MyClass
obj = MyClass()
Poi aggiungi un attributo di istanza con lo stesso nome 'year'
obj.__dict__['year'] = 2021
Quando provi ad accedere all'attributo 'year', Python lo trova prima nel metodo `__get__` del descriptor e lo restituisce, ignorando l'attributo nell'istanza.
print(obj.year)
2020
Spero che ora sia tutto chiaro! Fammi sapere se hai altre domande.