Python avanzato operatore punto

Operatore punto avanzato in Python

L’operatore che consente il paradigma orientato agli oggetti in Python

L'operatore punto è uno dei pilastri del paradigma orientato agli oggetti in Python. Foto di Madeline Pere su Unsplash

Questa volta, scriverò di qualcosa apparentemente banale. Si tratta dell’ “operatore punto”. Molti di voi hanno già utilizzato questo operatore molte volte, senza sapere o interrogarsi su ciò che succede dietro le quinte. E rispetto al concetto di metaclassi di cui ho parlato la volta scorsa, questo è un po’ più utilizzabile per le attività quotidiane. Scherzo, lo state praticamente usando ogni volta che usate Python per qualcosa di più di un “Ciao mondo”. Questa è esattamente la ragione per cui ho pensato che potreste voler approfondire, e io voglio essere la vostra guida. Iniziamo il viaggio!

Inizierò con una domanda banale: “Cos’è un operatore punto?”

Ecco un esempio:

hello = 'Ciao mondo!'print(hello.upper())# CIAO MONDO!

Bene, questo è sicuramente un esempio di “Ciao mondo”, ma difficilmente riesco a immaginare qualcuno che inizi a insegnarti Python proprio così. Comunque, l’operatore punto è la parte “.” dihello.upper(). Proviamo a dare un esempio più esplicito:

class Persona:    num_di_persone = 0    def __init__(self, nome):        self.nome = nome    def gridare(self):        print(f"Ehi! Sono {self.nome}.")p = Persona('John')p.gridare()# Ehi sono John.p.num_di_persone# 0p.nome# 'John'

Ci sono alcuni luoghi in cui si utilizza l’ “operatore punto”. Per rendere più facile vedere l’immagine generale, riassumiamo il modo in cui lo si utilizza in due casi:

  • lo si utilizza per accedere agli attributi di un oggetto o di una classe,
  • lo si utilizza per accedere alle funzioni definite nella definizione della classe.

Ovviamente, abbiamo tutto questo nel nostro esempio, e questo sembra intuitivo e come previsto. Ma c’è di più di quello che si vede! Date un’occhiata più da vicino a questo esempio:

p.gridare# <bound method Persona.gridare of <__main__.Persona object at 0x1037d3a60>>id(p.gridare)# 4363645248Persona.gridare# <function __main__.Persona.gridare(self)>id(Persona.gridare)# 4364388816

In qualche modo, p.gridare non fa riferimento alla stessa funzione di Persona.gridare anche se dovrebbe. Almeno te te lo aspetteresti, giusto? E p.gridare nemmeno è una funzione! Diamo un’occhiata all’esempio successivo prima di iniziare a discutere di ciò che sta accadendo:

class Persona:    num_di_persone = 0    def __init__(self, nome):        self.nome = nome    def gridare(self):        print(f"Ehi! Sono {self.nome}.")p = Persona('John')vars(p)# {'nome': 'John'}def gridare_v2(self):    print("Ehi, che succede?")p.gridare_v2 = gridare_v2vars(p)# {'nome': 'John', 'gridare_v2': <function __main__.gridare_v2(self)>}p.gridare()# Ehi, Sono John.p.gridare_v2()# TypeError: gridare_v2() missing 1 required positional argument: 'self'

Per coloro che non sono a conoscenza della funzione vars, restituisce il dizionario che contiene gli attributi di un’istanza. Se esegui vars(Persona) otterrai una risposta leggermente diversa, ma capirai la situazione. Ci saranno sia attributi con i loro valori che variabili che contengono le definizioni delle funzioni della classe. C’è ovviamente una differenza tra un oggetto che è un’istanza di una classe e l’oggetto classe stesso, e quindi ci sarà una differenza nella risposta della funzione vars per questi due casi.

Ora, è perfettamente valido definire una funzione aggiuntiva dopo che un oggetto è stato creato. Questa è la linea p.shout_v2 = shout_v2. Questo introduce un’altra coppia chiave-valore nel dizionario dell’istanza. A prima vista sembra tutto bene e saremo in grado di eseguire senza problemi, come se shout_v2 fosse stato specificato nella definizione della classe. Ma ahimè! Qualcosa va veramente storto. Non siamo in grado di chiamarla nello stesso modo in cui abbiamo chiamato il metodo shout.

