Mis à jour le lundi 18 septembre 2017
  • 40 heures
  • Difficile

Ce cours est visible gratuitement en ligne.

Ce cours existe en livre papier.

Ce cours existe en eBook.

Vous pouvez obtenir un certificat de réussite à l'issue de ce cours.

Vous pouvez être accompagné et mentoré par un professeur particulier par visioconférence sur ce cours.

J'ai tout compris !

Les métaclasses

Connectez-vous ou inscrivez-vous pour bénéficier de toutes les fonctionnalités de ce cours !

Toujours plus loin vers la métaprogrammation ! Nous allons ici nous intéresser au concept des métaclasses, ou comment générer des classes à partir… d'autres classes ! Je ne vous cache pas qu'il s'agit d'un concept assez avancé de la programmation Python, prenez donc le temps nécessaire pour comprendre ce nouveau concept.

Retour sur le processus d'instanciation

Depuis la troisième partie de ce cours, nous avons créé bon nombre d'objets. Nous avons découvert au début de cette partie le constructeur, cette méthode appelée quand on souhaite créer un objet.

Je vous ai dit alors que les choses étaient un peu plus complexes que ce qu'il semblait. Nous allons maintenant voir en quoi !

Admettons que vous ayez défini une classe :

class Personne:
    
    """Classe définissant une personne.
    
    Elle possède comme attributs :
    nom -- le nom de la personne
    prenom -- son prénom
    age -- son âge
    lieu_residence -- son lieu de résidence
    
    Le nom et le prénom doivent être passés au constructeur."""
    
    def __init__(self, nom, prenom):
        """Constructeur de notre personne."""
        self.nom = nom
        self.prenom = prenom
        self.age = 23
        self.lieu_residence = "Lyon"

Cette syntaxe n'a rien de nouveau pour nous.

Maintenant, que se passe-t-il quand on souhaite créer une personne ? Facile, on rédige le code suivant :

personne = Personne("Doe", "John")

Lorsque l'on exécute cela, Python appelle notre constructeur __init__ en lui transmettant les arguments fournis à la construction de l'objet. Il y a cependant une étape intermédiaire.

Si vous examinez la définition de notre constructeur :

def __init__(self, nom, prenom):

Vous ne remarquez rien d'étrange ? Peut-être pas, car vous avez été habitués à cette syntaxe depuis le début de cette partie : la méthode prend en premier paramètre self.

Or, self, vous vous en souvenez, c'est l'objet que nous manipulons. Sauf que, quand on crée un objet… on souhaite récupérer un nouvel objet mais on n'en passe aucun à la classe.

D'une façon ou d'une autre, notre classe crée un nouvel objet et le passe à notre constructeur. La méthode __init__ se charge d'écrire dans notre objet ses attributs, mais elle n'est pas responsable de la création de notre objet. Nous allons à présent voir qui s'en charge.

La méthode __new__

La méthode __init__, comme nous l'avons vu, est là pour initialiser notre objet (en écrivant des attributs dedans, par exemple) mais elle n'est pas là pour le créer. La méthode qui s'en charge, c'est __new__.

C'est aussi une méthode spéciale, vous en reconnaissez la particularité. C'est également une méthode définie par object, que l'on peut redéfinir en cas de besoin.

Avant de voir ce qu'elle prend en paramètres, voyons plus précisément ce qui se passe quand on tente de construire un objet :

  • On demande à créer un objet, en écrivant par exemple Personne("Doe", "John").

  • La méthode __new__ de notre classe (ici Personne) est appelée et se charge de construire un nouvel objet.

  • Si __new__ renvoie une instance de la classe, on appelle le constructeur __init__ en lui passant en paramètres cette nouvelle instance ainsi que les arguments passés lors de la création de l'objet.

Maintenant, intéressons-nous à la structure de notre méthode __new__.

C'est une méthode statique, ce qui signifie qu'elle ne prend pas self en paramètre. C'est logique, d'ailleurs : son but est de créer une nouvelle instance de classe, l'instance n'existe pas encore.

Elle ne prend donc pas self en premier paramètre (l'instance d'objet). Cependant, elle prend la classe manipulée cls.

Autrement dit, quand on souhaite créer un objet de la classe Personne, la méthode __new__ de la classe Personne est appelée et prend comme premier paramètre la classe Personne elle-même.

Les autres paramètres passés à la méthode __new__ seront transmis au constructeur.

Voyons un peu cela, exprimé sous forme de code :

