Métaclasses

Évaluation paresseuse

Pré-requis : Attributs, Métaclasses

Dans ce TP, nous nous intéresserons à l’évaluation paresseuse (lazy evaluation).

L’évaluation paresseuse, c’est quoi ?

Lorsque vous entrez une expression Python dans votre interpréteur et que celui-ci vous retourne une valeur, on dit que cette expression est évaluée. Évaluer une expression correspond donc à en calculer le résultat.

L’évaluation paresseuse se différencie de l’évaluation standard par rapport au moment où le calcul a lieu. Lors d’une évaluation traditionnelle, le résultat est tout de suite retourné, et peut être manipulé. Dans le cas d’une évaluation paresseuse, celui-ci n’est calculé que lorsqu’il est réellement nécessaire (quand on commence à manipuler l’objet), d’où le terme de paresseux.

En Python par exemple, nous avons étudié plus tôt le concept de générateurs, ils correspondent à de l’évaluation paresseuse : ils ne sont pas évalués avant que l’on ne commence à itérer dessus.

Objectif du TP

Ici, nous voulons réaliser un appel paresseux à une fonction. C’est-à-dire embarquer la fonction à appeler et ses paramètres, mais ne réaliser l’appel qu’au moment où nous avons besoin du résultat.

Par exemple :

1
2
3
4
5
6
7
8
>>> def square(x):
...     return x ** 2
...
>>> a = square(3)
>>> b = square(4)
>>> c = square(5)
>>> a + b == c
True

Nous n’avons réellement besoin des valeurs a, b et c qu’en ligne 5.

Si nous ne voulons pas calculer tout de suite le résultat, il faudra tout de même que notre fonction square retourne quelque chose. Et dans notre exemple, l’objet retourné devra posséder les méthodes __add__ et __eq__. Méthodes qui se chargeront d’effectuer le calcul du carré.

Ce ne sont ici que deux opérateurs, mais il en existe beaucoup d’autres, dont l’énumération serait inutile et fastidieuse, et il va nous falloir tous les gérer.

Opérateurs et méthodes spéciales

Le problème des opérateur en Python, c’est que les appels aux méthodes spéciales sont optimisés et ne passent pas par __getattribute__.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
>>> class A:
...     def __add__(self, rhs):
...         return 0
...     def __getattribute__(self, name):
...         print('getattribute')
...         return super().__getattribute__(name)
...
>>> A() + A()
0
>>> import operator
>>> operator.add(A(), A())
0
>>> A().__add__(A())
getattribute
0

Il va donc nous falloir intégrer toutes les méthodes spéciales à nos objets, et les métaclasses nous seront alors d’une grande aide pour toutes les générer.

Une liste de méthodes spéciales nous est fournie dans la documentation Python : https://docs.python.org/3/reference/datamodel.html#specialnames

  • __new__, __init__, __del__
  • __repr__, __str__, __bytes__, __format__
  • __lt__, __le__, __eq__, __ne__, __gt__, __ge__
  • __hash__, __bool__
  • __getattr__, __getattribute__, __setattr__, __delattr__, __dir__
  • __get__, __set__, __delete__
  • __instancecheck__, __subclasscheck__
  • __call__
  • __len__, __length_hint__, __getitem__, __missing__, __setitem__, __delitem__, __iter__, __reversed__, __contains__
  • __add__, __sub__, __mul__, __matmul__, __truediv__, __floordiv__, __mod__, __divmod__, __pow__, __lshift__, __rshift__, __and__, __xor__, __or__
  • __radd__, __rsub__, __rmul__, __rmatmul__, __rtruediv__, __rfloordiv__, __rmod__, __rdivmod__, __rpow__, __rlshift__, __rrshift__, __rand__, __rxor__, __ror__
  • __iadd__, __isub__, __imul__, __imatmul__, __itruediv__, __ifloordiv__, __imod__, __ipow__, __ilshift__, __irshift__, __iand__, __ixor__, __ior__
  • __neg__, __pos__, __abs__, __invert__, __complex__, __int__, __float__, __round__, __index__
  • __enter__, __exit__
  • __await__, __aiter__, __anext__, __aenter__, __aexit__