I lettori astuti avranno notato come io faccia attenzione nell’uso dei termini funzione e metodo. Dopotutto, c’è una differenza anche nella stampa di Python. Dai un’occhiata agli esempi precedenti. shout è un metodo, shout_v2 è una funzione. Almeno se guardiamo da prospettiva dell’oggetto p. Se guardiamo da prospettiva della classe Person, shout è una funzione e shout_v2 non esiste. È definita solo nel dizionario (namespace) dell’oggetto. Quindi, se decidi veramente di fare affidamento sui paradigmi orientati agli oggetti e sui meccanismi come l’incapsulamento, l’ereditarietà, l’astrazione e il polimorfismo, non definirai funzioni sugli oggetti, come accade per p nel nostro esempio. Ti assicurerai invece di definire funzioni nella definizione (corpo) della classe.

Quindi perché queste due cose sono diverse e perché otteniamo l’errore? Beh, la risposta più veloce è a causa di come funziona l’operatore “punto”. La risposta più lunga è che c’è un meccanismo dietro le quinte che fa la risoluzione dei nomi degli attributi per te. Questo meccanismo consiste nei metodi speciali __getattribute__ e __getattr__.

Ottenere gli attributi

All’inizio potrebbe sembrare poco intuitivo e piuttosto complicato, ma segui questo ragionamento. Fondamentalmente, ci sono due scenari che possono verificarsi quando cerchi di accedere a un attributo di un oggetto in Python: o c’è un attributo o non c’è. Semplice. In entrambi i casi, viene chiamato __getattribute__, o per semplificare, viene sempre chiamato. Questo metodo:

  • restituisce il valore dell’attributo calcolato,
  • chiama esplicitamente __getattr__, o
  • solleva AttributeError nel caso in cui __getattr__ venga chiamato per impostazione predefinita.

Se vuoi intercettare il meccanismo che risolve i nomi degli attributi, questo è il punto da cui devi agire. Devi solo fare attenzione, perché è molto facile finire in un ciclo infinito o compromettere l’intero meccanismo della risoluzione dei nomi, specialmente in scenari di ereditarietà orientata agli oggetti. Non è così semplice come potrebbe sembrare.

Se vuoi gestire i casi in cui non c’è un attributo nel dizionario dell’oggetto, puoi implementare direttamente il metodo __getattr__. Questo viene chiamato quando __getattribute__ non riesce ad accedere al nome dell’attributo. Se anche questo metodo non trova un attributo o non riesce ad affrontarne l’assenza, solleva un’eccezione AttributeError. Ecco come puoi giocare con queste funzionalità:

class Person:
    num_of_persons = 0
    def __init__(self, name):
        self.name = name
    
    def shout(self):
        print(f"Ciao! Sono {self.name}.")
            
    def __getattribute__(self, name):
        print(f'Ottenimento dell\'attributo: {name}')
        return super().__getattribute__(name)
        
    def __getattr__(self, name):
        print(f'Questo attributo non esiste: {name}')
        raise AttributeError()

p = Person('John')
p.name  # Ottenimento dell'attributo: name 'John'
p.name1  # Ottenimento dell'attributo: name1
          # Questo attributo non esiste: name1
## ... traccia della pila dell'eccezione
# AttributeError:

È molto importante chiamare super().__getattribute__(...) nella tua implementazione di __getattribute__. La ragione, come ho scritto prima, è che c’è molto in svolgimento nell’implementazione predefinita di Python. Ed è esattamente nel punto in cui l’operatore “punto” ottiene la sua magia. Beh, almeno metà della magia è qui. L’altra parte riguarda come viene creato un oggetto di classe dopo che l’interpretazione della definizione della classe è avvenuta.

Funzioni di classe

Il termine che uso qui è intenzionale. La classe contiene solo funzioni, e abbiamo visto questo in uno degli esempi precedenti:

p.shout# <metodo associato Person.shout di <__main__.Person oggetto a 0x1037d3a60>>Person.shout# <funzione __main__.Person.shout(self)>

Guardando dal punto di vista dell’oggetto, queste sono chiamate metodi. Il processo di trasformazione della funzione di una classe in un metodo di un oggetto è chiamato rilegatura, e il risultato è quello che si vede nell’esempio precedente, un metodo associato. Cosa lo rende associato e a cosa? Beh, una volta che si ha un’istanza di una classe e si inizia a chiamare i suoi metodi, si sta, in sostanza, passando il riferimento dell’oggetto a ciascuno dei suoi metodi. Ricordi l’argomento self? Quindi, come succede questo, e da chi viene fatto?

