Steffo's website

what a year, huh?

Guide - Metaclassi in Python

posted on ; updated on

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:

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 Python

In 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 iniziale cls che 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 di cls, __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:

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):
    ...