Python avanzato Metaclassi

Python avanzato Metaclassi di livello avanzato

Una breve introduzione a Python class object e come viene creato.

Come Atlante è per i cieli, le metaclassi sono per le classi. Foto di Alexander Nikitenko su Unsplash

Questo articolo continua la serie Advanced Python (precedente articolo sulle funzioni in Python). Questa volta, copro una introduzione alle metaclassi. Il tema è piuttosto avanzato perché raramente c’è bisogno per l’ingegnere di implementare metaclassi personalizzate. Tuttavia, è una delle costruzioni e meccanismi più importanti che ogni sviluppatore Python competente dovrebbe conoscere, principalmente perché consente il paradigma OOP.

Dopo aver compreso l’idea dietro le metaclassi e come vengono create le classi, sarai in grado di continuare ad apprendere i principi OOP di encapsulation, abstraction, inheritance e polymorphism. Sarai quindi in grado di capire come applicare tutto ciò attraverso numerosi pattern di design guidati da alcuni dei principi dell’ingegneria del software (ad esempio, SOLID).

Ora, cominciamo con questo esempio apparentemente banale:

class Persona:    passclass Bambino(Persona):    passbambino = Bambino()

Quando hai imparato la programmazione orientata agli oggetti, probabilmente sei incappato in un’idea generale che descrive cosa sono classi e oggetti, ed è così:

“Una classe è come uno stampo per biscotti. E gli oggetti sono biscotti modellati da esso”.

Questa è una spiegazione molto intuitiva e trasmette chiaramente l’idea. Detto questo, il nostro esempio definisce due modelli con poche o nessuna funzionalità, ma funzionano. Puoi sperimentare definendo il metodo __init__, impostare alcuni attributi dell’oggetto e renderlo più utilizzabile.

Tuttavia, ciò che è interessante in Python è che anche se una classe è un “template” che viene utilizzato per creare oggetti da essa, è anche un oggetto stesso. Tutti coloro che apprendono l’OOP in Python passeranno velocemente su questa affermazione, senza pensarci realmente in profondità. Tutto in Python è un oggetto, e quindi? Ma una volta che inizi a riflettere su questo, sorgono molte domande e si svelano interessanti peculiarità di Python.

Prima di iniziare a porsi queste domande per te, ricordiamoci che in Python, tutto è un oggetto. E intendo tutto. Questo è probabilmente qualcosa che hai già intuito, anche se sei un principiante. L’esempio successivo lo dimostra:

class Persona:    passid(Persona) # qualche posizione di memoriaclass Bambino(Persona):    passid(Bambino)# qualche posizione di memoria# Gli oggetti di classe vengono creati, puoi istanziare oggettibambino = Bambino()id(bambino)# qualche posizione di memoria

Sulla base di questi esempi, ecco alcune domande che dovresti porsi:

  • Se una classe è un oggetto, quando viene creata?
  • Chi crea gli oggetti di classe?
  • Se una classe è un oggetto, come mai riesco a chiamarla quando istanzio un oggetto?

Creazione di oggetti di classe

Python è ampiamente conosciuto come un linguaggio interpretato. Ciò significa che c’è un interprete (programma o processo) che va riga per riga e cerca di tradurlo in codice macchina. Questo è opposto ai linguaggi di programmazione compilati come C, dove il codice di programmazione viene tradotto in codice macchina prima di eseguirlo. Questa è una visione molto semplificata. Per essere più precisi, Python è sia compilato che interpretato, ma questa è un argomento per un’altra volta. Quello che è importante per il nostro esempio è che l’interprete scorre la definizione della classe e una volta che il blocco di codice della classe è terminato, viene creata l’oggetto di classe. Da quel momento in poi, puoi istanziare degli oggetti da esso. Devi farlo esplicitamente, ovviamente, anche se gli oggetti di classe vengono istanziati implicitamente.

Ma quale “processo” viene innescato quando l’interprete finisce di leggere il blocco di codice della classe? Potremmo andare direttamente ai dettagli, ma un grafico parla più di mille parole:

Come oggetti, classi e metaclassi sono correlati tra loro. Immagine di Ilija Lazarevic.

Se non lo sai, Python ha delle funzioni type che possono essere utilizzate per i nostri scopi ora. Chiamando type con l’oggetto come argomento, otterrai il tipo dell’oggetto. Che genialità! Dai un’occhiata:

class Persona:    passclass Bambino(Persona):    passbambino = Bambino()type(bambino)# Bambinotype(Bambino)# type

