Métaclasses Python

Métaclasses Python

Le terme métaprogrammation fait référence au potentiel d’un programme à se connaître ou à se manipuler. Python prend en charge une forme de métaprogrammation pour les classes appelées métaclasses .

Les métaclasses sont un OOP concept ésotérique, qui se cache derrière pratiquement tout le code Python. Vous les utilisez, que vous en soyez conscient ou non. Pour la plupart, vous n’avez pas besoin d’en être conscient. La plupart des programmeurs Python doivent rarement, voire jamais, penser aux métaclasses.

Lorsque le besoin s’en fait sentir, cependant, Python offre une capacité que tous les langages orientés objet ne prennent pas en charge: vous pouvez vous mettre à l’abri et définir des métaclasses personnalisées. L’utilisation de métaclasses personnalisées est quelque peu controversée, comme le suggère la citation suivante de Tim Peters, le gourou de Python qui est l’auteur du Zen of Python:

_ _ «Les métaclasses sont une magie plus profonde que 99% des utilisateurs devraient s’inquiéter. Si vous vous demandez si vous en avez besoin, vous n’en avez pas (les personnes qui en ont réellement besoin savent avec certitude qu’elles en ont besoin et n’ont pas besoin d’expliquer pourquoi).

  • Tim Peters _ _

Il y a des Pythonistas (comme les amateurs de Python sont connus) qui croient que vous ne devriez jamais utiliser de métaclasses personnalisées. Cela peut aller un peu loin, mais il est probablement vrai que les métaclasses personnalisées ne sont généralement pas nécessaires. S’il n’est pas assez évident qu’un problème les appelle, alors il sera probablement plus propre et plus lisible s’il est résolu de manière plus simple.

Pourtant, la compréhension des métaclasses Python vaut la peine, car elle conduit à une meilleure compréhension des internes des classes Python en général. Vous ne savez jamais: vous vous retrouverez peut-être un jour dans l’une de ces situations où vous savez simplement qu’une métaclasse personnalisée est ce que vous voulez.

*Soyez averti:* Ne manquez pas la suite de ce tutoriel — https://realpython.com/bonus/newsletter-dont-miss-updates/[Cliquez ici pour rejoindre la newsletter Real Python] et vous saurez quand le prochain versement sort.

À l’ancienne vs Classes de nouveau style

Dans le domaine Python, une classe peut être l’une des deux variétés. Aucune terminologie officielle n’a été décidée, ils sont donc appelés officieusement classes anciennes et nouvelles.

Classes à l’ancienne

Avec les classes à l’ancienne, classe et type ne sont pas tout à fait la même chose. Une instance d’une classe à l’ancienne est toujours implémentée à partir d’un seul type intégré appelé + instance +. Si + obj + est une instance d’une classe à l’ancienne, + obj . class + désigne la classe, mais + type (obj) + est toujours + instance +. L’exemple suivant est tiré de Python 2.7:

>>>

>>> class Foo:
...     pass
...
>>> x = Foo()
>>> x.__class__
<class __main__.Foo at 0x000000000535CC48>
>>> type(x)
<type 'instance'>

Classes de nouveau style

Les classes de nouveau style unifient les concepts de classe et de type. Si + obj + est une instance d’une classe de nouveau style, + type (obj) + est identique à + ​​obj . class +:

>>>

>>> class Foo:
...     pass
>>> obj = Foo()
>>> obj.__class__
<class '__main__.Foo'>
>>> type(obj)
<class '__main__.Foo'>
>>> obj.__class__ is type(obj)
True

>>>

>>> n = 5
>>> d = { 'x' : 1, 'y' : 2 }

>>> class Foo:
...     pass
...
>>> x = Foo()

>>> for obj in (n, d, x):
...     print(type(obj) is obj.__class__)
...
True
True
True

Type et classe

Dans Python 3, toutes les classes sont des classes de nouveau style. Ainsi, dans Python 3, il est raisonnable de faire référence au type d’un objet et à sa classe de manière interchangeable.

*Remarque:* En Python 2, les classes sont à l'ancienne par défaut. Avant Python 2.2, les classes de nouveau style n'étaient pas du tout prises en charge. A partir de Python 2.2, ils peuvent être créés mais doivent être explicitement déclarés comme nouveau style.

Rappelez-vous que, en Python, tout est un objet. Les classes sont également des objets. Par conséquent, une classe doit avoir un type. Quel est le type d’une classe?

Considérer ce qui suit:

>>>

>>> class Foo:
...     pass
...
>>> x = Foo()

>>> type(x)
<class '__main__.Foo'>

>>> type(Foo)
<class 'type'>

Le type de + x + est la classe + Foo +, comme vous vous en doutez. Mais le type de + Foo +, la classe elle-même, est + type +. En général, le type de toute classe de nouveau style est + type +.

Le type des classes intégrées que vous connaissez est également + type +:

>>>

>>> for t in int, float, dict, list, tuple:
...     print(type(t))
...
<class 'type'>
<class 'type'>
<class 'type'>
<class 'type'>
<class 'type'>

D’ailleurs, le type de + type + est aussi + type + (oui, vraiment):

>>>

>>> type(type)
<class 'type'>

+ type + est une métaclasse, dont les classes sont des instances. Tout comme un objet ordinaire est une instance d’une classe, toute classe de nouveau style en Python, et donc toute classe en Python 3, est une instance de la métaclasse + type +.

Dans le cas ci-dessus:

  • + x + est une instance de la classe + Foo +.

  • + Foo + est une instance de la métaclasse + type +.

  • + type + est également une instance de la métaclasse + type +, c’est donc une instance de lui-même.

Python Chaîne de classe Python

Définition dynamique d’une classe

La fonction intégrée + type () +, lorsqu’elle reçoit un argument, renvoie le type d’un objet. Pour les classes de nouveau style, c’est généralement la même chose que l’attribut https://docs.python.org/3/library/stdtypes.html#instance.class[object + class + de l’objet]:

>>>

>>> type(3)
<class 'int'>

>>> type(['foo', 'bar', 'baz'])
<class 'list'>

>>> t = (1, 2, 3, 4, 5)
>>> type(t)
<class 'tuple'>

>>> class Foo:
...     pass
...
>>> type(Foo())
<class '__main__.Foo'>

Vous pouvez également appeler + type () + avec trois arguments — + type (<name>, <bases>, <dct>) +:

  • + <nom> + spécifie le nom de la classe. Cela devient l’attribut + nom + de la classe.

  • + <bases> + spécifie un tuple des classes de base dont la classe hérite. Cela devient l’attribut + bases + de la classe.

  • + <dct> + spécifie un dictionnaire d’espace de noms contenant des définitions pour le corps de classe. Cela devient l’attribut + dict + de la classe.

L’appel de + type () + de cette manière crée une nouvelle instance de la métaclasse + type +. En d’autres termes, il crée dynamiquement une nouvelle classe.

Dans chacun des exemples suivants, l’extrait de code supérieur définit une classe dynamiquement avec + type () +, tandis que l’extrait de code ci-dessous définit la classe de la manière habituelle, avec l’instruction + class +. Dans chaque cas, les deux extraits sont fonctionnellement équivalents.

Exemple 1

Dans ce premier exemple, les arguments + <bases> + et + <dct> + passés à + ​​type () + sont tous deux vides. Aucun héritage d’aucune classe parent n’est spécifié et rien n’est initialement placé dans le dictionnaire d’espace de noms. Il s’agit de la définition de classe la plus simple possible:

>>>

>>> Foo = type('Foo', (), {})

>>> x = Foo()
>>> x
<__main__.Foo object at 0x04CFAD50>

>>>

>>> class Foo:
...     pass
...
>>> x = Foo()
>>> x
<__main__.Foo object at 0x0370AD50>

Exemple 2

Ici, + <bases> + est un tuple avec un seul élément + Foo +, spécifiant la classe parente dont + Bar + hérite. Un attribut, + attr +, est initialement placé dans le dictionnaire d’espace de noms:

>>>

>>> Bar = type('Bar', (Foo,), dict(attr=100))

>>> x = Bar()
>>> x.attr
100
>>> x.__class__
<class '__main__.Bar'>
>>> x.__class__.__bases__
(<class '__main__.Foo'>,)

>>>

>>> class Bar(Foo):
...     attr = 100
...

>>> x = Bar()
>>> x.attr
100
>>> x.__class__
<class '__main__.Bar'>
>>> x.__class__.__bases__
(<class '__main__.Foo'>,)

Exemple 3

Cette fois, + <bases> + est à nouveau vide. Deux objets sont placés dans le dictionnaire d’espace de noms via l’argument + <dct> +. Le premier est un attribut nommé + attr + et le second une fonction nommée + attr_val +, qui devient une méthode de la classe définie:

>>>

