Métaclasses

Nous connaissons maintenant une première métaclasse, type. Une métaclasse est une classe dont les instances sont des classes.

Ce chapitre a pour but de présenter comment créer nos propres métaclasses, et les mécanismes mis en œuvre par cela.

Quel est donc ce type ?

Ainsi, vous l’aurez compris, type n’est pas utile que pour connaître le type d’un objet. Dans l’utilisation que vous connaissiez, type prend un unique paramètre, et en retourne le type.

Pour notre autre utilisation, ses paramètres sont au nombre de 3 :

  • name – une chaîne de caractères représentant le nom de la classe à créer ;
  • bases – un tuple contenant les classes dont nous héritons (object est implicite) ;
  • dict – le dictionnaire des attributs et méthodes de la nouvelle classe.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
>>> type('A', (), {})
<class '__main__.A'>
>>> A = type('A', (), {'x': 4})
>>> A.x
4
>>> A().x
4
>>> type(A)
<class 'type'>
>>> type(A())
<class '__main__.A'>

Nous avons ici une classe A, strictement équivalente à la suivante :

1
2
class A:
    x = 4

Voici maintenant un exemple plus complet, avec héritage et méthodes.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
>>> B = type('B', (int,), {})
>>> B()
0
>>> B = type('B', (int,), {'next': lambda self: self + 1})
>>> B(5).next()
6
>>> def C_prev(self):
...     return self - 1
...
>>> C = type('C', (B,), {'prev': C_prev})
>>> C(5).prev()
4
>>> C(5).next()
6

Les métaclasses

À quoi sert une métaclasse ?

Lorsqu’on découvre les métaclasses, il est courant de commencer à les utiliser à tort et à travers. Les métaclasses sont un mécanisme complexe, et rendent plus difficile la compréhension du code. Il est alors préférable de s’en passer dans la limite du possible : les chapitres précédents présentent ce qu’il est possible de réaliser sans métaclasses.

L’intérêt principal des métaclasses est d’agir sur les classes lors de leur création, dans la méthode __new__ de la métaclasse. Par exemple pour ajouter à la classe de nouvelles méthodes ou des attributs supplémentaires. Ou encore pour transformer les attributs définis dans le corps de la classe.

Je vous propose plus loin dans ce chapitre l’exemple d’Enum, une implémentation du type énuméré en Python, pour illustrer l’utilité des métaclasses. Un autre exemple est celui des ORM1, où les classes représentent des tables d’une base de données. Les attributs de classe y sont transformés pour réaliser le schéma de la table, et de nouvelles méthodes sont ajoutées pour manipuler les entrées.

Notre première métaclasse

Pour mieux saisir le concept de métaclasse, je vous propose maintenant de créer notre première métaclasse. Nous savons que type est une classe, et possède donc les mêmes caractéristiques que les autres classes, énoncées plus tôt.

1
2
3
4
5
6
7
>>> type.__bases__ # type hérite d'object
(<class 'object'>,)
>>> type(type) # type est une instance de type
<class 'type'>
>>> type('A', (), {}) # on peut instancier type
<class '__main__.A'>
>>> class M(type): pass # on peut hériter de type

Toutes les classes étant des instances de type, on en déduit qu’il faut passer par type pour toute construction de classe. Une métaclasse est donc une classe héritant de type. La classe M du précédent exemple est une nouvelle métaclasse.

Une métaclasse opérera plus souvent lors de la création d’une classe que lors de son initialisation. C’est donc dans le constructeur (méthode __new__) que le tout va s’opérer. Avec une métaclasse M, la méthode M.__new__ sera appelée chaque fois que nous créerons une nouvelle classe de métaclasse M.

Le constructeur d’une métaclasse devra donc prendre les mêmes paramètres que type, et faire appel à ce dernier pour créer notre objet.

1
2
3
4
5
6
7
8
9
>>> class M(type):
...     def __new__(cls, name, bases, dict):
...         return super().__new__(cls, name, bases, dict)
...
>>> A = M('A', (), {})
>>> A
<class '__main__.A'>
>>> type(A)
<class '__main__.M'>

Nous avons ainsi créé notre propre métaclasse, et l’avons utilisée pour instancier une nouvelle classe.

Une autre syntaxe pour instancier notre métaclasse est possible, à l’aide du mot clef class : la métaclasse à utiliser peut être spécifiée à l’aide du paramètre metaclass entre les parenthèses derrière le nom de la classe.

1
2
3
4
5
>>> class B(metaclass=M):
...     pass
...
>>> type(B)
<class '__main__.M'>

Préparation de la classe

Nous avons étudié dans le chapitre sur les accesseurs l’attribut __dict__ des classes. Celui-ci est un dictionnaire, mais à quel moment est-il créé ?

Lors de la définition d’une classe, avant même de s’attaquer à ce que contient son corps, celle-ci est préparée. C’est-à-dire que le dictionnaire __dict__ est instancié, afin d’y stocker tout ce qui sera défini dans le corps.

