Retour au cours

backend / python

Métaclasses et descriptors

Leçon 231 exercice

Explication

Ce que vous allez apprendre

  • Comprendre pourquoi une classe est elle-même une instance de type en Python
  • Écrire une métaclasse simple et savoir quand __init_subclass__ suffit à sa place
  • Comprendre le protocole __get__/__set__ qui se cache derrière @property
  • Créer un descriptor réutilisable pour valider une valeur sur plusieurs attributs
  • Utiliser __slots__ pour réduire l'empreinte mémoire d'une classe très instanciée

Dans quel contexte ?

Un développeur travaille sur un ORM interne et veut qu'une nouvelle classe de modèle (class Utilisateur(Modele)) s'enregistre automatiquement dans un registre global dès sa définition, sans que personne n'ait besoin d'appeler une fonction manuellement. Deux options existent : une métaclasse personnalisée, ou la solution plus simple __init_subclass__ disponible depuis Python 3.6. Cette leçon explique les deux, et pourquoi la seconde suffit dans la grande majorité des cas réels.

Une classe est elle-même un objet

Voici l'idée la plus vertigineuse de Python : absolument tout est un objet, y compris les classes elles-mêmes. Une classe comme MaClasse est une instance d'un type appelé type. Si les instances ordinaires sont fabriquées par leur classe, les classes elles-mêmes sont fabriquées par leur métaclasse, qui vaut type par défaut. Une métaclasse personnalisée permet donc d'intervenir sur la façon dont une classe est construite, avant même qu'une seule instance existe.

Prérequis

Cette leçon suppose une bonne maîtrise de la POO (classes, héritage, @property) vue dans les leçons précédentes. Les métaclasses sont un sujet avancé qui n'a de sens qu'une fois ces bases solidement acquises.

Un outil puissant, à manier avec parcimonie

Les métaclasses sont rarement nécessaires en pratique : la grande majorité des besoins qu'on pourrait résoudre avec une métaclasse (comme l'auto-enregistrement de sous-classes) trouvent une solution plus simple avec __init_subclass__, disponible depuis Python 3.6. Il vaut mieux connaître les métaclasses pour comprendre du code existant que pour en écrire soi-même sans réelle nécessité.

Ce qui se cache réellement derrière @property

Les descriptors répondent à une question précise : comment @property sait-elle intercepter la lecture et l'écriture d'un attribut ? La réponse est un protocole à base de __get__ et __set__, que n'importe quelle classe peut implémenter pour créer un attribut intelligent et réutilisable entre plusieurs classes, comme le validateur Positif de cette leçon.

Un gain mémoire concret pour finir

__slots__ empêche une instance de stocker ses attributs dans un dictionnaire interne (__dict__), ce qui réduit sensiblement l'empreinte mémoire quand on crée un très grand nombre d'instances, au prix de ne plus pouvoir ajouter dynamiquement de nouveaux attributs.

BesoinSolution recommandée
Intervenir à la création d'une classe__init_subclass__ (simple) ou métaclasse (avancé)
Attribut avec validation réutilisableDescriptor (__get__/__set__)
Réduire la mémoire de millions d'instances__slots__
Un seul attribut calculé/validé@property (basé sur un descriptor)

Commandes & code

Métaclasses et descriptors

Le mécanisme qui fabrique les classes elles-mêmes, et le protocole derrière @property.

python
# En Python, TOUT est objet -- meme les classes sont des instances (de "type")
class MaClasse:
    pass

print(type(MaClasse))          # <class 'type'>
print(type(42))                 # <class 'int'>
print(type(int))                 # <class 'type'>
print(isinstance(MaClasse, type))  # True

# --- Metaclasses : les "classes des classes" ---
# type() peut aussi CREER une classe dynamiquement : type(nom, bases, namespace)
Dynamique = type("Dynamique", (), {"x": 1, "dire_bonjour": lambda self: "bonjour"})
instance = Dynamique()
print(instance.x, instance.dire_bonjour())

