lettura facile

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:

  1. Descriptor. Verifica se l'attributo è un descriptor presente nel dizionario della classe. Se lo trova, viene chiamato il metodo `__get__` del descrittore.
  2. Dizionario dell'istanza (`obj.__dict__`). Se non è un descriptor, cerca l'attributo nel dizionario dell'istanza e lo restituisce se lo trova.
  3. Dizionario della classe (`type(obj).__dict__`). Se non lo trova nell'istanza, cerca l'attributo nel dizionario della classe e lo restituisce se è presente. 
  4. 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.




Se qualcosa non ti è chiaro, scrivi la tua domanda nei commenti.




FacebookTwitterLinkedinLinkedin