Par défaut, la préparation d’une classe est donc un appel à dict, qui retourne un dictionnaire vide. Mais si la métaclasse est dotée d’une méthode de classe __prepare__, celle-ci sera appelée en lieu et place de dict. Cette méthode doit toutefois retourner un dictionnaire ou objet similaire. Elle peut par exemple initialiser ce dictionnaire avec des valeurs par défaut.

1
2
3
4
5
6
7
8
9
>>> class M(type):
...     @classmethod
...     def __prepare__(cls, name, bases):
...         return {'test': lambda self: print(self)}
...
>>> class A(metaclass=M): pass
...
>>> A().test()
<__main__.A object at 0x7f886cfd4e10>

Une métaclasse utile

Maintenant que nous savons créer et utiliser des métaclasses, servons-nous-en à bon escient. Il faut bien noter que les métaclasses répondent à des problèmes bien spécifiques, leur utilisation pourrait ne pas vous sembler évidente.

Les énumérations en Python sont implémentées à l’aide de métaclasses.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> from enum import Enum
>>> class Color(Enum):
...     red = 1
...     green = 2
...     blue = 3
...
>>> Color.red
<Color.red: 1>
>>> Color(1) is Color.red
True

En héritant d’Enum, on hérite aussi de sa métaclasse (EnumMeta)

1
2
>>> type(Color)
<class 'enum.EnumMeta'>

Attention d’ailleurs, lorsque vous héritez de plusieurs classes, assurez-vous toujours que leurs métaclasses soient compatibles (la hiérarchie entre les différentes métaclasses doit être linéaire).

Une implémentation simplifiée possible d’Enum est la suivante :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class EnumMeta(type):
    def __new__(cls, name, bases, dict):
        # Cache dans lequel les instances seront stockées
        dict['__mapping__'] = {}
        # Membres de l'énumération (tous les attributs qui ne sont pas du type __foo__)
        members = {k: v for (k, v) in dict.items() if not (k.startswith('__') and k.endswith('__'))}
        enum = super().__new__(cls, name, bases, dict)
        # On instancie toutes les valeurs possibles et on les intègre à la classe
        for key, value in members.items():
            value = enum(value)
            value.name = key # On spécifie le nom du membre
            setattr(enum, key, value) # On le définit comme atribut de classe
        return enum

class Enum(metaclass=EnumMeta):
    def __new__(cls, value):
        # On retourne la valeur depuis le cache si elle existe
        if value in cls.__mapping__:
            return cls.__mapping__[value]
        v = super().__new__(cls)
        v.value = value
        v.name = ''
        # On l'ajoute au cache
        cls.__mapping__[value] = v
        return v
    def __repr__(self):
        return '<{}.{}: {}>'.format(type(self).__name__, self.name, self.value)

Notre exemple précédent avec les couleurs s’exécute de la même manière.


  1. Object-Relational mapping, ou Mapping objet-relationnel, technique fournissant une interface orientée objet aux bases de données. 

Utiliser une fonction comme métaclasse

Par extension, on appelle parfois métaclasse tout callable qui renverrait une classe lorsqu’il serait appelé.

Ainsi, une fonction faisant appel à type serait considérée comme métaclasse.

1
2
3
4
5
6
7
8
>>> def meta(*args):
...     print('enter metaclass')
...     return type(*args)
...
>>> class A(metaclass=meta):
...     pass
...
enter metaclass

Cependant, on ne peut pas à proprement parler de métaclasse, celle de notre classe A étant toujours type.

1
2
>>> type(A)
<class 'type'>

Ce qui fait qu’à l’héritage, l’appel à la métaclasse serait perdu (cet appel n’étant réalisé qu’une fois).

1
2
3
4
5
>>> class B(A):
...     pass
...
>>> type(B)
<class 'type'>

Pour rappel, le comportement avec une « vraie » métaclasse serait le suivant :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
>>> class meta(type):
...     def __new__(cls, *args):
...         print('enter metaclass')
...         return super().__new__(cls, *args)
...
>>> class A(metaclass=meta):
...     pass
...
enter metaclass
>>> type(A)
<class '__main__.meta'>
>>> class B(A):
...     pass
...
enter metaclass
>>> type(B)
<class '__main__.meta'>

TP : Types immutables

Nous avons vu dans le chapitre précédent comment réaliser un type immutable. Nous voulons maintenant aller plus loin, en mettant en place une métaclasse qui nous permettra facilement de créer de nouveaux types immutables.

Déjà, à quoi ressemblerait une classe d’objets immutables ? Il s’agirait d’une classe dont les noms d’attributs seraient fixés à l’avance pour tous les objets. Et les attributs en question ne seraient bien sûr pas modifiables sur les objets. La classe pourrait bien sûr définir des méthodes, mais toutes ces méthodes auraient un accès en lecture seule sur les instances.