>>> Foo = type(
...     'Foo',
...     (),
...     {
...         'attr': 100,
...         'attr_val': lambda x : x.attr
...     }
... )

>>> x = Foo()
>>> x.attr
100
>>> x.attr_val()
100

>>>

>>> class Foo:
...     attr = 100
...     def attr_val(self):
...         return self.attr
...

>>> x = Foo()
>>> x.attr
100
>>> x.attr_val()
100

Exemple 4

Seules des fonctions très simples peuvent être définies avec https://dbader.org/blog/python-lambda-functions [+ lambda + en Python]. Dans l’exemple suivant, une fonction légèrement plus complexe est définie en externe puis affectée à + ​​attr_val + dans le dictionnaire d’espace de noms via le nom + f +:

>>>

>>> def f(obj):
...     print('attr =', obj.attr)
...
>>> Foo = type(
...     'Foo',
...     (),
...     {
...         'attr': 100,
...         'attr_val': f
...     }
... )

>>> x = Foo()
>>> x.attr
100
>>> x.attr_val()
attr = 100

>>>

>>> def f(obj):
...     print('attr =', obj.attr)
...
>>> class Foo:
...     attr = 100
...     attr_val = f
...

>>> x = Foo()
>>> x.attr
100
>>> x.attr_val()
attr = 100

Métaclasses personnalisées

Considérez à nouveau cet exemple très usé:

>>>

>>> class Foo:
...     pass
...
>>> f = Foo()

L’expression + Foo () + crée une nouvelle instance de la classe + Foo +. Lorsque l’interpréteur rencontre + Foo () +, les événements suivants se produisent:

  • La méthode + call () + de la classe parent de + Foo + 'est appelée. Puisque `+ Foo + est une classe de nouveau style standard, sa classe parente est la métaclasse + type +, donc la méthode + type + s + call () + `est invoquée.

  • Cette méthode + call () + à son tour appelle ce qui suit:

  • + nouveau () +

  • + init () +

Si + Foo + ne définit pas + new () + et + init () +, les méthodes par défaut sont héritées de l’ascendance de + Foo + '. Mais si `+ Foo + définit ces méthodes, elles remplacent celles de l’ascendance, ce qui permet un comportement personnalisé lors de l’instanciation de + Foo +.

Dans ce qui suit, une méthode personnalisée appelée + new () + est définie et affectée comme méthode + new () + pour + Foo +:

>>>

>>> def new(cls):
...     x = object.__new__(cls)
...     x.attr = 100
...     return x
...
>>> Foo.__new__ = new

>>> f = Foo()
>>> f.attr
100

>>> g = Foo()
>>> g.attr
100

Cela modifie le comportement d’instanciation de la classe + Foo +: chaque fois qu’une instance de + Foo + est créée, par défaut, elle est initialisée avec un attribut appelé + attr +, qui a une valeur de + 100 +. (Un code comme celui-ci apparaît plus généralement dans la méthode + init () + et pas typiquement dans + new () +. Cet exemple est conçu à des fins de démonstration.)

Maintenant, comme cela a déjà été réitéré, les classes sont aussi des objets. Supposons que vous vouliez personnaliser de manière similaire le comportement d’instanciation lors de la création d’une classe comme + Foo +. Si vous deviez suivre le modèle ci-dessus, vous définiriez à nouveau une méthode personnalisée et l’affecteriez en tant que méthode + nouveau () + pour la classe dont + Foo + est une instance. + Foo + est une instance de la métaclasse + type +, donc le code ressemble à ceci:

>>>

# Spoiler alert:  This doesn't work!
>>> def new(cls):
...     x = type.__new__(cls)
...     x.attr = 100
...     return x
...
>>> type.__new__ = new
Traceback (most recent call last):
  File "<pyshell#77>", line 1, in <module>
    type.__new__ = new
TypeError: can't set attributes of built-in/extension type 'type'

Sauf que, comme vous pouvez le voir, vous ne pouvez pas réaffecter la méthode + nouveau () + de la métaclasse + type +. Python ne le permet pas.

C’est probablement aussi bien. + type + est la métaclasse à partir de laquelle toutes les classes de nouveau style sont dérivées. Tu ne devrais vraiment pas te moquer de toute façon. Mais alors, quel recours existe-t-il si vous souhaitez personnaliser l’instanciation d’une classe?

Une solution possible est une métaclasse personnalisée. Essentiellement, au lieu de contourner avec la métaclasse + type +, vous pouvez définir votre propre métaclasse, qui dérive de + type +, et vous pouvez ensuite la contourner à la place.

