Métaprogrammation#

Hide code cell source

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

Qu’est-ce que la métaprogrammation ?#

La métaprogrammation est l’art d’écrire du code qui raisonne sur d’autres programmes — ou sur lui-même. En Python, cette notion recouvre deux activités distinctes mais complémentaires. La première est l”introspection : interroger un objet pour connaître sa nature, ses attributs, ses méthodes ou sa hiérarchie de classes, sans en avoir lu la définition statique. La seconde est la génération ou modification de code : créer des classes à la volée, intercepter la création d’instances, modifier le comportement d’attributs à l’aide de descripteurs, ou contrôler la construction de classes entières grâce aux métaclasses.

Ce qui rend Python particulièrement propice à la métaprogrammation, c’est que tout est un objet. Une classe est un objet de type type. Une fonction est un objet de type function. Un module est un objet de type module. On peut donc les manipuler, les inspecter, les passer en argument ou les retourner comme n’importe quelle autre valeur. Cette uniformité permet d’écrire des abstractions très puissantes, comme celles que l’on trouve dans les ORM (Django Models, SQLAlchemy), les frameworks de test (pytest, unittest) ou les bibliothèques de validation (Pydantic).

La métaprogrammation est parfois considérée comme une technique réservée aux experts, car elle peut rendre le code difficile à comprendre si elle est mal utilisée. Mais elle est omniprésente dans Python : chaque fois que vous utilisez @property, @dataclass, @staticmethod ou que vous héritez d’une classe abstraite, vous bénéficiez de mécanismes métaprogrammatiques. Comprendre ces mécanismes de l’intérieur vous permettra de mieux utiliser les bibliothèques existantes et, le cas échéant, d’en créer de nouvelles.

Introspection#

L’introspection désigne la capacité d’un programme à examiner sa propre structure pendant l’exécution. Python offre un riche ensemble de fonctions et de modules dédiés à cette tâche.

Les fonctions intégrées d’introspection#

Les fonctions getattr, setattr, hasattr et delattr forment le quatuor fondamental pour manipuler les attributs d’un objet de façon dynamique.

Définition 31 (Fonctions d’accès dynamique aux attributs)

  • getattr(obj, name, default) : retourne la valeur de l’attribut name sur obj. Si l’attribut n’existe pas, retourne default s’il est fourni, sinon lève AttributeError.

  • setattr(obj, name, value) : définit l’attribut name sur obj avec la valeur value. Équivalent à obj.name = value, mais le nom est une chaîne dynamique.

  • hasattr(obj, name) : retourne True si obj possède un attribut name (sans lever d’exception).

  • delattr(obj, name) : supprime l’attribut name sur obj. Équivalent à del obj.name.

class Robot:
    def __init__(self, nom, vitesse):
        self.nom = nom
        self.vitesse = vitesse

    def deplacer(self):
        return f"{self.nom} se déplace à {self.vitesse} m/s."

r = Robot("R2D2", 3)

# Accès dynamique à un attribut dont le nom est connu à l'exécution
attribut = "vitesse"
print(getattr(r, attribut))          # 3

# Modification dynamique
setattr(r, "vitesse", 5)
print(r.vitesse)                     # 5

# Vérification
print(hasattr(r, "deplacer"))        # True
print(hasattr(r, "voler"))           # False

# Accès à une méthode par son nom et appel
methode = getattr(r, "deplacer")
print(methode())                     # R2D2 se déplace à 5 m/s.
3
5
True
False
R2D2 se déplace à 5 m/s.

La fonction vars() retourne le dictionnaire __dict__ d’un objet ou d’une classe, c’est-à-dire l’ensemble de ses attributs d’instance ou de classe sous forme de dictionnaire mutable. La fonction dir() retourne une liste triée de tous les noms accessibles sur un objet — y compris les attributs hérités.

print(vars(r))
# {'nom': 'R2D2', 'vitesse': 5}

# dir() inclut les attributs hérités de object
noms = [n for n in dir(r) if not n.startswith("__")]
print(noms)
# ['deplacer', 'nom', 'vitesse']
{'nom': 'R2D2', 'vitesse': 5}
['deplacer', 'nom', 'vitesse']

Le module inspect#

Le module inspect de la bibliothèque standard pousse l’introspection bien plus loin. Il permet d’examiner les fonctions (leur signature, leurs annotations, leur code source), les classes (leur hiérarchie, leurs membres) et les cadres d’exécution (la pile d’appels).

import inspect

def additionner(a: int, b: int = 0) -> int:
    """Additionne deux entiers."""
    return a + b

# Signature complète
sig = inspect.signature(additionner)
print(sig)                           # (a: int, b: int = 0) -> int

# Paramètres individuels
for nom, param in sig.parameters.items():
    print(f"  {nom}: annotation={param.annotation}, "
          f"défaut={param.default}")