class Personne:
    
    """Classe définissant une personne.
    
    Elle possède comme attributs :
    nom -- le nom de la personne
    prenom -- son prénom
    age -- son âge
    lieu_residence -- son lieu de résidence
    
    Le nom et le prénom doivent être passés au constructeur."""
    
    def __new__(cls, nom, prenom):
        print("Appel de la méthode __new__ de la classe {}".format(cls))
        # On laisse le travail à object
        return object.__new__(cls, nom, prenom)
    
    def __init__(self, nom, prenom):
        """Constructeur de notre personne."""
        print("Appel de la méthode __init__")
        self.nom = nom
        self.prenom = prenom
        self.age = 23
        self.lieu_residence = "Lyon"

Essayons de créer une personne :

>>> personne = Personne("Doe", "John")
Appel de la méthode __new__ de la classe <class '__main__.Personne'>
Appel de la méthode __init__
>>>

Redéfinir __new__ peut permettre, par exemple, de créer une instance d'une autre classe. Elle est principalement utilisée par Python pour produire des types immuables (en anglais, immutable), que l'on ne peut modifier, comme le sont les chaînes de caractères, les tuples, les entiers, les flottants…

La méthode __new__ est parfois redéfinie dans le corps d'une métaclasse. Nous allons à présent voir de ce dont il s'agit.

Créer une classe dynamiquement

Je le répète une nouvelle fois, en Python, tout est objet. Cela veut dire que les entiers, les flottants, les listes sont des objets, que les modules sont des objets, que les packages sont des objets… mais cela veut aussi dire que les classes sont des objets !

La méthode que nous connaissons

Pour créer une classe, nous n'avons vu qu'une méthode, la plus utilisée, faisant appel au mot-clé class.

class MaClasse:

Vous pouvez ensuite créer des instances sur le modèle de cette classe, je ne vous apprends rien.

Mais là où cela se complique, c'est que les classes sont également des objets.

Si les classes sont des objets… cela veut dire que les classes sont elles-mêmes modelées sur des classes ?

Eh oui. Les classes, comme tout objet, sont modelées sur une classe. Cela paraît assez difficile à comprendre au début. Peut-être cet extrait de code vous aidera-t-il à comprendre l'idée.

>>> type(5)
<class 'int'>
>>> type("une chaîne")
<class 'str'>
>>> type([1, 2, 3])
<class 'list'>
>>> type(int)
<class 'type'>
>>> type(str)
<class 'type'>
>>> type(list)
<class 'type'>
>>>

On demande le type d'un entier et Python nous répond class int. Sans surprise. Mais si on lui demande la classe de int, Python nous répond class type.

En fait, par défaut, toutes nos classes sont modelées sur la classe type. Cela signifie que :

  1. quand on crée une nouvelle classe (class Personne: par exemple), Python appelle la méthode __new__ de la classe type ;

  2. une fois la classe créée, on appelle le constructeur __init__ de la classe type.

Cela semble sans doute encore obscur. Ne désespérez pas, vous comprendrez peut-être un peu mieux ce dont je parle en lisant la suite. Sinon, n'hésitez pas à relire ce passage et à faire des tests par vous-mêmes.

Créer une classe dynamiquement

Résumons :

  • nous savons que les objets sont modelés sur des classes ;

  • nous savons que nos classes, étant elles-mêmes des objets, sont modelées sur une classe ;

  • la classe sur laquelle toutes les autres sont modelées par défaut s'appelle type.

Je vous propose d'essayer de créer une classe dynamiquement, sans passer par le mot-clé class mais par la classe type directement.

La classe type prend trois arguments pour se construire :

  • le nom de la classe à créer ;

  • un tuple contenant les classes dont notre nouvelle classe va hériter ;

  • un dictionnaire contenant les attributs et méthodes de notre classe.

