Autour du codeDevelopper toujours mieux
Posté le

Python et les metaclasses

En python tout est objet et même une classe est objet, mais si une classe est un objet comment est-elle instanciée ?

Vous l'avez compris, les classes sont les instances des métaclasses.

Commençons par un petit jeu, si je veux savoir qui a instancié un objet je peux appeler type sur mon objet voyons ce que cela donne avec les classes.

>>> class Foo:
...     ...
...
>>> foo = Foo()
>>> type(foo)
<class '__main__.Foo'>
>>> type(Foo)
<class 'type'>

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

Par défaut, la métaclasse c'est type !

Ca veux dire que je peux créer des classes avec type !? surprised

Parfaitement smile . En python, type sert à deux choses connaître le type d'un objet et créer dynamiquement de nouvelles classes. Pour ça, il faut donner 3 arguments:

  1. Le nom de la classe
  2. Un tuple contenant ses parents
  3. Ses attributs dans un dictionnaire
>>> Foo = type("Foo", (object,), {"a": 45} )
>>> foo = Foo()
>>> foo.a
45

>>> Foo = type("Foo", (object,), {"a": 45} )
>>> foo = Foo()
>>> foo.a
45

Les méthodes étant des attributs de classe et non d'instance, on peut donc passer des méthodes au 3éme arguments de type.

>>> def __init__(self, a):
...     self.a = a
...
>>> Foo = type('Foo', tuple(), {'__init__': __init__})
>>> foo = Foo(42)
>>> foo.a
42

>>> def __init__(self, a):
...     self.a = a
...
>>> Foo = type('Foo', tuple(), {'__init__': __init__})
>>> foo = Foo(42)
>>> foo.a
42

Créer c'est propre métaclasses

Plus fort encore, on peut créer ses propres métaclasse. Généralement lorsque l'on souhaite modifier le comportement d'une classe on dérive de celle-ci. Il en est de même avec les métaclasses, on va donc dériver de type pour créer notre propre métaclasse qui affichera des informations sur la création de notre classe.

class MetaClasse(type):
    def __new__(mcs, name, bases, attrs):
        print("class '{}' created".format(name))
        print("class bases: {}".format(bases))
        print("class attributs: {}".format(attrs))
        return super().__new__(mcs, name, bases, attrs)

class MetaClasse(type):
    def __new__(mcs, name, bases, attrs):
        print "class '{}' created".format(name)
        print "class bases: {}".format(bases)
        print "class attributs: {}".format(attrs)
        return super(MetaClasse, mcs).__new__(mcs, name, bases, attrs)

Comme vous pouvez le voir j'ai utilisé __new__ et non __init__. Lors d'un appel à __new__, ma classe qui sera instanciée par MetaClasse n'existe pas encore et le premier paramètre de __new__ est MetaClasse. Si vous utilisez la méthode __init__, le premier paramètre sera par contre votre classe.

Habituellement on ne crée pas une classe dynamiquement, on la déclare. Cela ne nous empêche pas de la rattacher à une métaclasse en effet, la déclaration de classe aussi statique soit elle fera quand même travailler type. Pour faire travailler notre métaclasse à la place de type dans une déclaration statique on fait comme ça:

>>> class Foo(metaclass=MetaClasse):
...     ...
...
class 'Foo' created
class bases: ()
class attributs: {'__module__': '__main__', '__qualname__': 'Foo'}

>>> class Foo(object):
...     __metaclass__ = MetaClasse
...
class 'Foo' created
class bases: (<type 'object'>,)
class attributs: {'__module__': '__main__', '__metaclass__': <class '__main__.metaclasse'>}

A quoi ça sert ?

Vous concevez un framework et vous souhaitez que certaine des classes aient une méthode run. Vous pouvez utiliser les métaclasse pour ça.

class MetaFilter(type):
    def __new__(mcs, name, bases, attrs):
        """
        Test lors de la fabrication de la classe s'il y a bien une methode run.
        """
        if 'run' not in attrs:
            raise NotImplementedError("You must redefine 'run' methode in class {}".format(name))

        return super().__new__(mcs, name, bases, attrs)

class MetaFilter(type):
    def __new__(mcs, name, bases, attrs):
        """
        Test lors de la fabrication de la classe s'il y a bien une methode run.
        """
        if 'run' not in attrs:
            raise NotImplementedError("You must redefine 'run' methode in class {}".format(name))

        return super(MetaFilter, mcs).__new__(mcs, name, bases, attrs)

