Comprendre les Métaclasses en Python

Prérequis : La fonction exec

La fonction exec exécute une chaîne de caractères contenant du code Python. Elle prend jusqu'à trois arguments :

  • Une chaîne représentant le code à exécuter.
  • (Optionnel) Un dictionnaire représentant l'espace de noms global. Par défaut, c'est globals().
  • (Optionnel) Un dictionnaire représentant l'espace de noms local. Par défaut, c'est locals().

Tout nom créé durant l'exécution de la chaîne de code est stocké dans l'espace de noms local spécifié.

Démonstration de l'impact sur les espaces de noms

g = {'x': 10, 'y': 20} l = {}

exec(""" global x, z x = 100 z = 200 local_var = 300 """, g, l)

print(g) # {'x': 100, 'y': 20, 'z': 200, ...} print(l) # {'local_var': 300}


</div>### La méthode spéciale `__call__`

Cette méthode est invoquée lorsqu'une instance d'une classe est appelée comme une fonction, c'est-à-dire avec des parenthèses `()`. Elle est différente de la méthode `__init__`, qui est appelée lors de la *création* de l'instance (`mon_objet = MaClasse()`).

<div>```

class Gestionnaire:
    def __init__(self, nom):
        self.nom = nom

    def __call__(self, message):
        return f"[{self.nom}] Traitement du message : {message}"

gestion = Gestionnaire("Alpha")
# Création de l'instance (__init__)
resultat = gestion("Données entrantes")
# Appel de l'instance (__call__)
print(resultat) # [Alpha] Traitement du message : Données entrantes

En Python, tout est un objet, y compris les classes elles-mêmes. Lorsque l'interpréteur exécute une instruction class, il crée un objet qui représente cette classe. En conséquence, un objet de type type.

class Vehicule: roues = 4

ma_voiture = Vehicule() # 'ma_voiture' est une instance de Vehicule print(type(ma_voiture)) # <class 'main.Vehicule'>

'Vehicule' est lui-même un objet !

print(type(Vehicule)) # <class 'type'>


</div>Puisqu'une classe est un objet, elle peut être manipulée comme n'importe quel autre objet en Python :

- Assignée à une variable.
- Passée en argument de fonction.
- Retournée par une fonction.
- Créée dynamiquement à l'exécution.

<div>```

# Exemple de manipulation dynamique
def obtenir_classe(nom):
    if nom == "voiture":
        class Voiture:
            pass
        return Voiture
    else:
        class Camion:
            pass
        return Camion

ClasseChoisie = obtenir_classe("voiture")
instance = ClasseChoisie()
print(type(instance)) # <class '__main__.obtenir_classe.<locals>.Voiture'>

La fonction type() a une double vie. D'une part, elle renvoie le type d'un objet. D'autre part, elle peut être utilisée pour créer dynamiquement de nouveaux types (classes). C'est le constructeur de classe intégré de Python.

Sa syntaxe pour la création de classe est :