Bene, la prima parte avviene quando il corpo della classe viene interpretato. Ci sono diverse cose che accadono in questo processo, come la definizione di uno spazio dei nomi della classe, l’aggiunta di valori degli attributi ad esso, la definizione di funzioni (di classe) e la loro rilegatura ai loro nomi. Ora, mentre queste funzioni vengono definite, vengono avvolte in un certo modo. Avvolte in un concetto di oggetto chiamato concettualmente descrittore. Questo descrittore permette questo cambiamento nell’identificazione e nel comportamento delle funzioni di classe che abbiamo visto in precedenza. Mi assicurerò di scrivere un post separato sul blog sugli descrittori, ma per ora, sappi che questo oggetto è un’istanza di una classe che implementa un insieme predefinito di metodi dunder. Questo è anche chiamato Protocollo. Una volta che questi sono implementati, si dice che gli oggetti di questa classe seguono il protocollo specifico e quindi si comportano nel modo previsto. C’è una differenza tra i descrittori dati e non-dati. I primi implementano i metodi dunder __get__, __set__ e/o __delete__. I secondi implementano solo il metodo __get__. Ad ogni modo, ogni funzione in una classe finisce per essere avvolta in un cosiddetto descrittore non-dato.

Una volta che si inizia l’identificazione dell’attributo utilizzando l’operatore “punto”, viene chiamato il metodo __getattribute__ e inizia tutto il processo di risoluzione dei nomi. Questo processo si interrompe quando la risoluzione ha successo, e procede in questo modo:

  1. restituire il descrittore di dati che ha il nome desiderato (livello di classe), o
  2. restituire l’attributo dell’istanza con il nome desiderato (livello di istanza), o
  3. restituire il descrittore non-dato con il nome desiderato (livello di classe), o
  4. restituire l’attributo di classe con il nome desiderato (livello di classe), o
  5. sollevare AttributeError che essenzialmente chiama il metodo __getattr__.

La mia idea iniziale era quella di lasciarti con un riferimento alla documentazione ufficiale su come questo meccanismo è implementato, almeno un esempio Python, per scopi di apprendimento, ma ho deciso di aiutarti anche in questa parte. Tuttavia, ti consiglio vivamente di andare a leggere l’intera pagina della documentazione ufficiale.

Quindi, nel prossimo frammento di codice, metterò alcune descrizioni nei commenti, in modo che sia più facile leggere e capire il codice. Eccolo:

def object_getattribute(obj, name):    "Emula PyObject_GenericGetAttr() in Objects/object.c"    # Crea un oggetto normale per un utilizzo successivo.    null = object()    """    obj è un oggetto istanziato dalla nostra classe personalizzata. Qui    proviamo a trovare il nome della classe da cui è stato istanziato.    """    objtype = type(obj)     """    name rappresenta il nome della funzione di classe, dell'attributo     di istanza o di qualsiasi attributo di classe. Qui cerchiamo di     trovarlo e mantenerne un riferimento. MRO è l'abbreviazione di Method    Resolution Order, e ha a che fare con l'ereditarietà delle classi.    Non davvero importante in questo momento. Diciamo che questo     meccanismo trova il nome in modo ottimale attraverso tutte le     classi genitore.    """    cls_var = find_name_in_mro(objtype, name, null)    """    Qui verifichiamo se questo attributo di classe è un oggetto che ha     il metodo __get__ implementato. Se lo ha, è un descrittore     non-dato. Questo è importante per i passaggi successivi.    """    descr_get = getattr(type(cls_var), '__get__', null)    """    Quindi ora o il nostro attributo di classe fa riferimento a un     descrittore, nel qual caso testiamo se è un descrittore dati e     restituiamo il riferimento al metodo __get__ del descrittore, o     passiamo al blocco di codice successivo.    """    if descr_get is not null:        if (hasattr(type(cls_var), '__set__')            or hasattr(type(cls_var), '__delete__')):            return descr_get(cls_var, obj, objtype)  # descrittore dati    """    Nei casi in cui il nome non faccia riferimento a un descrittore     dati, verifichiamo se fa riferimento alla variabile nel dizionario     dell'oggetto, e in tal caso restituiamo il suo valore.    """    if hasattr(obj, '__dict__') and name in vars(obj):        return vars(obj)[name]  # variabile di istanza    """    Nei casi in cui il nome non faccia riferimento alla variabile nel     dizionario dell'oggetto, cerchiamo di vedere se fa riferimento a un     descrittore non-dato e restituiamo un riferimento ad esso.    """    if descr_get is not null:        return descr_get(cls_var, obj, objtype)  # descrittore non-dato    """    Nel caso in cui il nome non faccia riferimento a nulla di quanto     sopra, cerchiamo di vedere se fa riferimento a un attributo di     classe e restituiamo il suo valore.    """    if cls_var is not null:        return cls_var                                  # variabile di classe    """    Se la risoluzione del nome non ha avuto successo, solleviamo una     eccezione AttriuteError, e viene invocato __getattr__.    """    raise AttributeError(name)