La chiamata a type nell’esempio ha senso. bambino è di tipo Bambino. Abbiamo usato un oggetto di classe per crearlo. Quindi in qualche modo, puoi pensare a type(bambino) come al nome del suo “creatore”. E in un certo senso, la classe Bambino è il suo creatore perché l’hai chiamata per creare una nuova istanza. Ma cosa succede quando provi a ottenere il “creatore” dell’oggetto di classe, type(Bambino)? Ottieni il type. Riassumendo, un oggetto è un’istanza di una classe e una classe è un’istanza di un tipo. A questo punto, potresti chiederti come una classe possa essere un’istanza di una funzione, e la risposta è che type è sia una funzione che una classe. Questo è stato intenzionalmente lasciato così per garantire la compatibilità con le versioni precedenti.

Ciò che farà girare la testa è il nome che abbiamo per la classe usata per creare un oggetto di classe. Si chiama metaclassa. Ed è importante fare una distinzione tra l’ereditarietà dal punto di vista del paradigma orientato agli oggetti e i meccanismi di un linguaggio che ti consentono di praticare tale paradigma. Le metaclassi forniscono questo meccanismo. Ciò che può essere ancora più confuso è che le metaclassi possono ereditare classi genitori proprio come le classi regolari possono fare. Ma questo può diventare rapidamente una programmazione “in inception”, quindi non approfondiamolo.

Dobbiamo occuparci di queste metaclassi quotidianamente? Beh, no. Nei casi rari, probabilmente avrai bisogno di definirle e usarle, ma nella maggior parte dei casi, il comportamento predefinito va benissimo.

Continuiamo il nostro percorso, questa volta con un nuovo esempio:

class Genitore:    def __init__(self, nome, eta):        self.nome = nome        self.eta = etagenitore = Genitore('John', 35)

Qualcosa del genere dovrebbe essere il tuo primo passo OOP in Python. Ti viene insegnato che __init__ è un costruttore in cui imposti i valori degli attributi dell’oggetto e sei pronto. Tuttavia, questo metodo dunder __init__ è esattamente ciò che dice: il passo di inizializzazione. Non è strano che lo chiami per inizializzare un oggetto eppure ottieni un’istanza di un’oggetto in cambio? Non c’è return lì, giusto? Quindi, come è possibile? Chi restituisce l’istanza di una classe?

Molto pochi apprendono all’inizio del loro percorso Python che c’è un altro metodo che viene chiamato implicitamente e si chiama __new__. Questo metodo crea effettivamente un’istanza prima che __init__ venga chiamato per inizializzarla. Ecco un esempio:

class Genitore:    def __new__(cls, nome, eta):        print('viene chiamato il new')        return super().__new__(cls)    def __init__(self, nome, eta):        print('viene chiamato l\'init')        self.nome = nome        self.eta = etagenitore = Genitore('John', 35)# viene chiamato il new# viene chiamato l'init

Quello che vedrai immediatamente è che __new__ restituisce super().__new__(cls). Questa è una nuova istanza. super() recupera la classe genitore di Genitore, che è implicitamente una classe object. Questa classe viene ereditata da tutte le classi in Python. Ed è anche un oggetto di per sé. Un’altra mossa innovativa dei creatori di Python!

isinstance (oggetto, oggetto) # True

Ma cosa lega __new__ e __init__? Deve esserci qualcosa di più su come viene eseguita l’istanza degli oggetti quando chiamiamo Parent('John', 35). Guardateci ancora una volta. Stai invocando (chiamando) un oggetto di classe, come una funzione.

Callable di Python

Python, essendo un linguaggio tipizzato strutturalmente, consente di definire metodi specifici nella tua classe che descrivono un Protocollo (un modo di usare il suo oggetto) e in base a questo, tutte le istanze di una classe si comporteranno nel modo previsto. Non intimidirti se provieni da altri linguaggi di programmazione. I protocolli sono qualcosa di simile alle interfacce in altri linguaggi. Tuttavia, qui non dichiariamo esplicitamente che stiamo implementando un’interfaccia specifica e, quindi, un comportamento specifico. Implementiamo solo i metodi che sono descritti dal Protocollo, e tutti gli oggetti avranno il comportamento del protocollo. Uno di questi protocolli è Callable. Implementando __call__ come metodo speciale, consenti al tuo oggetto di essere chiamato come una funzione. Guarda l’esempio:

class Parent: def __new__ (cls, nome, età): print ('nuovo è chiamato') return super (). __new__ (cls) def __init __ (self, nome, età): print ('init è chiamato') self.nome = nome self.età = età def __call __ (self): print ('Genitore qui! ') parent = Parent ('John', 35) parent () # Parent here!  