type(nom, bases, dictionnaire_attributs)
  • nom : Une chaîne représentant le nom de la classe.
  • bases : Un tuple contenant les classes parentes (pour l'héritage).
  • dictionnaire_attributs : Un dictionnaire contenant les attributs et méthodes de la classe.

Création dynamique d'une classe

ClasseDynamique = type('Dynamique', (object,), {'valeur': 42, 'afficher': lambda self: print(self.valeur)})

obj = ClasseDynamique() obj.afficher() # 42 print(type(ClasseDynamique)) # <class 'type'>


</div>Qu'est-ce qu'une Métaclassse ?
------------------------------

Une métaclassse est la classe d'une classe. Si une classe est un moule pour créer des instances (objets), une métaclassse est le moule pour créer des classes. La classe `type` est la métaclassse intégrée par défaut de Python.

<div>```

print(type(int))   # <class 'type'>
print(type(str))   # <class 'type'>
print(type(object))# <class 'type'>

# Notre classe Dynamique créée plus haut est une instance de 'type'
print(type(ClasseDynamique)) # <class 'type'>

L'attribut __metaclass__ (et la syntaxe Python 3)

En Python 2, on pouvait définir une métaclassse pour une classe en ajoutant un attribut __metaclass__. En Python 3, la syntaxe standard passe par un argument de mot-clé metaclass dans la définition de la classe.

Syntaxe Python 3

class MaClasse(metaclass=MaMétaclassse): pass


</div>Lors de la définition d'une classe, Python suit cette logique :

1. La classe a-t-elle un attribut `metaclass` ? Si oui, l'utiliser pour créer l'objet-classe.
2. Sinon, chercher dans les classes parentes une métaclassse héritée.
3. Si aucune n'est trouvée, utiliser `type` par défaut.

Création Personnalisée d'une Classe (Processus Détaillé)
--------------------------------------------------------

Pour comprendre ce qui se passe "sous le capot", nous pouvons reproduire manuellement la création d'une classe.

<div>```

# 1. Définir les composants
nom_classe = 'Client'
classes_parentes = (object,)
corps_de_classe = """
nom_entreprise = "ACME Inc."

def __init__(self, identifiant):
    self.id = identifiant

def presenter(self):
    print(f"Client #{self.id} de {self.nom_entreprise}")
"""

# 2. Traiter le corps : obtenir un espace de noms
espace_noms = {}
exec(corps_de_classe, {}, espace_noms)

# 3. Créer la classe en appelant le constructeur type()
Client = type(nom_classe, classes_parentes, espace_noms)

c = Client(123)
c.presenter() # Client #123 de ACME Inc.

Une métaclassse personnalisée doit généralement implémenter une méthode __new__ ou __init__ pour intercepter la création de la classe.

Exemple 1 : Validation à la Création de la Classe

Cette métaclassse vérifie que le nom de la classe commence par une majuscule et qu'une docstring est présente.

class ValidationMeta(type): def new(mcs, nom, bases, espace_noms): # Vérification 1 : Le nom de la classe doit commencer par une majuscule if not nom[0].isupper(): raise TypeError(f"Le nom de la classe '{nom}' doit commencer par une majuscule.")

    # Vérification 2 : Une docstring doit être présente
    docstring = espace_noms.get('__doc__')
    if not docstring or not docstring.strip():
        raise TypeError(f"La classe '{nom}' doit avoir une docstring non vide.")

    # Appel au constructeur parent pour finaliser la création
    return super().__new__(mcs, nom, bases, espace_noms)

Utilisation

class CompteUtilisateur(metaclass=ValidationMeta): """Classe représentant un compte utilisateur.""" def init(self, nom_utilisateur): self.utilisateur = nom_utilisateur

Ceci lèverait une TypeError :

class mauvais_nom(metaclass=ValidationMeta):

pass


</div>### Exemple 2 : Contrôle de l'Instanciation (Singleton via Métaclassse)

En sucrhargeant la méthode `__call__` de la métaclassse, on peut contrôler comment ses instances (qui sont des classes) sont appelées pour créer des objets.

<div>```

import threading

class SingletonMeta(type):
    _instances = {}
    _lock = threading.Lock()

    def __call__(cls, *args, **kwargs):
        # Double vérification pour le thread-safety
        if cls not in cls._instances:
            with cls._lock:
                if cls not in cls._instances:
                    instance = super().__call__(*args, **kwargs)
                    cls._instances[cls] = instance
        return cls._instances[cls]

class ConnexionDB(metaclass=SingletonMeta):
    """Gestionnaire de connexion à la base de données (Singleton)."""
    def __init__(self):
        self.connecte = True
        print("Initialisation de la connexion unique...")

# Les deux appels renvoient la même instance
conn1 = ConnexionDB() # Affiche le message d'initialisation
conn2 = ConnexionDB() # N'affiche rien, renvoie conn1
print(conn1 is conn2) # True

Quand nous appelons une_classe() pour créer une instance, c'est la méthode __call__ de sa métaclassse qui est exécutée. Cette méthode typiquement :

  1. Crée une nouvelle instance de la classe en appelant __new__.
  2. Initialise cette instance en appelant __init__.
  3. Retourne l'instance initialisée.

Notre SingletonMeta ci-dessus modifie ce processus pour ne pas recréer une instance si elle existe déjà.

Pourquoi et Quand Utiliser les Métaclassses ?

Les métaclassses sont un mécanisme puissant mais complexe. Elles sont principalement utiles pour :

  • Créer des API expressives et déclaratives (comme l'ORM de Django).
  • Appliquer des règles ou des validations à l'échelle de toutes les classes d'un framework.
  • Instrumenter ou modifier automatiquement les classes (par exemple, enregistrement, injection de méthodes).

Conseil d'utilisation : Dans 99% des cas, un décorateur de classe (@decorator) ou un simple héritage suffit. Réservez les métaclassses aux cas où vous avez besoin de contrôler la création même des classes.

Exercices Pratiques

Exercice 1 : Uniformisation des Attributs

Créez une métaclassse qui transforme tous les attributs de données (non-appelables, ne commençant pas par __) d'une classe en lettres majuscules.

class MetaUniformisation(type): def new(mcs, nom, bases, espace_noms): esp_noms_modifie = {} for cle, valeur in espace_noms.items(): if not callable(valeur) and not cle.startswith('__'): esp_noms_modifie[cle.upper()] = valeur else: esp_noms_modifie[cle] = valeur return super().new(mcs, nom, bases, esp_noms_modifie)

class Profil(metaclass=MetaUniformisation): nom_complet = "Jean Dupont" age = 30 def saluer(self): print("Bonjour !")

print(Profil.NOM_COMPLET) # "Jean Dupont" print(Profil.AGE) # 30 p = Profil() p.saluer() # "Bonjour !"


</div>### Exercice 2 : Constructeur Automatique

Créez une métaclassse qui permet de créer des instances *sans* définir de méthode `__init__`. Les arguments passés lors de l'instanciation doivent être stockés comme des attributs (en majuscules) de l'objet. Les arguments doivent être nommés.

<div>```

class MetaConstructeur(type):
    def __call__(cls, **kwargs):
        # kwargs contient les attributs nommés
        if not kwargs:
            raise TypeError("Les attributs doivent être passés sous forme de mots-clés.")
        
        instance = object.__new__(cls)
        # Initialisation automatique : stockage en majuscules
        for cle, valeur in kwargs.items():
            setattr(instance, cle.upper(), valeur)
        return instance

class Article(metaclass=MetaConstructeur):
    pass

art = Article(titre="Python Avancé", prix=29.99)
print(art.TITRE) # "Python Avancé"
print(art.PRIX)  # 29.99

Étiquettes: metaclasses type __metaclass__ __new__ __call__

Publié le 22 juin à 23h54