Python & Métaclasses – cock the hammer
La métaclasse n’a pas toujours très bonne presse en Python. Jugée complexe à comprendre, et donc à utiliser, maintenir, déboguer, … son utilisation est très généralement découragée sous prétexte (souvent justifié) que le langage apporte suffisamment de souplesse pour gérer la plupart des cas auxquels font face les utilisateurs du langage.
Et pourtant, cet espèce d’interdit crée en même temps une sorte d’attirance un peu comparable à une envie adolescente de transgression. On parle des métaclasses, on veut savoir comment les manipuler, on pose des questions dessus durant les entretiens d’embauches, tout en assurant derrière qu’on ne fait pas de ça ici.
Ce rapport ambigu peut se retrouver dans la fameuse citation de Tim Peters, vénérable core développeur de Python et auteur (entre autre) de la PEP 20, « Zen of Python », que personne ne peut s’empêcher de rappeler dès que l’on parle de métaclasses :
Metaclasses are deeper magic than 99% of users should ever worry about. If you wonder whether you need them, you don’t (the people who actually need them know with certainty that they need them, and don’t need an explanation about why)..
Tim Peters
Ce n’est seulement qu’assez récemment que j’ai vraiment compris cette phrase. En plus de 6 années de développement à 80-90% Python, il m’est quelque fois arrivé de me poser devant un problème de pure design de code et de me dire « hmmm… peut-être une métaclasse ? ». Mais finalement le problème se résolvait sans ne serait-ce qu’effleurer ce concept.
D’autant plus que, pendant toutes ces années, aucun exemple d’utilisation de métaclasses ne m’avait jamais réellement parlé : souvent les codes soulignent plus ce que permettent les métaclasses plutôt que ce qu’on en ferait réellement dans un cas concret.
Et soudain, au détour d’une problématique toute simple, le cas évident, le besoin limpide s’offre à moi.
N.B : le code de cet article est écrit en Python 3.6.
Le besoin
Imaginons un instant qu’on développe un ensemble d’exceptions pour un programme quelconque.
Ces exceptions peuvent parfois remonter côté client, il faut donc un mécanisme pour les convertir dans un format exploitable – du JSON en l’occurrence, donc assez basiquement un dictionnaire Python dont certains types devront être explicitement formatés (dates, objets, etc.).
Ces exceptions servent aux développeurs à exprimer des erreurs spécifiques au programme, ajoutées au fil du développement ; ils doivent donc pouvoir les définir facilement. Cependant, vu la contrainte client de convertibilité, il est évident que toutes ces erreurs ne seront pas seulement des définitions de quelques lignes, mais qu’en même temps plusieurs morceaux de codes auront des comportements très similaires.
Solution 1
Face à ça, première idée : on crée, entre développeurs, une convention définissant la structure et les attendus des classes d’exceptions et on la grave quelque part (dans une doc, une classe abstraite, ou de façon plus réaliste, « on s’en souviendra »). Un certain nombre de fonctions utilitaires permettront de facilement convertir des variables vers une forme compatible JSON. Les arguments pourront être vérifiés à l’initialisation des instances, on aura donc des garanties, par exemple sur leur type ou autre, on pourra mettre des valeurs par défaut, etc.
Bref ça répond au besoin.
Mais c’est moche.
Franchement c’est moche : un tiers des lignes sera mangé par des entêtes de fonctions, un autre par des répétitions de code basique de vérification. Jugez un peu :
from datetime import datetime from abc import ABC, abstractmethod from typing import Dict, Any, Optional def convert_date(date): return datetime.timestamp(date) class MyAbstractError(ABC): @abstractmethod def __init__(self, *args, **kwargs): pass @abstractmethod def to_json(self) -> Dict[str, Any]: pass class MyError1(MyAbstractError): error_name = 'Error1' def __init__(self, value1 : Optional[datetime] = None): if value1 is None: self._value1 = datetime.utcnow() else: assert isinstance(value1, datetime), f'{value1} is bad because [...]' self._value1 = value1 def to_json(self) -> Dict[str, Any]: return { 'type': self.error_name, 'value1': convert_date(self._value1) } class MyError2(MyAbstractError): error_name = 'Error2' def __init__(self, value1: str, value2 : bool=True): assert isinstance(value1, str), f'{value1} is bad because [...]' assert isinstance(value2, bool), f'{value2} is bad because [...]' self._value1 = value1 self._value2 = value2 def to_json(self) -> Dict[str, Any]: return { 'type': self.error_name, 'value1': self._value1, 'value2': self._value2, } > error = MyError1(4) AssertionError: 4 is bad because [...] > error = MyError1(datetime.utcnow()) > error.to_json() {'type': 'Error1', 'value1': 1234567890.987654}
Problèmes
On pourrait factoriser plusieurs morceaux évidemment, par exemple définir une fonction générique qui prendrait des itérables de valeurs et informations quelconques afin de centraliser les étapes de vérifications des arguments des __init__
, mais on voit bien que le vrai code, celui qui peut se factoriser, est très minoritaire. On est condamné à produire beaucoup de lignes, même pour définir des exceptions très basiques.
Problème d’ailleurs, rien ne me garantit que mes définitions sont justes : si j’oublie de définir ma méthode to_json
, mon programme s’exécutera tout à fait normalement… jusqu’à ce que mon exception soit instanciée (ce qui peut prendre du temps, voire sortir du bois en production – le truc que tu veux pas – si les cas d’erreurs sont mal testés). Et oui : Python accepte tout à fait qu’une classe aie ses attributs surchargés en dehors de son bloc classique de définition, et du coup abc.ABC
n’a rien à redire de la déclaration incomplète de la classe d’erreur. Par contre à l’instanciation, to_json
sera manquant, et donc TypeError: Can't instantiate abstract class blabla
, fin du game.
Finalement on se rend compte surtout que nos définitions sont polluées par beaucoup de code inintéressant. En tant que développeur du programme, est-on concerné par la soupe interne des fonctions ? On s’en moque. Lorsqu’on lit le module définissant les exceptions, tout ce qu’on veut savoir rapidement c’est :
- quelles exceptions sont à ma disposition ?
- lorsque je trouve celle qui me va, comment je l’instancie (arguments, type, etc.) ?
- si aucune exception ne me convient, comment en définir une nouvelle rapidement ?
Solution 2 – Démêler les spaghetti
Découpler la donnée de son traitement
Déjà le premier truc à faire serait certainement de découper plus strictement les responsabilités des objets manipulés ici. Par exemple voir que beaucoup de code servant à vérifier, convertir, fournir des valeurs par défaut aux arguments, et donc faire de ce code un tout cohérent. Une classe par exemple !
from datetime import datetime from abc import ABC, abstractmethod from typing import Dict, Any, Callable, Optional def convert_date(date): return datetime.timestamp(date) class ErrorArgument: def __init__(self, default=None, type=None, converter=lambda x: x): self._default = default self._type = type self._converter = converter @property def default(self): return self._default() if isinstance(self._default, Callable) else self._default def validate(self, value) -> bool: return (self._type is None) or isinstance(value, self._type) def to_json(self, value): return self._converter(value) class MyAbstractError(ABC): @abstractmethod def __init__(self, *args, **kwargs): pass @abstractmethod def to_json(self) -> Dict[str, Any]: pass class MyError1(MyAbstractError): error_name = 'Error1' value1 = ErrorArgument(datetime.utcnow, datetime, convert_date) def __init__(self, value1 : Optional[datetime] = None): if value1 is not None: assert self.value1.validate(value1), f'{value1} is bad because [...]' self._value1 = value1 else: self._value1 = self.value1.default def to_json(self) -> Dict[str, Any]: return { 'type': self.error_name, 'value1': self.value1.to_json(self._value1) } class MyError2(MyAbstractError): error_name = 'Error2' value1 = ErrorArgument(type=str) value2 = ErrorArgument(default=True, type=bool) def __init__(self, value1: str, value2 : Optional[bool] = None): if value1 is not None: assert self.value1.validate(value1), f'{value1} is bad because [...]' self._value1 = value1 else: self._value1 = self.value1.default if value2 is not None: assert self.value2.validate(value2), f'{value2} is bad because [...]' self._value2 = value2 else: self._value2 = self.value2.default def to_json(self) -> Dict[str, Any]: return { 'type': self.error_name, 'value1': self.value1.to_json(self._value1), 'value2': self.value2.to_json(self._value2) } > error = MyError1(4) AssertionError: 4 is bad because [...] > error = MyError1(datetime.utcnow()) > error.to_json() {'type': 'Error1', 'value1': 1234567890.987654}
On remarque que le code est encore plus long qu’avant, et surtout plus répétitif.
… et compresser
Mais en rajoutant une contrainte (les arguments doivent dorénavant être nommés, ce qui permet de les manipuler de façon un peu plus générique), il peut se factoriser comme ceci :
from datetime import datetime from typing import Dict, Any, Callable def convert_date(date): return datetime.timestamp(date) class ErrorArgument: def __init__(self, default=None, type=None, converter=lambda x: x): self._default = default self._type = type self._converter = converter @property def default(self): return self._default() if isinstance(self._default, Callable) else self._default def validate(self, value) -> bool: return (self._type is None) or isinstance(value, self._type) def to_json(self, value): return self._converter(value) class MyBaseError: def __init__(self, **kwargs): for key in kwargs.keys(): assert key in self.arguments, f'Unexpected argument {key!r}.' for key in [k for (k, v) in self.arguments.items() if v.default is None]: assert key in kwargs, f'Missing argument {key!r}.' self._final_arguments = {} for key, value in self.arguments.items(): if key in kwargs: assert value.validate(kwargs[key]), f'{kwargs[key]} is bad because [...]' self._final_arguments[key] = kwargs[key] else: self._final_arguments[key] = self.arguments[key].default def to_json(self) -> Dict[str, Any]: message = {'type': self.error_name} for key, value in self._final_arguments.items(): message[key] = self.arguments[key].to_json(value) return message class MyError1(MyBaseError): error_name = 'Error1' arguments = { 'value1': ErrorArgument(datetime.utcnow, datetime, convert_date) } class MyError2(MyBaseError): error_name = 'Error2' arguments = { 'value1': ErrorArgument(type=str), 'value2': ErrorArgument(default=True, type=bool) } > error = MyError1(value1=4) AssertionError: 4 is bad because [...] > error = MyError(value1=datetime.utcnow()) > error.to_json() {'type': 'Error1', 'value1': 1234567890.987654}
En faisant correspondre les noms des arguments aux clefs du dictionnaire arguments
défini en tant qu’attribut des classes d’exception, on peut ajouter une classe mère qui va automatiquement vérifier les arguments, mettre des valeurs par défaut, … [tout ce qu’on veut] de façon centralisée, ce qui purge fortement le code des classes finales. On commence ainsi à voir se dégager des définitions d’erreurs purement déclaratives, et c’est une excellente chose.
On aimerait toutefois aller plus loin. La définition explicite du nom de l’erreur par exemple, n’est pas plaisante. On pourrait la forger en accédant à self.__class__.__name__
, mais les nombreux underscores nous font sentir qu’on prend quelque chose à rebours. En effet cela signifierait qu’on modifierait le nom de la classe à partir d’une instance de cette classe, ce qui est franchement un mauvais design.
Les arguments définis dans un dictionnaire sont une autre source d’insatisfaction. La cause est que l’on doit pouvoir les retrouver à partir d’une string afin de matcher sur les arguments du __init__
. Une autre façon de faire serait d’utiliser getattr
, mais personnellement de vieilles cicatrices me rendent son emploi un brin répugnant.
En prenant un peu de recul, on voit que ces problèmes tournent autour du même besoin : manipuler directement les attributs de la classe elle-même, comme ceux d’une instance. Ça tombe bien, car c’est précisément ce que font les métaclasses.
Solution 3 – Fresh as a metaclass
Passons un peu de temps sur de la technique brute pour être clair avec ce qu’on manipule, et revenons à quelques fondamentaux de Python.
Instanciation d’une classe
Une instance de classe – un objet – est créée à partir d’un type classe, à travers la fonction __new__
, qui retourne l’instance de la classe. __init__
reçoit alors cette nouvelle instance et l’initialise (duh). Dans la vie de tout les jours, __new__
est rarement utilisé, sa plus-value par rapport à __init__
étant limitée à des cas assez spécifiques.
class Foo: def __new__(cls): cls.__name__ = 'ModifiedFoo' # dumb example of code return super().__new__(cls) >>> Foo.__name__ 'Foo' >>> Foo() <__main__.Foo at 0x7f69b12b6be0> >>> Foo.__name__ 'ModifiedFoo'
super()
, utilisé dans __new__
, permet d’atteindre les méthodes du type de base de Foo
, qui est par défaut le type Python le plus basique : object
. L’appel super().__new__(cls)
, c’est à dire object.__new__(Foo)
, renvoie une instance de la classe Foo
.
La présence d’object
en tant que type de base de toute classe n’ayant pas de parents peut se voir explicitement en utilisant un autre mot-clef du langage : type
, qui permet de définir complètement une classe (avec son nom, ses héritages, ses attributs, méthodes et tout ce qu’il faut). Il y a en effet équivalence entre ces deux constructions (l’exemple vient tout droit de la doc officielle) :
class X: a = 1 X = type('X', (object,), dict(a=1))
La dernière ligne se lit « attribue à X
un type nommé 'X'
, héritant d’object
, et possédant dans son namespace une variable a
dont la valeur est l’entier 1
« .
Cet exemple nous permet de voir que la déclaration d’une classe se fait par l’appel d’une fonction (ici type
). Et bien évidemment, on peut définir nous-même cette fonction, et donc contrôler totalement ce qui construit la classe.
L’argument metaclass
Cela est permis avec l’argument metaclass
d’une classe. Cet argument, qui est par défaut cette fameuse fonction type
, doit être un callable
, attendre 3 arguments (dans l’ordre : un str
, un tuple
et un dict
), et sa valeur de retour sera la classe elle-même.
Si je veux par exemple afficher toutes les informations d’une déclaration, tout en ne créant pas de classe (c’est à dire en fournissant une métaclasse qui ne retourne rien – même si ce n’est sûrement pas très utile en pratique) :
def meta(name, parents, namespace): print(f'Name: {name}') print(f'Parents: {parents}') print(f'Namespace: {namespace}') class A: pass class B(A, metaclass=meta): def first(self): pass
Dès la déclaration de B
, on aura immédiatement :
Name: B Parents: (<class '__main__.A'>,) Namespace: {'__module__': '__main__', '__qualname__': 'B', 'first': <function B.first at 0x7f0fc5d609d8>}
Et la fonction meta
ne renvoyant rien, donc None
, B
sera … None
:
> type(B) NoneType > B is None True
En général cependant, on voudra au minimum que nos métaclasses renvoient un type
, et on retrouve tout de suite, avec l’emploi précédent de type, ce à quoi correspondent les 3 arguments passés à la métaclasse : le nom de la classe, ses héritages, et les clefs-valeurs de son namespace.
def meta(name, parents, namespace): return type(name, parents, namespace) class A: pass class B(A, metaclass=meta): def first(self): pass > type(B) type > B() <__main__.B at 0x7f0fc5dc2160> > isinstance(B(), A) True
Métaclasse de type classe
Comme on l’a dit, tout callable
peut être utilisé pour cet argument metaclass
; en particulier rien ne l’oblige à être réellement une classe. Mais si on veut utiliser une classe Meta
en tant que métaclasse de A
, alors il faut que Meta
, lorsqu’elle est appelée – donc instanciée -, renvoie un type qui correspond à celui qu’on veut pour A
. Pour cela, pas le choix : il faut passer par __new__
(__init__
ne pouvant que renvoyer None
).
class Meta: def __new__(cls, name, parents, namespace): return type(name, parents, namespace) class A(metaclass=Meta): pass
Ou, si on veut assumer le fait que Meta
, en tant que métaclasse, doit elle-même être un type
(et non pas un object
comme c’est le cas dans le code précédent) :
class Meta(type): def __new__(cls, name, parents, namespace): return super().__new__(cls, name, parents, namespace) class A(metaclass=Meta): pass
Retour au business
Voilà ce que pourrait donner l’utilisation d’une métaclasse pour notre cas :
from datetime import datetime from typing import Dict, Any, Callable, Tuple def convert_date(date): return datetime.timestamp(date) class ErrorArgument: def __init__(self, default=None, type=None, converter=lambda x: x): self._default = default self._type = type self._converter = converter @property def default(self): return self._default() if isinstance(self._default, Callable) else self._default def validate(self, value) -> bool: return (self._type is None) or isinstance(value, self._type) def to_json(self, value): return self._converter(value) class MyMetaError(type): def __new__(cls, name: str, parents: Tuple, namespace: Dict): expected_args = {key: value for (key, value) in namespace.items() if isinstance(value, ErrorArgument)} def error_init(self, **kwargs): for key in kwargs.keys(): assert key in expected_args, f'Unexpected argument {key!r}.' for key in [k for (k,v) in expected_args.items() if v.default is None]: assert key in kwargs, f'Missing argument {key!r}.' self._final_arguments = {} for key, value in expected_args.items(): if key in kwargs: assert value.validate(kwargs[key]), f'{kwargs[key]} is bad because [...]' self._final_arguments[key] = kwargs[key] else: self._final_arguments[key] = expected_args[key].default def error_to_json(self) -> Dict[str, Any]: message = {'type': name} for key, value in self._final_arguments.items(): message[key] = expected_args[key].to_json(value) return message namespace['__init__'] = error_init namespace['to_json'] = error_to_json return super().__new__(cls, name, parents, namespace) class MyBaseError(metaclass=MyMetaError): pass class MyError1(MyBaseError): value1 = ErrorArgument(datetime.utcnow, datetime, convert_date) class MyError2(MyBaseError): value1 = ErrorArgument(type=str) value2 = ErrorArgument(default=True, type=bool) > error = MyError1(value1=4) AssertionError: 4 is bad because [...] > error = MyError1(value1=datetime.utcnow()) > error.to_json() {'type': 'MyError1', 'value1': 1234567890.987654}
Le code n’est même pas tellement plus compliqué que précédemment. Les méthodes __init__
et to_json
de la classe mère MyBaseError
sont déplacées dans la métaclasse, et affectées ensuite à MyBaseError
via son namespace. Plutôt que manipuler l’ancien attribut de classe arguments
, on filtre simplement sur les valeurs du namespace
de type ErrorArgument
. Le reste du code fonctionnel reste identique.
La déclaration des erreurs par contre, est devenue vraiment propre et va droit au but. Imaginons plusieurs dizaines de définition d’exceptions ; en chercher une ou en ajouter est facile, direct et j’oserais même dire agréable.
Conclusion
Bien que verbeux, cette article n’expose qu’un cas de métaclasse assez simple, et plusieurs notions ne sont qu’égratignées voire tues. Si vous voulez des liens plus efficaces, je peux conseiller les suivants :
- Des exemples tirés de bibliothèques massivement utilisées et écrites par des gens qui savent ce qu’ils font, expliqué par Eli Bendersky qui sait ce qu’il dit,
- Pour aller plus loin en s’explosant le cerveau dans la joie (et avec des diagrammes), un article sur les métaclasses et les magic methods.