Python & Métaclasses – cock the hammer

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

Utilisation d’une métaclasse – métaphore

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 :

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 !

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 :

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.

On peut parfois savoir qu’on est parti sur un mauvais design avant même de l’essayer IRL.

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, 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.

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) :

La dernière ligne se lit « attribue à X une 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 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) :

Dès la déclaration de B, on aura immédiatement :

Et la fonction meta ne renvoyant rien, donc None, B sera … None :

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.

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).

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) :

Retour au business

Voilà ce que pourrait donner l’utilisation d’une métaclasse pour notre cas :

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 :

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *