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 ?
- Les métaclasses
- Utiliser une fonction comme métaclasse
- TP : Types immutables
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.
-
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.
- Définition du terme métaclasse : https://docs.python.org/3/glossary.html#term-metaclass
- Personnalisation de la création de classes : https://docs.python.org/3/reference/datamodel.html#customizing-class-creation
- Classe
type
: https://docs.python.org/3/library/functions.html#type - PEP relative aux métaclasses : https://www.python.org/dev/peps/pep-3115/
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
.