Mais celle-ci n’est pas complète, __next__ n’y figure par exemple pas. Je n’ai pas trouvé de liste exhaustive, et c’est donc celle-ci que nous utiliserons. Nous omettrons cependant la première ligne (constructeur, initialiseur et destructeur), car les objets que nous recevrons seront déjà construits.

Il nous faut aussi différencier les opérateurs des autres méthodes spéciales. En effet, les méthodes spéciales associées aux opérateurs peuvent dans certains cas retourner NotImplemented et laisser l’opérateur décider d’un comportement (comme appeler une méthode de l’autre opérande dans le cas d’un opérateur binaire). Pour nous faciliter la tâche et ne pas avoir à gérer nous-même ces comportements, nous ferons donc appel à l’opérateur plutôt qu’à la méthode spéciale. Le module operator nous permettra facilement de savoir si la méthode spéciale est un opérateur, et donc d’agir en conséquence (en vérifiant que la méthode est présente dans operator.__dict__ par exemple).

La solution que je propose est la suivante.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import operator

class LazyMeta(type):
    # Référencement de toutes les méthodes spéciales, ou presque
    specials = [
        '__repr__', '__str__', '__bytes__', '__format__',
        '__lt__', '__le__', '__eq__', '__ne__', '__gt__', '__ge__',
        '__hash__', '__bool__',
        '__getattr__', '__getattribute__', '__setattr__', '__delattr__', '__dir__',
        '__get__', '__set__', '__delete__',
        '__instancecheck__', '__subclasscheck__',
        '__call__',
        '__len__', '__length_hint__', '__getitem__', '__missing__', '__setitem__', '__delitem__',
        '__iter__', '__reversed__', '__contains__',
        '__add__', '__sub__', '__mul__', '__matmul__', '__truediv__', '__floordiv__', '__mod__',
        '__divmod__', '__pow__', '__lshift__', '__rshift__', '__and__', '__xor__', '__or__',
        '__radd__', '__rsub__', '__rmul__', '__rmatmul__', '__rtruediv__', '__rfloordiv__', '__rmod__',
        '__rdivmod__', '__rpow__', '__rlshift__', '__rrshift__', '__rand__', '__rxor__', '__ror__',
        '__iadd__', '__isub__', '__imul__', '__imatmul__', '__itruediv__', '__ifloordiv__', '__imod__',
        '__ipow__', '__ilshift__', '__irshift__', '__iand__', '__ixor__', '__ior__',
        '__neg__', '__pos__', '__abs__', '__invert__', '__complex__', '__int__', '__float__',
        '__round__', '__index__',
        '__enter__', '__exit__',
        '__await__', '__aiter__', '__anext__', '__aenter__', '__aexit__',
        '__next__'
    ]

    def get_meth(methname):
        "Fonction utilisée pour créer une méthode dynamiquement"
        def meth(self, *args, **kwargs):
            # On tente d'accéder à l'objet évalué (value)
            try:
                value = object.__getattribute__(self, 'value')
            # S'il n'existe pas, il nous faut alors le calculer puis le stocker
            except AttributeError:
                value = object.__getattribute__(self, 'expr')()
                object.__setattr__(self, 'value', value)
            # Appel à l'opérateur si la méthode est un opérateur
            if methname in operator.__dict__:
                return getattr(operator, methname)(value, *args, **kwargs)
            # Sinon, appel à la méthode de l'objet
            return getattr(value, methname)(*args, **kwargs)
        return meth

    @classmethod
    def __prepare__(cls, name, bases):
        # On prépare la classe en lui ajoutant toutes les méthodes référencées
        methods = {}
        for methname in cls.specials:
            methods[methname] = cls.get_meth(methname)
        return methods

# Le type Lazy est celui que nous utiliserons pour l'évaluation paresseuse
class Lazy(metaclass=LazyMeta):
    def __init__(self, expr):
        # Il possède une expression (un callable qui retournera l'objet évalué)
        # Les autres méthodes de Lazy sont ajoutées par la métaclasse
        object.__setattr__(self, 'expr', expr)

Nous sommes obligés d’utiliser object.__getattribute__ et object.__setattr__ pour accéder aux attributs, afin de ne pas interférer avec les méthodes redéfinies dans la classe courante.

