18. Métaclasses

type(), metaclass=, __new__ de métaclasse, cas concrets : singleton, registre, validation de classe, DSL.


18.1 Principe

Une métaclasse est la classe d’une classe. type est la métaclasse par défaut.

class MaClasse:
    pass
 
# Équivaut à
MaClasse = type("MaClasse", (), {})

Hiérarchie :

type  →  métaclasse (la classe des classes)
  ↓
MaClasse  →  classe
  ↓
obj  →  instance

18.2 type(name, bases, dict)

# Création dynamique de classe
MaClasse = type("MaClasse", (object,), {"x": 10, "f": lambda self: self.x})
obj = MaClasse()
obj.f()  # 10

18.3 Métaclasse personnalisée

Une métaclasse hérite de type et surcharge __new__ :

class MaMéta(type):
    def __new__(mcs, name, bases, namespace, **kwargs):
        print(f"Création de {name}")
        # namespace = dict des attributs et méthodes
        namespace["créé_par"] = mcs.__name__
        return super().__new__(mcs, name, bases, namespace)
 
class MaClasse(metaclass=MaMéta):
    pass
# Création de MaClasse
 
MaClasse.créé_par  # "MaMéta"

__new__ vs __init__ dans une métaclasse

class MaMéta(type):
    def __new__(mcs, name, bases, namespace):
        print(f"__new__: {name}")
        cls = super().__new__(mcs, name, bases, namespace)
        return cls
 
    def __init__(cls, name, bases, namespace, **kwargs):
        print(f"__init__: {name}")
        super().__init__(name, bases, namespace)
  • __new__ : avant la création de la classe (modifier le namespace)
  • __init__ : après la création (initialiser la classe)

__call__ : contrôle des instances

class SingletonMeta(type):
    _instances = {}
 
    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]
 
class Configuration(metaclass=SingletonMeta):
    def __init__(self):
        self.paramètres = {}
 
c1 = Configuration()
c2 = Configuration()
c1 is c2  # True

18.4 Cas concrets

1. Validation de classe

Garantir qu’une classe implémente certaines méthodes :

class InterfaceMeta(type):
    def __new__(mcs, name, bases, namespace):
        if name != "PluginBase":
            obligatoires = ["exécuter", "nom"]
            for attr in obligatoires:
                if attr not in namespace:
                    raise TypeError(
                        f"{name} doit implémenter {attr}"
                    )
        return super().__new__(mcs, name, bases, namespace)
 
class PluginBase(metaclass=InterfaceMeta):
    pass
 
class MonPlugin(PluginBase):
    nom = "mon-plugin"
    def exécuter(self):
        print("Exécution")
 
# class MauvaisPlugin(PluginBase):  # TypeError !
#     pass

2. Registre automatique

class RegistreMeta(type):
    registry = {}
 
    def __new__(mcs, name, bases, namespace):
        cls = super().__new__(mcs, name, bases, namespace)
        if name != "OperationBase":
            RegistreMeta.registry[name] = cls
        return cls
 
class OperationBase(metaclass=RegistreMeta):
    def apply(self, x): ...
 
class Addition(OperationBase):
    def apply(self, x, y): return x + y
 
class Multiplication(OperationBase):
    def apply(self, x, y): return x * y
 
print(RegistreMeta.registry)
# {'Addition': <class Addition>, 'Multiplication': <class Multiplication>}

3. Ajout d’attributs automatique

class AutoProps(type):
    def __new__(mcs, name, bases, namespace):
        annotations = namespace.get("__annotations__", {})
        for attr_name, attr_type in annotations.items():
            if attr_name.startswith("_") or attr_name in namespace:
                continue
            # Crée des accesseurs automatiques
            namespace[attr_name] = property(
                lambda self, n=attr_name: getattr(self, f"_{n}"),
                lambda self, val, n=attr_name: setattr(self, f"_{n}", val),
            )
        return super().__new__(mcs, name, bases, namespace)
 
class Point(metaclass=AutoProps):
    x: float
    y: float
 
    def __init__(self, x, y):
        self._x = x
        self._y = y
 
p = Point(1, 2)
print(p.x)  # 1
p.y = 5

4. DSL (Domain Specific Language)

class DSLMeta(type):
    def __new__(mcs, name, bases, namespace):
        # Convertit les méthodes en commandes enregistrées
        cls = super().__new__(mcs, name, bases, namespace)
        cls._commandes = {}
        for attr_name, attr_val in namespace.items():
            if callable(attr_val) and not attr_name.startswith("_"):
                cls._commandes[attr_name] = attr_val
        return cls
 
    def __call__(cls, *args, **kwargs):
        instance = super().__call__(*args, **kwargs)
        return instance
 
class Script(metaclass=DSLMeta):
    pass
 
class MonScript(Script):
    def saluer(self, nom):
        print(f"Bonjour {nom}")
 
    def calculer(self, x, y):
        return x + y
 
# MonScript._commandes = {"saluer": ..., "calculer": ...}

18.5 Métaclasse + __init_subclass__

Alternative plus simple aux métaclasses (PEP 487, 3.6+) :

class PluginBase:
    registry = {}
 
    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        PluginBase.registry[cls.__name__] = cls
 
class MonPlugin(PluginBase):
    pass

Quand utiliser __init_subclass__ vs métaclasse ?

__init_subclass__Métaclasse
Simple hook à la créationContrôle total de la création
Pas de conflit si plusieurs héritagesRisque de conflit entre métaclasses
Suffisant pour registre/validationNécessaire pour DSL, __call__, __new__ avancé

18.6 __prepare__ — contrôler le namespace

class OrdonnéMeta(type):
    @classmethod
    def __prepare__(mcs, name, bases, **kwargs):
        return {}  # dict normal, ou OrderedDict pour préserver l'ordre
 
class Exemple(metaclass=OrdonnéMeta):
    a = 1
    b = 2

18.7 Métaclasse pour __slots__ automatique

class SlotsMeta(type):
    def __new__(mcs, name, bases, namespace):
        annotations = namespace.get("__annotations__", {})
        if "__slots__" not in namespace and not any(
            hasattr(b, "__slots__") for b in bases
        ):
            namespace["__slots__"] = tuple(annotations.keys())
        return super().__new__(mcs, name, bases, namespace)
 
class Point(metaclass=SlotsMeta):
    x: float
    y: float
 
    def __init__(self, x, y):
        self.x = x
        self.y = y
 
# p = Point(1, 2)
# p.z = 3  # AttributeError

18.8 Avertissements

  • Les métaclasses créent une complexité cognitive élevée
  • Une métaclasse peut entrer en conflit avec une autre (solution : préférer __init_subclass__)
  • 90% des cas d’usage sont couverts par __init_subclass__, les décorateurs de classe, ou __set_name__
  • N’utiliser une métaclasse que quand les alternatives échouent

🔗 ← Retour au cours · ← précédent · Suivant →