Metaclassi in Python
In Python, tutto è un oggetto, incluse le classi stesse.
Infatti, possiamo vedere che le classi sono istanze della classe speciale type:
class Something:
pass
assert isinstance(Something, type)
Quando non bastano i @classmethod
Come veri e propri oggetti, le classi possono avere attributi e in generale comportarsi da oggetti loro stesse, e anche avere propri metodi attraverso il decoratore @classmethod:
class Something:
abc: int = 123
@classmethod
def get_abc(cls) -> int:
return cls.abc
assert Something.abc == 123
assert Something.get_abc() == 123
assert str(Something) == "<class '__main__.Something'>"
assert type(Something) == type
Non sempre però questo basta per ottenere le funzionalità desiderate.
Ad esempio, @classmethod non è sufficiente per permettere a due classi di essere sommate tra di loro con l'operatore + (metodo __add__) senza crearne un'istanza:
class Number:
value: int = 0
@classmethod
def get_value(cls) -> int:
return self.cls
@classmethod # Non funziona!
def __add__(cls, other):
if issubclass(other, Number):
return cls.value + other.value
else:
return cls.value + other
class One(Number):
value = 1
class Two(Number):
value = 2
assert One.get_value() == 1
assert Two.get_value() == 2
assert One + Two == 3
# Traceback (most recent call last): # File "<python-input>", ...
# assert One + Two == 3 # ~~~~^~~~~ # TypeError: unsupported operand type(s) for +: 'type' and 'type'
In tal caso, dobbiamo andare a modificare il tipo della classe stessa, e poi usare quel nuovo tipo per creare la classe.
Possiamo farlo creando una nuova classe che eredita da type, definendoci i metodi desiderati sopra come metodi di istanza, e creando le nuove classi specificando il parametro metaclass=... dove specificheremmo l'ereditarietà:
class Number(type):
# Entrambi i metodi non hanno @classmethod
# Vengono chiamati sulle *istanze* di questo tipo
# Ovvero le *classi create con esso*
def get_value(self) -> int:
return self.value
def __add__(self, other):
if isinstance(other, Number): # Cambiato issubclass a isinstance
return self.value + other.value
else:
return self.value + other
class One(metaclass=Number):
value = 1
class Two(metaclass=Number):
value = 2
assert One.get_value() == 1
assert Two.get_value() == 2
assert One + Two == 3
assert type(One) == Number
assert type(Number) == type
Modificare __init__ di una metaclasse
Con questo sistema possiamo quindi modificare i metodi speciali (doppio underscore) delle classi stesse. E questo include __init__!
Ad esso, vengono automaticamente passati tre parametri, di cui possiamo fare uso nella chiamata:
name, unastrcontenente il nome della classe creata;bases, unatuplecontenente tutte le classi da cui quella creata eredita;namespace, undictche associa i nomi degli attributi di classe ai loro valori, un po' come falocals()con le variabili locali.
Ad esempio, possiamo fare in modo che, quando viene creata una classe usando la nostra metaclasse, venga stampato un messaggio contenente le tutte le sue informazioni:
from typing import Any
class NotifyType(type):
def __init__(
self,
name: str,
bases: tuple[type, ...],
namespace: dict[str, Any],
):
super().__init__(name, bases, namespace)
print(f"A new class was just created!")
print(f"Its name is: {name!r}")
print(f"It inherits from: {bases!r}")
print(f"It defines these attributes and methods: {namespace!r}")
class Example(metaclass=NotifyType):
VARIABLE = 1234
##### OUTPUT #####
# A new class was just created!
# Its name is: 'Example'
# It inherits from: ()
# It defines these attributes and methods: {'__module__': '__main__', '__qualname__': 'Example', '__firstlineno__': 16, 'VARIABLE': 1234, '__static_attributes__': ()}
##### FINE #####
Essendo la classe già stata creata quando viene chiamato __init__, non possiamo però modificare quei parametri, solo farne uso.
Modificare __new__ di una metaclasse
Per modificare quei parametri, dobbiamo agire un passo prima, prima che la classe venga creata.
Abbiamo visto che creare una classe corrisponde a creare un'istanza della sua metaclasse, e ci ricordiamo che, per tutti i tipi di Python, la creazione delle istanze di una classe avviene nel metodo __new__.
Possiamo allora cambiare come funziona definire una nuova classe modificando il metodo __new__ della sua metaclasse!
Funzionamento generale di
__new__in PythonIn tutte le classi di Python (non solo le metaclassi)
__new__è un metodo che:
- riceve gli stessi parametri che vengono passati ad
__init__, più un parametro inizialeclsche corrisponde alla classe a cui appartiene il metodo, come se fosse un@classmethod;- restituisce il valore che sarà considerato il risultato del costruttore;
- determina se deve essere successivamente chiamato
__init__: se il valore restituito non è un'istanza dicls,__init__non viene chiamato.È molto raro che sia necessario sovrascriverlo: ciò solitamente succede quando si vuole modificare il comportamento di built-in di Python, come, nel caso delle metaclassi, il built-in
type.
Ad esempio, possiamo fare in modo che una classe erediti automaticamente dall'ultima classe che è stata creata, realizzando così una catena "implicita" di classi:
from typing import Self, Any
class ChainType(type):
previously_created_class = None def __new__(
cls: type[Self],
name: str,
bases: tuple[type, ...],
namespace: dict[str, Any],
) -> Self:
# Aggiungiamo l'ultima classe creata a `bases`
if cls.previously_created_class != None:
bases = (*bases, cls.previously_created_class)
# Creiamo effettivamente la classe con `bases` modificato
instance = super().__new__(cls, name, bases, namespace)
# Aggiorniamo l'ultima classe creata
cls.previously_created_class = instance
# Restituiamo la classe creata
return instance
class A(metaclass=ChainType):
is_letter = True
class B(metaclass=ChainType):
pass
class C(metaclass=ChainType):
pass
assert issubclass(B, A)
assert issubclass(C, B)
assert issubclass(C, A)
assert A.is_letter is True
assert B.is_letter is True
assert C.is_letter is True
Oppure, se vogliamo confondere un nostro collega non creando proprio la classe che ha definito:
class Lol(type):
def __new__(cls, name, bases, namespace):
return None
class Haha(metaclass=Lol):
pass
assert Haha is None
Passare keyword arguments a __new__
Per funzionalità più avanzate, possiamo specificare dopo metaclass=... una serie di keyword arguments che verranno passati a __new__.
Ad esempio, possiamo fare in modo che vengano automaticamente definiti tanti attributi di classe con uno specifico valore:
from typing import Iterable, Any
class Definer(type):
def __new__(cls, name, bases, namespace, *, keys: Iterable[str], value: Any):
for key in keys:
namespace[key] = value
return super().__new__(cls, name, bases, namespace)
class OopsAllSixes(metaclass=Definer, keys=("A", "B", "C", "D", "E", "F"), value=6):
pass
assert OopsAllSixes.A == 6
assert OopsAllSixes.B == 6
assert OopsAllSixes.C == 6
assert OopsAllSixes.D == 6
assert OopsAllSixes.E == 6
assert OopsAllSixes.F == 6
Chiamare manualmente il costruttore delle metaclassi
Possiamo accorgerci che possiamo chiamare il metodo __new__ e __init__ di una metaclasse come un normale costruttore di un'istanza, creando però una nuova classe.
Ovviamente, è necessario fornirgli i parametri appropriati:
class Meta(type):
def __new__(cls, name, bases, namespace):
return super().__new__(cls, name, bases, namespace)
Class = Meta("Class", (), {"HAD_ICECREAM": True})
assert isinstance(Class, Meta)
assert Class.HAD_ICECREAM is True
Possiamo intuire allora che la definizione di una classe è solo una sintassi alternativa per questa chiamata:
class Meta(type):
def __new__(cls, name, bases, namespace):
return super().__new__(cls, name, bases, namespace)
class Class(metaclass=Meta):
HAD_ICECREAM = True
Visto che tutte le metaclassi ereditano da type stessa, anch'essa deve supportare la stessa sintassi:
Class = type("Class", (), {"HAD_PIZZA": True})
assert isinstance(Class, type)
assert Class.HAD_PIZZA is True
E allora possiamo capire che la metaclasse "di default" di tutte le classi è type:
class Class(metaclass=type):
pass
Hai probabilmente usato metaclassi senza saperlo
Due casi molto comuni si usano metaclassi sono:
quando si dichiarano enum (
Enum), il cui tipo è definito con la metaclasseEnumType, che modifica come vengono interpretati gli attributi della classe definita;quando si dichiarano abstract base classes (
ABC), il cui tipo è definito con la metaclasseABCMeta, che verifica al momento di creazione di una nuova classe che non siano rimasti metodi taggati con@abstractmethod.
Solitamente, però, non lo si nota: quando si eredita da una classe che usa una certa metaclasse, è implicito che anche la nuova classe farà uso di quella metaclasse:
from enum import Enum, EnumType, auto
class Fruit(Enum):
APPLE = auto()
PEAR = auto()
BANANA = auto()
assert isinstance(Fruit, EnumType)
Conflitto di metaclassi
Questa relazione implicita però diventa un problema nel momento in cui l'ereditarietà diventa multipla, ad esempio se si cerca di creare un enum astratto, perchè non è più possibile determinare quale metaclasse deve essere usata per creare la classe:
from enum import Enum
from abc import ABC, abstractmethod
class AbstractEnum(ABC, Enum):
@abstractmethod
def print_something(self):
raise NotImplementedError()
# Cosa deve chiamare l'interprete per creare la classe?
# AbstractEnum = ABC("AbstractEnum", ...)
# oppure
# AbstractEnum = EnumType("AbstractEnum", ...)
# ?
#
# Traceback (most recent call last):
# File "<python-input>", ...
# class AbstractEnum(ABC, Enum):
# ...<2 lines>...
# raise NotImplementedError()
# TypeError: metaclass conflict: the metaclass of a derived class must be a (non-strict) subclass of the metaclasses of all its bases
Una possibile soluzione è quella di creare una propria metaclasse che erediti da entrambe le metaclassi e faccia quello che si desidera:
from enum import EnumType
from abc import ABCMeta, abstractmethod
# Quale dei due __new__ chiamo prima? Quello di ABCMeta, o quello di EnumType?
class AbstractEnumType(ABCMeta, EnumType):
...
class EnumAbstractType(EnumType, ABCMeta):
...