À l’utilisation, cela donne :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
>>> def _eval():
...     print('evaluated')
...     return 4
...
>>> l = Lazy(_eval)
>>> l + 5
evaluated
9
>>> l + 5
9
>>> l = Lazy(lambda: [])
>>> l
[]
>>> l.append(5)
>>> l
[5]
>>> type(l)
<class '__main__.Lazy'>
>>> class A:
...     pass
...
>>> l = Lazy(lambda: A())
>>> l
<__main__.A object at 0x7f7e3d7376a0>
>>> type(l)
<class '__main__.Lazy'>
>>> l.x = 0
>>> l.x
0
>>> l.y # AttributeError
>>> l + 1 # TypeError
>>> abs(l) # TypeError

Aussi, pour en revenir au TP sur la récursivité terminale, il nous suffirait de faire retourner à notre fonction un objet de type Lazy pour ne plus avoir à différencier call et __call__. Les appels ne seraient alors exécutés, itérativement, qu’à l’utilisation du retour (quand on chercherait à itérer dessus, à l’afficher, ou autre). Il n’y aurait ainsi plus besoin de se soucier de savoir si nous sommes dans un appel récursif ou dans le premier appel.

Types immutables

Pré-requis : Descripteurs, Métaclasses

Je vous avais promis une autre approche pour créer des types immutables, la voici ! Pour rappel, notre première version créait des classes héritant de tuple/namedtuple. Les attributs, ainsi stockés dans une structure immutable, n’étaient alors pas réassignables. Mais nous étions gênés par la présence de trop nombreuses méthodes qui polluaient nos objets.

La seconde version que je vous propose ici est plus étonnante, car elle ne repose pas sur un type immutable, mais sur super. Vous pourriez vous demander ce que super vient faire dans cette histoire. Pour vous l’expliquer, intéressons-nous au fonctionnement des objets de ce type.

Objets super

Dans sa forme complète, un objet super s’initialise avec une classe et une instance (plus ou moins directe) de cette classe.

1
>>> obj = super(tuple, (1, 2, 3))

L’objet ainsi créé se comportera comme une instance de la classe suivante dans le MRO. Dans l’exemple précédent, obj se comportera comme une instance d’object (classe parente de tuple).

Les deux arguments fournis à super se retrouvent stockés dans l’objet.

1
2
3
4
>>> obj.__thisclass__
<class 'tuple'>
>>> obj.__self__
(1, 2, 3)

On trouve aussi un 3ème attribut, __self_class__, le type de __self__. Ce type pouvant être différent de __thisclass__ si l’instance passée à super est l’instance d’une de ses classes filles.

1
2
3
4
5
6
7
>>> class T(tuple): pass
...
>>> obj = super(tuple, T((0, 1, 2)))
>>> obj.__thisclass__
<class 'tuple'>
>>> obj.__self_class__
<class '__main__.T'>

L’intérêt est que ces 3 seuls attributs ne sont pas redéfinissables.

1
2
3
4
>>> obj.__self__ = (2, 3, 4)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: readonly attribute

Aussi, les objets super disposent de peu de méthodes, contrairement aux tuples.

1
2
3
4
5
>>> dir(obj)
['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__',
'__get__', '__getattribute__', '__gt__', '__hash__', '__init__', '__le__', '__lt__',
'__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__self__',
'__self_class__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__thisclass__']

Sur ces 26 éléments, seuls 7 sont définis dans super, les autres appartiennent à object.

1
2
>>> [meth for meth in dir(obj) if getattr(getattr(super, meth, None), '__objclass__', None) is super]
['__get__', '__getattribute__', '__init__', '__repr__', '__self__', '__self_class__', '__thisclass__']

Ainsi, notre nouvelle version de types immutables se basera sur super, en incluant un objet tuple qui stockera les données.

ImmutableMeta

Mais revenons-en à nos métaclasses. Ne nous appuyant ici pas sur namedtuple, il va falloir gérer nous-même la liaison entre les noms d’attributs et les éléments du tuple.

Pour cela, depuis le champ __fields__ définit dans la classe immutable, on générera des propriétés pour chaque nom de champ, en l’associant à un index du tuple.

On utilisera pour cela une méthode field_property, qui sera définie comme méthode statique dans la métaclasse, et recevra un unique paramètre : l’index dans le tuple du champ à indexer.

1
2
3
4
5
@staticmethod
def field_property(i):
    def get_field(self):
        return self.__self__[i] # On accède au tuple pointé par __self__ et à l'élément d'index i
    return property(get_field) # On enveloppe la fonction get_field dans une property