On aurait par exemple quelque chose comme :

1
2
3
4
5
class Point(metaclass=ImmutableMeta):
    __fields__ = ('x', 'y')

    def distance(self):
        return (self.x**2 + self.y**2)**0.5
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
>>> p = Point(x=3, y=4)
>>> p.x
3
>>> p.y
4
>>> p.distance()
5.0
>>> p.x = 0
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can´t set attribute
>>> p.z = 0
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Point' object has no attribute 'z'

Hériter de tuple

Plusieurs solutions s’offrent à nous pour mener ce travail. Nous pouvons, comme précédemment, faire hériter tous nos immutables de tuple. Il faudra alors faire pointer chacun des noms d’attributs sur les éléments du tuple, via des propriétés par exemple. On peut simplifier cela avec namedtuple, qui réalise cette partie du travail.

Notre métaclasse se chargerait ainsi d’extraire les champs du type immutable, de créer un namedtuple correspondant, puis en faire hériter notre classe immutable.

1
2
3
4
5
class ImmutableMeta(type):
    def __new__(cls, name, bases, dict):
        fields = dict.pop('__fields__', ())
        bases += (namedtuple(name, fields),)
        return super().__new__(cls, name, bases, dict)

Si l’on implémente une classe Point comme dans l’exemple plus haut, on remarque que la classe se comporte comme convenu jusqu’au p.z = 0. En effet, il nous est ici possible d’ajouter de nouveaux attributs à nos objets, pourtant voulus immutables.

1
2
3
4
>>> p = Point(x=3, y=4)
>>> p.z = 5
>>> p.z
5

Les slots à la rescousse

Comme nous l’avons vu avec les accesseurs, il est possible de définir un ensemble __slots__ des attributs possibles des instances de cette classe. Celui-ci a entre autres pour effet d’empêcher de définir d’autres attributs à nos objets.

C’est donc dans ce sens que nous allons maintenant l’utiliser. Nos types immutables n’ont besoin d’aucun attribut : tout ce qu’ils stockent est contenu dans un tuple, et les accesseurs sont des propriétés. Ainsi, notre métaclasse ImmutableMeta peut simplement définir un attribut __slots__ = () à nos classes.

1
2
3
4
5
6
class ImmutableMeta(type):
    def __new__(cls, name, bases, dict):
        fields = dict.pop('__fields__', ())
        bases += (namedtuple(name, fields),)
        dict['__slots__'] = ()
        return super().__new__(cls, name, bases, dict)

Le problème des méthodes de tuple

Nous avons maintenant entre les mains une classe de types immutables répondant aux critères décrits plus haut. Mais si on y regarde de plus près, on remarque un léger problème : nos classes possèdent des méthodes incongrues héritées de tuple et namedtuple. On voit par exemple des méthodes __getitem__, count ou index qui ne nous sont d’aucune utilité et polluent les classes.__getitem__ est d’autant plus problématique qu’il s’agit d’un opérateur du langage, qui se retrouve automatiquement surchargé.

1
2
3
4
5
6
7
8
>>> dir(Point)
['__add__', '__class__', '__contains__', '__delattr__', '__dict__', '__dir__',
'__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__',
'__getnewargs__', '__getstate__', '__gt__', '__hash__', '__init__', '__iter__',
'__le__', '__len__', '__lt__', '__module__', '__mul__', '__ne__', '__new__',
'__reduce__', '__reduce_ex__', '__repr__', '__rmul__', '__setattr__',
'__sizeof__', '__slots__', '__str__', '__subclasshook__', '_asdict', '_fields',
'_make', '_replace', '_source', 'count', 'index', 'x', 'y']

Alors on peut dans un premier temps choisir d’hériter de tuple plutôt que d’un namedtuple pour faire un premier tri, mais ça ne règle pas le soucis. Et il nous est impossible de supprimer ces méthodes, puisqu’elles ne sont pas définies dans nos classes mais dans une classe parente.

Il faut alors bidouiller, en remplaçant les méthodes par des attributs levant des AttributeError pour faire croire à leur absence, en redéfinissant __dir__ pour les en faire disparaître, etc. Mais nos objets continueront à être des tuples et ces méthodes resteront accessibles d’une manière ou d’une autre (en appelant directement tuple.__getitem__, par exemple).

Nous verrons dans les exercices complémentaires une autre piste pour créer nos propres types immutables.


Et pour terminer ce chapitre, un nouveau rappel vers la documenation Python. Je vous encourage vraiment à la lire le plus possible, elle est très complète et très instructive, bien que parfois un peu bordélique.

Je tenais aussi à présenter ici un tutoriel/guide en 8 parties de Sam&Max dédié au modèle objet, à type et aux métaclasses.

Enfin, je ne peux que vous conseiller de vous pencher sur les sources de CPython pour comprendre les mécanismes internes. Notamment le fichier Objects/typeobject.c qui définit les classes type et object.