La première étape consiste à définir une métaclasse qui dérive de + type +, comme suit:

>>>

>>> class Meta(type):
...     def __new__(cls, name, bases, dct):
...         x = super().__new__(cls, name, bases, dct)
...         x.attr = 100
...         return x
...

L’en-tête de définition + classe Meta (type): + spécifie que + Meta + dérive de + type +. Étant donné que + type + est une métaclasse, cela fait également de + Meta + une métaclasse.

Notez qu’une méthode personnalisée + nouveau () + a été définie pour + Meta +. Il n’était pas possible de le faire directement à la métaclasse + type +. La méthode + new () + effectue les opérations suivantes:

  • Délègue via + super () + à la méthode + new () + de la métaclasse parent (+ type +) pour réellement créer une nouvelle classe

  • Attribue l’attribut personnalisé + attr + à la classe, avec une valeur de + 100 +

  • Renvoie la classe nouvellement créée

Maintenant, l’autre moitié du vaudou: Définissez une nouvelle classe + Foo + et spécifiez que sa métaclasse est la métaclasse personnalisée + Meta +, plutôt que la métaclasse standard + type + '. Pour ce faire, utilisez le mot clé `+ metaclass + dans la définition de classe comme suit:

>>>

>>> class Foo(metaclass=Meta):
...     pass
...
>>> Foo.attr
100

_Voila! _ + Foo + a récupéré automatiquement l’attribut + attr + dans la métaclasse + Meta +. Bien sûr, toutes les autres classes que vous définissez de la même manière feront de même:

>>>

>>> class Bar(metaclass=Meta):
...     pass
...
>>> class Qux(metaclass=Meta):
...     pass
...
>>> Bar.attr, Qux.attr
(100, 100)

De la même manière qu’une classe fonctionne comme un modèle pour la création d’objets, une métaclasse fonctionne comme un modèle pour la création de classes. Les métaclasses sont parfois appelées https://en.wikipedia.org/wiki/Factory_(object-oriented_programming)}class factories].

Comparez les deux exemples suivants:

*Object Factory:*

>>>

>>> class Foo:
...     def __init__(self):
...         self.attr = 100
...

>>> x = Foo()
>>> x.attr
100

>>> y = Foo()
>>> y.attr
100

>>> z = Foo()
>>> z.attr
100
*Usine de classe:*

>>>

>>> class Meta(type):
...     def __init__(
...         cls, name, bases, dct
...     ):
...         cls.attr = 100
...
>>> class X(metaclass=Meta):
...     pass
...
>>> X.attr
100

>>> class Y(metaclass=Meta):
...     pass
...
>>> Y.attr
100

>>> class Z(metaclass=Meta):
...     pass
...
>>> Z.attr
100

Est-ce vraiment nécessaire?

Aussi simple que soit l’exemple d’usine de classe ci-dessus, c’est l’essence même du fonctionnement des métaclasses. Ils permettent la personnalisation de l’instanciation de classe.

Pourtant, c’est beaucoup de bruit juste pour conférer l’attribut personnalisé + attr + à chaque classe nouvellement créée. Avez-vous vraiment besoin d’une métaclasse juste pour ça?

En Python, il existe au moins deux autres façons de réaliser efficacement la même chose:

*Héritage simple:*

>>>

>>> class Base:
...     attr = 100
...

>>> class X(Base):
...     pass
...

>>> class Y(Base):
...     pass
...

>>> class Z(Base):
...     pass
...

>>> X.attr
100
>>> Y.attr
100
>>> Z.attr
100
*Décorateur de classe:*

>>>

>>> def decorator(cls):
...     class NewClass(cls):
...         attr = 100
...     return NewClass
...
>>> @decorator
... class X:
...     pass
...
>>> @decorator
... class Y:
...     pass
...
>>> @decorator
... class Z:
...     pass
...

>>> X.attr
100
>>> Y.attr
100
>>> Z.attr
100

Conclusion

Comme le suggère Tim Peters, les métaclasses peuvent facilement basculer dans le domaine de la «solution à la recherche d’un problème». Il n’est généralement pas nécessaire de créer des métaclasses personnalisées. Si le problème actuel peut être résolu de manière plus simple, il devrait probablement l’être. Néanmoins, il est avantageux de comprendre les métaclasses afin de comprendre Python classes en général et de savoir quand une métaclasse est vraiment l’outil approprié à utiliser.