# Vérifier si un objet est une fonction, une classe, etc.
print(inspect.isfunction(additionner))  # True
print(inspect.isclass(Robot))           # True

# Source du code (quand disponible)
# print(inspect.getsource(additionner))
(a: int, b: int = 0) -> int
  a: annotation=<class 'int'>, défaut=<class 'inspect._empty'>
  b: annotation=<class 'int'>, défaut=0
True
True

__init_subclass__#

Le hook __init_subclass__ est une méthode de classe appelée automatiquement chaque fois qu’une classe hérite de la classe qui le définit. C’est une façon élégante d’effectuer des traitements sur les sous-classes au moment de leur création, sans recourir aux métaclasses.

```{prf:definition} __init_subclass__ :label: definition-17-02 __init_subclass__(cls, **kwargs) est un hook de classe appelé par type.__init_subclass__ lorsqu’une classe hérite de la classe courante. cls est la sous-classe nouvellement créée. Les arguments de classe supplémentaires (class Enfant(Parent, option=True)) sont transmis via **kwargs.


```{code-cell} python
class PluginBase:
    _registry: dict = {}

    def __init_subclass__(cls, commande: str = None, **kwargs):
        super().__init_subclass__(**kwargs)
        if commande is not None:
            PluginBase._registry[commande] = cls
            print(f"Plugin enregistré : '{commande}' → {cls.__name__}")

class PluginBonjour(PluginBase, commande="bonjour"):
    def executer(self):
        return "Bonjour, monde !"

class PluginAuRevoir(PluginBase, commande="aurevoir"):
    def executer(self):
        return "Au revoir !"

# Le registre est rempli automatiquement à la définition des classes
print(PluginBase._registry)
plugin = PluginBase._registry["bonjour"]()
print(plugin.executer())

Ce pattern est utilisé dans de nombreux frameworks (Django REST Framework, Celery, Click) pour enregistrer automatiquement des composants sans que le développeur ait à les déclarer manuellement dans un registre central.

Descripteurs#

Un descripteur est un objet qui définit comment l’accès à un attribut d’une autre classe se comporte. C’est le mécanisme sous-jacent de @property, @staticmethod, @classmethod et des champs des ORM comme Django.

Définition 32 (Protocole descripteur)

Un objet est un descripteur s’il implémente au moins une de ces méthodes :

  • __get__(self, obj, objtype=None) : appelée lors de la lecture de l’attribut (obj.attr).

  • __set__(self, obj, value) : appelée lors de l’écriture (obj.attr = value).

  • __delete__(self, obj) : appelée lors de la suppression (del obj.attr).

Un descripteur qui implémente __set__ ou __delete__ est dit descripteur de données (data descriptor) ; il a la priorité sur le __dict__ de l’instance. Un descripteur qui n’implémente que __get__ est un descripteur non-données (non-data descriptor).

class PositifOuNul:
    """Descripteur qui valide qu'une valeur est >= 0."""

    def __set_name__(self, owner, name):
        self._name = name
        self._private = f"_{name}"

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self._private, 0)

    def __set__(self, obj, value):
        if value < 0:
            raise ValueError(
                f"'{self._name}' doit être positif ou nul, reçu : {value}"
            )
        setattr(obj, self._private, value)

class Produit:
    prix = PositifOuNul()
    quantite = PositifOuNul()

    def __init__(self, nom, prix, quantite):
        self.nom = nom
        self.prix = prix
        self.quantite = quantite

p = Produit("Pomme", 0.50, 100)
print(p.prix)       # 0.5
p.prix = 0.75
print(p.prix)       # 0.75

try:
    p.prix = -1
except ValueError as e:
    print(e)        # 'prix' doit être positif ou nul, reçu : -1
0.5
0.75
'prix' doit être positif ou nul, reçu : -1

Comment @property est implémenté#

La fonction intégrée property est elle-même un descripteur. On peut en écrire une version simplifiée pour comprendre son fonctionnement :

class ma_property:
    def __init__(self, fget=None, fset=None, fdel=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        if self.fget is None:
            raise AttributeError("attribut illisible")
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError("attribut non modifiable")
        self.fset(obj, value)

    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel)

Métaclasses#

En Python, tout objet est une instance d’une classe. Mais une classe elle-même est aussi un objet — une instance de sa métaclasse. Par défaut, toutes les classes sont des instances de type, qui est à la fois une classe et sa propre métaclasse.

Définition 33 (Métaclasse)

Une métaclasse est la classe d’une classe. Elle contrôle la création de classes : quand Python rencontre l’instruction class Foo(Bar):, il appelle la métaclasse de Bar pour construire l’objet classe Foo. La métaclasse par défaut est type. On peut spécifier une métaclasse différente avec class Foo(Bar, metaclass=MaMeta):.