È importante tenere presente che questa implementazione è in Python solo per scopi di documentazione e descrizione della logica implementata nel metodo __getattribute__. In realtà, è implementato in C. Solo guardandolo, si può immaginare che sia meglio non giocare con la reimplementazione dell’intero processo. Il modo migliore è provare a fare parte della risoluzione da soli e poi ricorrere all’implementazione di CPython con return super().__getattribute__(name) come mostrato nell’esempio sopra.

L’importante qui è che ogni funzione della classe (che è un oggetto) viene incapsulata in un descrittore non dati (che è un oggetto della classe function), e questo significa che questo oggetto wrapper ha il metodo dunder __get__ definito. Ciò che fa questo metodo dunder è restituire un nuovo oggetto chiamabile (pensaci come una nuova funzione), in cui il primo argomento è il riferimento all’oggetto su cui stiamo eseguendo l’operatore “punto”. Ho detto di pensare ad esso come a una nuova funzione poiché è chiamabile. In sostanza, è un altro oggetto chiamato MethodType. Dagli un’occhiata:

type(p.shout)# ottenere il nome dell'attributo: shout# methodtype(Person.shout)# funzione

Una cosa interessante è certamente questa classe function. Questa è esattamente l’oggetto wrapper che definisce il metodo __get__. Tuttavia, una volta che cerchiamo di accedervi come metodo shout tramite l’operatore “punto”, __getattribute__ itera attraverso la lista e si ferma al terzo caso (restituzione di un descrittore non dati). Questo metodo __get__ contiene una logica aggiuntiva che prende il riferimento dell’oggetto e crea un MethodType con il riferimento alla function e all’oggetto.

Ecco il mockup ufficiale della documentazione:

class Function:    ...    def __get__(self, obj, objtype=None):        if obj is None:            return self        return MethodType(self, obj)

Disregardate la differenza nel nome delle classi. Ho usato function invece di Function per renderlo più comprensibile, ma userò il nome Function a partire da ora in modo da seguire l’esplicazione della documentazione ufficiale.

Comunque, solo guardando questo mockup, potrebbe essere sufficiente per capire come questa classe function si inserisce nel contesto, ma permettetemi di aggiungere un paio di righe di codice mancanti che probabilmente renderanno le cose ancora più chiare. Aggiungerò altri due metodi di classe in questo esempio, ossia:

class Function:    ...    def __init__(self, fun, *args, **kwargs):        ...        self.fun = fun    def __get__(self, obj, objtype=None):        if obj is None:            return self        return MethodType(self, obj)    def __call__(self, *args, **kwargs):        ...        return self.fun(*args, **kwargs)

Perché ho aggiunto questi metodi? Beh, ora puoi immaginare facilmente come l’oggetto Function svolga il suo ruolo in tutto questo scenario di collegamento dei metodi. Questo nuovo oggetto Function memorizza la funzione originale come attributo. Questo oggetto è anche chiamabile, il che significa che possiamo invocarlo come una funzione. In quel caso, funziona esattamente come la funzione che incapsula. Ricorda, tutto in Python è un oggetto, persino le funzioni. E MethodType ‘incapsula’ l’oggetto Function insieme al riferimento all’oggetto su cui stiamo chiamando il metodo (nel nostro caso shout).

Come fa MethodType a fare ciò? Beh, conserva questi riferimenti e implementa un protocollo chiamabile. Ecco il mockup ufficiale della classe MethodType:

class MethodType:    def __init__(self, func, obj):        self.__func__ = func        self.__self__ = obj    def __call__(self, *args, **kwargs):        func = self.__func__        obj = self.__self__        return func(obj, *args, **kwargs)

Di nuovo, per brevità, func finisce per fare riferimento alla nostra iniziale funzione di classe (shout), obj fa riferimento all’istanza (p), e poi abbiamo argomenti e argomenti con nome che vengono passati insieme. self nella dichiarazione di shout finisce per fare riferimento a questo ‘obj’, che è essenzialmente p nel nostro esempio.

Alla fine dovrebbe essere chiaro perché facciamo una distinzione tra funzioni e metodi e come le funzioni vengono associate una volta che vengono accedute attraverso gli oggetti utilizzando l’operatore “punto”. Se ci pensiamo, saremmo perfettamente d’accordo nell’invo

Un’altra cosa! Se ti piace il modo in cui spiego le cose e c’è qualcosa di avanzato nel mondo di Python di cui vorresti leggere, dì la tua!

Articoli precedenti nella serie di Python avanzato:

Python avanzato: Funzioni

Dopo aver letto il titolo, probabilmente ti stai chiedendo qualcosa del tipo: “Le funzioni in Python sono un argomento avanzato …

towardsdatascience.com

Python avanzato: Metaclassi

Breve introduzione all’oggetto classe di Python e come viene creato

towardsdatascience.com

Riferimenti