Implementando __call__ nella definizione della classe, le istanze della tua classe diventano chiamabili. Ma cosa succede con Parent ('John', 35)? Come puoi ottenere lo stesso risultato con il tuo oggetto di classe? Se la definizione del tipo di oggetto (classe) specifica che l’oggetto è chiamabile, allora il tipo di oggetto della classe (tipo cioè metaclass) dovrebbe specificare che l’oggetto di classe è chiamabile anche, giusto? L’invocazione dei metodi dunder __new__ e __init__ avviene lì.

A questo punto, è il momento di iniziare a giocare con le metaclassi.

Metaclassi Python

Esistono almeno due modi per modificare il processo di creazione dell’oggetto di classe. Uno è utilizzando i decoratori di classe; l’altro è specificando esplicitamente una metalcass. Descriverò l’approccio delle metaclassi. Tieni presente che una metaclass sembra una classe normale e l’unico eccezione è che deve ereditare una classe di tipo. Perché? Perché le classi di tipo hanno tutta l’implementazione necessaria affinché il nostro codice funzioni ancora come previsto. Ad esempio:

class MyMeta (type): def __call __ (self, * args, ** kwargs): print (f' {self.__name__} è chiamato ' f'con args = {args}, kwargs = {kwarg}') class Genitore (metaclass = MyMeta): def __new __ (cls, nome, età): print ('nuovo è chiamato') return super (). __new__ ( cls) def __init __ (self, nome, età): print ('init è chiamato') self.nome = nome self.età = età parent = Parent ('John', 35) # Parent is called with args = ('John', 35), kwargs = {} type (parent) # NoneType  

Qui, MyMeta è la forza trainante dietro la creazione di un nuovo oggetto di classe e specifica anche come vengono creati nuovi istanze della classe. Dai un’occhiata più da vicino alle ultime due righe dell’esempio. parent non contiene nulla! Ma perché? Perché, come puoi vedere, MyMeta.__call__ stampa solo informazioni e non restituisce nulla. Esplicitamente, intendo. Implicitamente, questo significa che restituisce None, che è del tipo NoneType.

Come possiamo risolvere questo?

class MyMeta (type): def __call __ (cls, * args, ** kwargs): print (f' {cls.__name__} è chiamato ' f'con args = {args}, kwargs = {kwargs}') print ('metaciclo chiama __new__') obj = cls.__new__ (cls, * args, ** kwargs) if isinstance (obj, cls): print ('metaciclo chiama __init__') cls.__init__ (obj, * args, ** kwargs) return obj class Parent (metaclass = MyMeta): def __new__ (cls, nome, età): print ('nuovo è chiamato') return super (). __new__ (cls) def __init__ (self, nome, età): print ('init è chiamato') self.nome = nome self.età = età parent = Parent ('John', 35) # Parent is called with args = ('John', 35), kwargs = {} # metaciclo chiamate __new__ # nuovo è chiamato # metaciclo chiama __init__ # init è chiamato type (parent) # Parentstr (parent) # '<__main__.Parent oggetto a 0x103d540a0> ' 

Dall’output, puoi vedere cosa succede all’invocazione di MyMeta.__call__. L’implementazione fornita è solo un esempio di come funziona l’intero processo. Dovresti fare attenzione se hai intenzione di ridefinire parti di metaclassi tu stesso. Ci sono alcuni casi limite che devi considerare. Ad esempio, uno dei casi limite è che Parent.__new__ può restituire un oggetto che non è un’istanza della classe Parent. In quel caso, non verrà inizializzato dal metodo Parent.__init__. Questo è il comportamento atteso di cui devi essere consapevole e non ha senso inizializzare un oggetto che non è un’istanza della stessa classe.

Conclusioni

Questo concluderebbe una breve panoramica di cosa succede quando si definisce una classe e si crea un’istanza di essa. Naturalmente, potresti andare ancora oltre e vedere cosa succede durante l’interpretazione del blocco della classe. Tutto questo succede anche nella metaclass. È fortunato per la maggior parte di noi che probabilmente non avremo bisogno di creare e utilizzare metaclassi specifici. Tuttavia, è utile capire come funziona tutto. Mi piacerebbe usare un detto simile che si applica all’uso dei database NoSQL, che dice più o meno così: se non sei sicuro se devi utilizzare le metaclassi Python, probabilmente non devi farlo.

Riferimenti