Une classe fille a pour métaclasse la même classe que sa classe mère. On va utiliser ce fait pour créer une classe de base qui sera redéfinie par l'utilisateur final.

class BaseFilter(metaclass=MetaFilter):

    def run(self, value):
        raise NotImplementedError("You must redefine 'run' methode")

class BaseFilter(object):
    __metaclass__ = MetaFilter

    def run(self, value):
        raise NotImplementedError("You must redefine 'run' methode")

Le gros avantage est que l'erreur est indiquée très tôt et qu'une seule fois au démarrage de l'application.

>>> class MyFilter(BaseFilter):
...    ...
...
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
 File "<stdin>", line 7, in __new__
NotImplementedError: You must redefine 'run' methode in class MyFilter

>>> class MyFilter(BaseFilter):
...    pass
...
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
 File "<stdin>", line 7, in __new__
NotImplementedError: You must redefine 'run' methode in class MyFilter

Un autre cas intéressant est de faire un registre pour l'injection de dépendance qui ajoute automatiquement une classes à sa déclaration.

class MetaRegistry(type):
    """
    Registre capturant les classes
    """

    __current_class_filter = {}

    def __init__(cls, name, bases, attrs):
        """
        Les classes filles remplacent les classe de base dans le registre. Les classes
        de bases sont les classes par defaut. Si un utilisateur hérite d'une classe de
        bases, ça classe remplacera la classe de base dans le registre.
        """
        if not bases or object in bases:
            MetaRegistry.__current_class_filter[cls] = cls
        else:
            MetaRegistry.__current_class_filter[bases[0]] = cls

    @classmethod
    def get_class(mcs, class_name):
        return mcs.__current_class_filter[class_name]


class BaseInFilter(metaclass=MetaRegistry):
    """
    Classe de base utilisé par défaut par le framework.
    filtre les données en entrée. Ne fait rien par
    défaut.
    """
    def run(self, value):
        return value


class BaseToPy(metaclass=MetaRegistry):
    """
    Classe de base utilisé par défaut par le framework.
    convertie une valeur en objet python
    str -> str par défaut
    """
    def cast(self, value):
        return value


def valide_input(value):
    """
    Fonction interne au framework utilisant l'injection de dépendance.
    en appelant le MetaRegistry.
    """
    to_py = MetaRegistry.get_class(BaseToPy)()
    in_filter  = MetaRegistry.get_class(BaseInFilter)()
    return to_py.cast(in_filter.run(value))


# L'utilisateur déclare cette classe car le comportement de BaseInFilter ne lui convient pas.
# Elle va aller remplacer BaseInFilter dans le registre
class MyInFilter(BaseInFilter):
    def run(self, value):
        return value.strip().replace(',', '.')

class MetaRegistry(type):
    """
    Registre capturant les classes
    """

    __current_class_filter = {}

    def __init__(cls, name, bases, attrs):
        """
        Les classes filles remplacent les classe de base dans le registre. Les classes
        de bases sont les classes par defaut. Si un utilisateur hérite d'une classe de
        bases, ça classe remplacera la classe de base dans le registre.
        """
        if object in bases:
            MetaRegistry.__current_class_filter[cls] = cls
        else:
            MetaRegistry.__current_class_filter[bases[0]] = cls

    @classmethod
    def get_class(mcs, class_name):
        return mcs.__current_class_filter[class_name]


class BaseInFilter(object):
    """
    Classe de base utilisé par défaut par le framework.
    filtre les données en entrée. Ne fait rien par
    défaut.
    """
    __metaclass__ = MetaRegistry

    def run(self, value):
        return value


class BaseToPy(object):
    """
    Classe de base utilisé par défaut par le framework.
    convertie une valeur en objet python
    str -> str par défaut
    """
    __metaclass__ = MetaRegistry

    def cast(self, value):
        return value

def valide_input(value):
    """
    Fonction interne au framework utilisant l'injection de dépendance.
    """
    to_py = MetaRegistry.get_class(BaseToPy)()
    in_filter  = MetaRegistry.get_class(BaseInFilter)()
    return to_py.cast(in_filter.run(value))


# L'utilisateur déclare cette classe car le comportement de BaseInFilter ne lui convient pas.
# Elle va aller remplacer BaseInFilter dans le registre
class MyInFilter(BaseInFilter):
    def run(self, value):
        return value.strip().replace(',', '.')


print valide_input("1,66")

Voila, vous en savez plus sur les metaclasses. Donc à vos IDE et bon code ! laughing