# type() à trois arguments crée une classe dynamiquement
# type(nom, bases, dictionnaire)
Animal = type("Animal", (object,), {
    "cri": "...",
    "parler": lambda self: f"Je fais '{self.cri}' !"
})

class Chien(Animal):
    cri = "Ouaf"

d = Chien()
print(d.parler())           # Je fais 'Ouaf' !
print(type(Chien))          # <class 'type'>
print(type(type))           # <class 'type'>
Je fais 'Ouaf' !
<class 'type'>
<class 'type'>

Créer une métaclasse#

On crée une métaclasse en héritant de type et en surchargeant __new__ (qui construit l’objet classe) et/ou __init__ (qui l’initialise).

class SingletonMeta(type):
    """Métaclasse qui implémente le patron Singleton."""
    _instances: dict = {}

    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]

class ConfigurationApp(metaclass=SingletonMeta):
    def __init__(self, chemin="/etc/app.conf"):
        self.chemin = chemin
        print(f"Configuration chargée depuis {chemin}")

c1 = ConfigurationApp("/home/user/.apprc")
c2 = ConfigurationApp("/autre/chemin")   # __init__ non rappelé
print(c1 is c2)      # True
print(c1.chemin)     # /home/user/.apprc
Configuration chargée depuis /home/user/.apprc
True
/home/user/.apprc

Remarque 31

__new__ vs __init__ dans une métaclasse : __new__(mcs, nom, bases, namespace) est appelée en premier et retourne le nouvel objet classe. __init__(cls, nom, bases, namespace) reçoit ensuite cet objet pour l’initialiser. Dans la majorité des cas pratiques, on surcharge __new__ pour modifier le dictionnaire d’attributs avant que la classe ne soit créée, et __init__ pour effectuer des actions après la création. La méthode __call__ de la métaclasse est invoquée lorsqu’on appelle la classe pour créer une instance.

Cas d’usage réels#

Les ORM Django utilisent une métaclasse (ModelBase) pour transformer les déclarations de champs (CharField, IntegerField) en descripteurs actifs, enregistrer les modèles dans un registre global et générer automatiquement les requêtes SQL. SQLAlchemy utilise un mécanisme similaire. Ces usages justifient les métaclasses : quand on a besoin d’agir sur la structure d’une classe au moment de sa définition, pas à celui de son instanciation.

Remarque 32

Quand utiliser les métaclasses ? La réponse honnête est : rarement. Pour la plupart des problèmes, __init_subclass__, les décorateurs de classe ou les descripteurs suffisent et sont plus lisibles. Les métaclasses s’imposent lorsqu’on doit modifier le comportement de type.__new__ lui-même, ou lorsqu’on crée un framework qui doit agir sur des centaines de classes d’utilisateurs de façon transparente. Comme le dit Tim Peters : « Si vous pensez avoir besoin d’une métaclasse, vous avez probablement tort — mais si vous en avez réellement besoin, vous le saurez. »

__class_getitem__ et génériques#

Depuis Python 3.7, la syntaxe list[int] et dict[str, int] fonctionne grâce à la méthode spéciale __class_getitem__. Elle est appelée lorsqu’on indexe une classe avec des crochets.

class Pile:
    """Pile générique avec annotation de type."""

    def __class_getitem__(cls, item):
        # Retourne un alias de type pour les annotations
        return f"Pile[{item.__name__ if hasattr(item, '__name__') else item}]"

    def __init__(self):
        self._data = []

    def empiler(self, valeur):
        self._data.append(valeur)

    def depiler(self):
        return self._data.pop()

# Utilisation dans les annotations
def traiter(pile: Pile[int]) -> None:
    pass

print(Pile[int])     # Pile[int]
print(Pile[str])     # Pile[str]
Pile[int]
Pile[str]

Les classes génériques standard (list, dict, tuple, set) implémentent __class_getitem__ depuis Python 3.9, ce qui permet d’écrire directement list[int] dans les annotations sans importer List depuis typing. Pour des génériques personnalisés complets, on hérite de typing.Generic[T].

Hide code cell source

fig, ax = plt.subplots(figsize=(12, 7))
ax.set_xlim(0, 14)
ax.set_ylim(0, 8)
ax.axis('off')
ax.set_title(
    "Relation entre objet, classe et métaclasse en Python",
    fontsize=15, fontweight='bold', pad=15
)

couleurs = {
    "meta": "#e74c3c",
    "classe": "#3498db",
    "instance": "#27ae60",
    "fleche": "#2c3e50",
}

def boite(ax, x, y, w, h, texte_principal, texte_secondaire, couleur):
    rect = patches.FancyBboxPatch(
        (x, y), w, h,
        boxstyle="round,pad=0.15", linewidth=2.5,
        edgecolor=couleur, facecolor=couleur, alpha=0.15
    )
    ax.add_patch(rect)
    bord = patches.FancyBboxPatch(
        (x, y), w, h,
        boxstyle="round,pad=0.15", linewidth=2.5,
        edgecolor=couleur, facecolor='none'
    )
    ax.add_patch(bord)
    ax.text(x + w/2, y + h*0.65, texte_principal,
            ha='center', va='center', fontsize=12,
            fontweight='bold', color=couleur)
    ax.text(x + w/2, y + h*0.28, texte_secondaire,
            ha='center', va='center', fontsize=9,
            color='#555555', style='italic')

# Métaclasse
boite(ax, 0.5, 5.0, 3.5, 2.2, "type", "métaclasse par défaut", couleurs["meta"])

# Classe
boite(ax, 5.0, 5.0, 3.5, 2.2, "MaClasse", "class MaClasse:", couleurs["classe"])

# Instance
boite(ax, 9.5, 5.0, 3.5, 2.2, "instance", "MaClasse()", couleurs["instance"])

# Flèche type → MaClasse (instanciation de la classe)
ax.annotate('', xy=(5.0, 6.1), xytext=(4.0, 6.1),
            arrowprops=dict(arrowstyle='->', color=couleurs["meta"], lw=2.2))
ax.text(4.5, 6.55, "instance de", ha='center', fontsize=8.5,
        color=couleurs["meta"], style='italic')

# Flèche MaClasse → instance (instanciation)
ax.annotate('', xy=(9.5, 6.1), xytext=(8.5, 6.1),
            arrowprops=dict(arrowstyle='->', color=couleurs["classe"], lw=2.2))
ax.text(9.0, 6.55, "instance de", ha='center', fontsize=8.5,
        color=couleurs["classe"], style='italic')

# Flèche type → type (type est instance de lui-même)
ax.annotate('', xy=(0.5, 5.6), xytext=(0.5, 4.2),
            arrowprops=dict(arrowstyle='->', color=couleurs["meta"], lw=2,
                            connectionstyle="arc3,rad=-0.5"))
ax.text(0.05, 4.85, "type(type)\n= type", ha='center', fontsize=8,
        color=couleurs["meta"], style='italic')

# Héritage : MaClasse hérite de object
boite(ax, 5.0, 1.5, 3.5, 2.2, "object", "classe racine", "#8e44ad")

ax.annotate('', xy=(6.75, 5.0), xytext=(6.75, 3.7),
            arrowprops=dict(arrowstyle='->', color="#8e44ad", lw=2.2,
                            linestyle='dashed'))
ax.text(7.6, 4.35, "hérite de", ha='center', fontsize=8.5,
        color="#8e44ad", style='italic')

# Légende
legend_items = [
    (couleurs["meta"], "Métaclasse"),
    (couleurs["classe"], "Classe"),
    (couleurs["instance"], "Instance"),
    ("#8e44ad", "Héritage"),
]
for i, (c, lbl) in enumerate(legend_items):
    ax.add_patch(patches.FancyBboxPatch(
        (0.5 + i*3.3, 0.3), 0.4, 0.4,
        boxstyle="round,pad=0.05", facecolor=c, alpha=0.7))
    ax.text(1.1 + i*3.3, 0.5, lbl, fontsize=9, va='center', color=c,
            fontweight='bold')

plt.tight_layout()
plt.show()
_images/d07f271c483682e836038442d179063122cdb217877fdb87083dc1a3421e5cc6.png

Résumé#

Dans ce chapitre, nous avons exploré les mécanismes par lesquels Python permet au code de s’inspecter et de se modifier lui-même :

  • L”introspection avec getattr, setattr, hasattr, vars(), dir() et le module inspect permet d’examiner n’importe quel objet à l’exécution, sans en connaître la structure à l’avance.

  • __init_subclass__ offre un point d’extension propre pour réagir à la création de sous-classes, sans la complexité des métaclasses — idéal pour les registres de plugins ou les frameworks.

  • Les descripteurs (__get__, __set__, __delete__) sont le mécanisme sous-jacent de @property, @staticmethod et des champs d’ORM. Maîtriser ce protocole permet de comprendre comment Python gère réellement l’accès aux attributs.

  • Les métaclasses contrôlent la création de classes entières. Elles sont puissantes mais doivent rester un outil de dernier recours, réservé aux frameworks.

  • __class_getitem__ permet la syntaxe générique MaClasse[T], utilisée par le système de types depuis Python 3.9.

Dans le chapitre suivant, nous abordons un tout autre paradigme : la programmation fonctionnelle en Python, avec functools, itertools, operator et les fonctions d’ordre supérieur.