Il nous faudra aussi gérer l’initialiseur de notre classe, associant les arguments positionnels et nommés aux champs de nos objets.

Vient alors la méthode __new__ de la métaclasse, qui se déroulera en plusieurs temps :

  • D’abord, récupérer le champ __fields__ du dict de la classe, et instancier un field_property pour chaque, ajouté au dict ;
  • Ensuite, générer une signature de la méthode __init__, utilisée pour vérifier que les arguments correspondent bien aux champs lors de la construction ;
  • Enfin, définir dans le dict un attribut __slots__ à () pour éviter de pouvoir ajouter d’autres attributs aux instances de nos classes ;

Notre classe ImmutableMeta complète est donc la suivante.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class ImmutableMeta(type):
    def __new__(cls, name, bases, dict):
        fields = dict.pop('__fields__', ())
        # Création des properties associées aux champs
        dict.update({field: cls.field_property(i) for i, field in enumerate(fields)})
        # Création d'une signature artificielle composée des noms de champs
        dict['__signature__'] = inspect.Signature(
          inspect.Parameter(field, inspect.Parameter.POSITIONAL_OR_KEYWORD) for field in fields)
        dict['__slots__'] = ()
        return super().__new__(cls, name, bases, dict)

    @staticmethod
    def field_property(i):
        def get_field(self):
            return self.__self__[i]
        return property(get_field)

Et pour l’utiliser, nous créons une classe Immutable, héritant de super et définissant une méthode __init__ pour initialiser nos objets immutables. Cette méthode devra vérifier les arguments conformément à la signature, puis créer un tuple des attributs, à passer à l’initialiseur parent (celui de super).

1
2
3
4
5
class Immutable(super, metaclass=ImmutableMeta):
    def __init__(self, *args, **kwargs):
        t = self.__signature__.bind(*args, **kwargs).args
        # Rappel : l'initialiseur prend un type et une instance de ce type
        super().__init__(tuple, t)

Il nous suffit alors d’hériter d’Immutable, et de définir un champ __fields__ pour avoir un nouveau type d’objets immutables.

1
2
3
4
5
class Point(Immutable):
    __fields__ = ('x', 'y')

    def distance(self):
        return (self.x**2 + self.y**2)**0.5
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
>>> p = Point(3, 4)
>>> p.x
3
>>> p.y
4
>>> p.distance()
5.0
>>> p.x = 0
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can´t set attribute
>>> p.z = 0
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Point' object has no attribute 'z'

Grâce à super, nous n’avons plus ici accès à [] ou __getitem__ sur nos objets.

1
2
3
4
5
6
7
8
>>> p[0]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'Point' object does not support indexing
>>> p.__getitem__(0)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Point' object has no attribute '__getitem__'

Améliorations

Notre classe est terminée, mais nous pouvons encore lui apporter quelques améliorations.

Premièrement, redéfinir la méthode __repr__ de la classe Immutable. Elle pointe ici sur celle de super, qui ne correspond pas vraiment à notre objet.

On peut choisir de la faire pointer sur object.__repr__ :

1
2
3
>>> Immutable.__repr__ = object.__repr__
>>> Point(1, 2)
<__main__.Point object at 0x7f012d454448>

Ou encore donner un résultat similaire aux named tuples, en écrivant le nom de la classe suivi de ses attributs entre paramètres.

1
2
def __repr__(self):
    return '{}{}'.format(type(self).__name__, repr(self.__self__))
1
2
3
4
>>> Point(1, 2)
Point(1, 2)
>>> Point(x=1, y=2)
Point(1, 2)

Bonus Python 3.6 :

1
2
def __repr__(self):
    return f'{type(self).__name__}{self.__self__!r}'

Seconde amélioration, masquer les méthodes inutiles. Pour cela, on on peut définir une méthode __dir__ dans Immutable. Cette méthode spéciale est celle appelée par la fonction dir. Nous pouvons alors en filtrer les méthodes pour supprimer celles définies par super (comme nous l’avons fait plus tôt en regardant l’attribut __objclass__ des méthodes).

1
2
3
4
5
def __dir__(self):
    d = super().__dir__()
    d = [meth for meth in d
      if getattr(getattr(type(self), meth, None), '__objclass__', None) is not super]
    return d