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 :

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.

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

Laisser un commentaire

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

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.