# Definir une metaclasse personnalisee : herite de "type"
class MetaValidation(type):
    def __new__(mcs, nom, bases, namespace):
        # s'execute a la CREATION de la classe, pas de l'instance
        for attr_nom, attr_valeur in namespace.items():
            if callable(attr_valeur) and not attr_nom.startswith("__"):
                print(f"Methode enregistree : {attr_nom}")
        return super().__new__(mcs, nom, bases, namespace)

class MonService(metaclass=MetaValidation):
    def traiter(self):
        pass
    def valider(self):
        pass
# Affiche "Methode enregistree : traiter" et "Methode enregistree : valider"
# au moment de la DEFINITION de la classe, avant meme toute instanciation

# Cas d'usage reel : pattern Singleton via metaclasse
class MetaSingleton(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=MetaSingleton):
    def __init__(self):
        self.parametres = {}

c1, c2 = Configuration(), Configuration()
print(c1 is c2)      # True

# __init_subclass__ : alternative souvent plus simple qu'une metaclasse (Python 3.6+)
class RegistrePlugin:
    plugins = []
    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        RegistrePlugin.plugins.append(cls)     # auto-enregistrement a chaque sous-classe

class PluginA(RegistrePlugin):
    pass

class PluginB(RegistrePlugin):
    pass

print(RegistrePlugin.plugins)     # [PluginA, PluginB]

# --- Descriptors : le protocole derriere @property, methodes, slots ---
class Positif:
    """Descriptor validant qu'une valeur numerique reste positive."""
    def __set_name__(self, owner, name):
        self.nom_prive = f"_{name}"

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return getattr(instance, self.nom_prive, 0)

    def __set__(self, instance, valeur):
        if valeur < 0:
            raise ValueError(f"{self.nom_prive} doit etre positif")
        setattr(instance, self.nom_prive, valeur)

class Produit:
    prix = Positif()          # descriptor partage, mais stocke une valeur par instance
    quantite = Positif()

    def __init__(self, prix, quantite):
        self.prix = prix           # declenche Positif.__set__
        self.quantite = quantite

p = Produit(10, 5)
print(p.prix)                  # declenche Positif.__get__
try:
    p.prix = -5
except ValueError as e:
    print(f"Refuse : {e}")

# __slots__ : optimisation memoire, empeche la creation dynamique d'attributs
class PointOptimise:
    __slots__ = ("x", "y")     # pas de __dict__ par instance -> memoire reduite

    def __init__(self, x, y):
        self.x = x
        self.y = y

pt = PointOptimise(1, 2)
try:
    pt.z = 3          # AttributeError : __slots__ interdit les attributs non declares
except AttributeError as e:
    print(f"Refuse : {e}")

import sys
print(sys.getsizeof(pt))    # sensiblement plus petit qu'un objet equivalent sans __slots__

Résumé

  • Une métaclasse (type par défaut) contrôle la création des classes elles-mêmes.
  • __init_subclass__ couvre souvent les mêmes besoins qu'une métaclasse, plus simplement (auto-enregistrement).
  • Un descriptor (__get__/__set__) est le mécanisme sous-jacent de @property : logique réutilisable entre attributs.
  • __slots__ supprime le __dict__ par instance : gain mémoire notable sur de nombreuses instances.

Exercices pratiques

1 disponible
1

Mission : dupliquer une validation de prix sur dix classes sans copier-coller

Objectif : Remplacer une validation dupliquée par un descriptor réutilisable, puis choisir entre __init_subclass__ et métaclasse pour un auto-enregistrement de plugins.

Contexte

Dix classes différentes du projet (Produit, Commande, Facture...) redéfinissent chacune un setter presque identique pour vérifier qu'un attribut numérique reste positif, avec if valeur < 0: raise ValueError(...) copié-collé dix fois. Un développeur veut factoriser cette logique en un seul endroit, réutilisable sur n'importe quel attribut de n'importe quelle classe.

Tu dois écrire ce validateur une seule fois sous forme de descriptor, l'appliquer à deux classes différentes, puis choisir le bon outil pour un auto-enregistrement de plugins.

Résoudre l’exercice →