backend / python
Métaclasses et descriptors
Explication
Ce que vous allez apprendre
- Comprendre pourquoi une classe est elle-même une instance de
typeen 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.
| Besoin | Solution recommandée |
|---|---|
| Intervenir à la création d'une classe | __init_subclass__ (simple) ou métaclasse (avancé) |
| Attribut avec validation réutilisable | Descriptor (__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.
# 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 (
typepar 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
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.