>>> Personne = type("Personne", (), {})
>>> Personne
<class '__main__.Personne'>
>>> john = Personne()
>>> dir(john)
['__class__', '__delattr__', '__dict__', '__doc__', '__eq__', '__format__', '__g
e__', '__getattribute__', '__gt__', '__hash__', '__init__', '__le__', '__lt__',
'__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '_
_setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__']
>>>

J'ai simplifié le code au maximum. Nous créons bel et bien une nouvelle classe que nous stockons dans notre variable Personne, mais elle est vide. Elle n'hérite d'aucune classe et elle ne définit aucun attribut ni méthode de classe.

Nous allons essayer de créer deux méthodes pour notre classe :

  • un constructeur __init__ ;

  • une méthode presenter affichant le prénom et le nom de la personne.

Je vous donne ici le code auquel on peut arriver :

def creer_personne(personne, nom, prenom):
    """La fonction qui jouera le rôle de constructeur pour notre classe Personne.
    
    Elle prend en paramètre, outre la personne :
    nom -- son nom
    prenom -- son prenom"""
    
    personne.nom = nom
    personne.prenom = prenom
    personne.age = 21
    personne.lieu_residence = "Lyon"

def presenter_personne(personne):
    """Fonction présentant la personne.
    
    Elle affiche son prénom et son nom"""
    
    print("{} {}".format(personne.prenom, personne.nom))

# Dictionnaire des méthodes
methodes = {
    "__init__": creer_personne,
    "presenter": presenter_personne,
}

# Création dynamique de la classe
Personne = type("Personne", (), methodes)

Avant de voir les explications, voyons les effets :

>>> john = Personne("Doe", "John")
>>> john.nom
'Doe'
>>> john.prenom
'John'
>>> john.age
21
>>> john.presenter()
John Doe
>>>

Je ne vous le cache pas, c'est une fonctionnalité que vous utiliserez sans doute assez rarement. Mais cette explication était à propos quand on s'intéresse aux métaclasses.

Pour l'heure, décomposons notre code :

  1. On commence par créer deux fonctions, creer_personne et presenter_personne. Elles sont amenées à devenir les méthodes __init__ et presenter de notre future classe. Étant de futures méthodes d'instance, elles doivent prendre en premier paramètre l'objet manipulé.

  2. On place ces deux fonctions dans un dictionnaire. En clé se trouve le nom de la future méthode et en valeur, la fonction correspondante.

  3. Enfin, on fait appel à type en lui passant, en troisième paramètre, le dictionnaire que l'on vient de constituer.

Si vous essayez de mettre des attributs dans ce dictionnaire passé à type, vous devez être conscients du fait qu'il s'agira d'attributs de classe, pas d'attributs d'instance.

Définition d'une métaclasse

Nous avons vu que type est la métaclasse de toutes les classes par défaut. Cependant, une classe peut posséder une autre métaclasse que type.

Construire une métaclasse se fait de la même façon que construire une classe. Les métaclasses héritent de type. Nous allons retrouver la structure de base des classes que nous avons vues auparavant.

Nous allons notamment nous intéresser à deux méthodes que nous avons utilisées dans nos définitions de classes :

  • la méthode __new__, appelée pour créer une classe ;

  • la méthode __init__, appelée pour construire la classe.

La méthode __new__

Elle prend quatre paramètres :

  • la métaclasse servant de base à la création de notre nouvelle classe ;

  • le nom de notre nouvelle classe ;

  • un tuple contenant les classes dont héritent notre classe à créer ;

  • le dictionnaire des attributs et méthodes de la classe à créer.

Les trois derniers paramètres, vous devriez les reconnaître : ce sont les mêmes que ceux passés à type.

Voici une méthode __new__ minimaliste.

class MaMetaClasse(type):
    
    """Exemple d'une métaclasse."""
    
    def __new__(metacls, nom, bases, dict):
        """Création de notre classe."""
        print("On crée la classe {}".format(nom))
        return type.__new__(metacls, nom, bases, dict)

Pour dire qu'une classe prend comme métaclasse autre chose que type, c'est dans la ligne de la définition de la classe que cela se passe :

class MaClasse(metaclass=MaMetaClasse):
    pass

En exécutant ce code, vous pouvez voir :

On crée la classe MaClasse

La méthode __init__

Le constructeur d'une métaclasse prend les mêmes paramètres que __new__, sauf le premier, qui n'est plus la métaclasse servant de modèle mais la classe que l'on vient de créer.

Les trois paramètres suivants restent les mêmes : le nom, le tuple des classes-mères et le dictionnaire des attributs et méthodes de classe.

Il n'y a rien de très compliqué dans le procédé, l'exemple ci-dessus peut être repris en le modifiant quelque peu pour qu'il s'adapte à la méthode __init__.

Maintenant, voyons concrètement à quoi cela peut servir.

Les métaclasses en action

Comme vous pouvez vous en douter, les métaclasses sont généralement utilisées pour des besoins assez complexes. L'exemple le plus répandu est une métaclasse chargée de tracer l'appel de ses méthodes. Autrement dit, dès qu'on appelle une méthode d'un objet, une ligne s'affiche pour le signaler. Mais cet exemple est assez difficile à comprendre car il fait appel à la fois au concept des métaclasses et à celui des décorateurs, pour décorer les méthodes tracées.

Je vous propose quelque chose de plus simple. Il va de soi qu'il existe bien d'autres usages, dont certains complexes, des métaclasses.

Nous allons essayer de garder nos classes créées dans un dictionnaire prenant comme clé le nom de la classe et comme valeur la classe elle-même.

Par exemple, dans une bibliothèque destinée à construire des interfaces graphiques, on trouve plusieurs widgets (ce sont des objets graphiques) comme des boutons, des cases à cocher, des menus, des cadres… Généralement, ces objets sont des classes héritant d'une classe mère commune. En outre, l'utilisateur peut, en cas de besoin, créer ses propres classes héritant des classes de la bibliothèque.

Par exemple, la classe mère de tous nos widgets s'appellera Widget. De cette classe hériteront les classes Bouton, CaseACocher, Menu, Cadre, etc. L'utilisateur de la bibliothèque pourra par ailleurs en dériver ses propres classes.

Le dictionnaire que l'on aimerait créer se présente comme suit :

{
    "Widget": Widget,
    "Bouton": Bouton,
    "CaseACocher": CaseACocher,
    "Menu": Menu,
    "Cadre": Cadre,
    ...
}

Ce dictionnaire pourrait être rempli manuellement à chaque fois qu'on crée une classe héritant de Widget mais avouez que ce ne serait pas très pratique.

Dans ce contexte, les métaclasses peuvent nous faciliter la vie. Vous pouvez essayer de faire l'exercice, le code n'est pas trop complexe. Cela dit, étant donné qu'on a vu beaucoup de choses dans ce chapitre et que les métaclasses sont un concept plutôt avancé, je vous donne directement le code qui vous aidera peut-être à comprendre le mécanisme :

trace_classes = {} # Notre dictionnaire vide

class MetaWidget(type):
    
    """Notre métaclasse pour nos Widgets.
    
    Elle hérite de type, puisque c'est une métaclasse.
    Elle va écrire dans le dictionnaire trace_classes à chaque fois
    qu'une classe sera créée, utilisant cette métaclasse naturellement."""
    
    def __init__(cls, nom, bases, dict):
        """Constructeur de notre métaclasse, appelé quand on crée une classe."""
        type.__init__(cls, nom, bases, dict)
        trace_classes[nom] = cls

Pas trop compliqué pour l'heure. Créons notre classe Widget :

class Widget(metaclass=MetaWidget):
    
    """Classe mère de tous nos widgets."""
    
    pass

Après avoir exécuté ce code, vous pouvez voir que notre classe Widget a bien été ajoutée dans notre dictionnaire :

>>> trace_classes
{'Widget': <class '__main__.Widget'>}
>>>

Maintenant, construisons une nouvelle classe héritant de Widget.

class bouton(Widget):
    
    """Une classe définissant le widget bouton."""
    
    pass

Si vous affichez de nouveau le contenu du dictionnaire, vous vous rendrez compte que la classe Bouton a bien été ajoutée. Héritant de Widget, elle reprend la même métaclasse (sauf mention contraire explicite) et elle est donc ajoutée au dictionnaire.

Vous pouvez étoffer cet exemple, faire en sorte que l'aide de la classe soit également conservée, ou qu'une exception soit levée si une classe du même nom existe déjà dans le dictionnaire.

Pour conclure

Les métaclasses sont un concept de programmation assez avancé, puissant mais délicat à comprendre de prime abord. Je vous invite, en cas de doute, à tester par vous-mêmes ou à rechercher d'autres exemples, ils sont nombreux.

En résumé

  • Le processus d'instanciation d'un objet est assuré par deux méthodes, __new__ et __init__.

  • __new__ est chargée de la création de l'objet et prend en premier paramètre sa classe.

  • __init__ est chargée de l'initialisation des attributs de l'objet et prend en premier paramètre l'objet précédemment créé par __new__.

  • Les classes étant des objets, elles sont toutes modelées sur une classe appelée métaclasse.

  • À moins d'être explicitement modifiée, la métaclasse de toutes les classes est type.

  • On peut utiliser type pour créer des classes dynamiquement.

  • On peut faire hériter une classe de type pour créer une nouvelle métaclasse.

  • Dans le corps d'une classe, pour spécifier sa métaclasse, on exploite la syntaxe suivante : class MaClasse(metaclass=NomDeLaMetaClasse):.

Exemple de certificat de réussite
Exemple de certificat de réussite