Licence CC BY-SA

Accesseurs et descripteurs

Dernière mise à jour :
Auteur :
Catégorie :

L’expression foo.bar est en apparence très simple : on accède à l’attribut bar d’un objet foo. Cependant, divers mécanismes entrent en jeu pour nous retourner cette valeur, nous permettant d’accéder à des attributs définis à la volée.

Nous allons découvrir dans ce chapitre quels sont ces mécanismes, et comment les manipuler.

Sachez premièrement que foo.bar revient à exécuter

  • getattr(foo, 'bar')

Il s’agit là de la lecture, deux fonctions sont équivalentes pour la modification et la suppression :

  • setattr(foo, 'bar', value) pour foo.bar = value
  • delattr(foo, 'bar') pour del foo.bar

L'attribut de Dana

Que font réellement getattr, setattr et delattr ? Elles appellent des méthodes spéciales de l’objet.

setattr et delattr sont les cas les plus simples, la correspondance est faite avec les méthodes __setattr__ et __delattr__. Ces deux méthodes prennent les mêmes paramètres (en plus de self) que les fonctions auxquelles elles correspondent. __setattr__ prendra donc le nom de l’attribut et sa nouvelle valeur, et __delattr__ le nom de l’attribut.

Quant à getattr, la chose est un peu plus complexe, car deux méthodes spéciales lui correspondent : __getattribute__ et __getattr__. Ces deux méthodes prennent en paramètre le nom de l’attribut. La première est appelée lors de la récupération de tout attribut. La seconde est réservée aux cas où l’attribut n’a pas été trouvé (si __getattribute__ lève une AttributeError).

Ces méthodes sont chargées de retourner la valeur de l’attribut demandé. Il est en cela possible d’implémenter des attributs dynamiquement, en modifiant le comportement des méthodes : par exemple une condition sur le nom de l’attribut pour retourner une valeur particulière.

Par défaut, __getattribute__ retourne les attributs définis dans l’objet (contenus dans son dictionnaire __dict__ que nous verrons plus loin), et lève une AttributeError si l’attribut ne l’est pas.__getattr__ n’est pas présente de base dans l’objet, et n’a donc pas de comportement par défaut. Il est plutôt conseillé de passer par cette dernière pour implémenter nos attributs dynamiques.

Ainsi, il nous suffit de coupler les méthodes de lecture, d’écriture, et/ou de suppression pour disposer d’attributs dynamiques. Il faut aussi penser à relayer les appels au méthodes parentes via super pour utiliser le comportement par défaut quand on ne sait pas gérer l’attribut en question.

Le cas de __getattr__ est un peu plus délicat : n’étant pas implémentée dans la classe object, il n’est pas toujours possible de relayer l’appel. Il convient alors de travailler au cas par cas, en utilisant super si la classe parente implémente __getattr__, ou en levant une AttributeError sinon.

L’exemple suivant présente une classe Temperature dont les instances possèdent deux attributs celsius et fahrenheit qui sont générés à la volée, et non stockés dans l’objet.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class Temperature:
    def __init__(self):
        self.value = 0

    def __getattr__(self, name):
        if name == 'celsius':
            return self.value
        if name == 'fahrenheit':
            return self.value * 1.8 + 32
        raise AttributeError(name)

    def __setattr__(self, name, value):
        if name == 'celsius':
            self.value = value
        elif name == 'fahrenheit':
            self.value = (value - 32) / 1.8
        else:
            super().__setattr__(name, value)

Et à l’utilisation :

1
2
3
4
5
6
7
8
9
>>> t = Temperature()
>>> t.celsius = 37
>>> t.celsius
37
>>> t.fahrenheit
98.6 # Ou valeur approximative
>>> t.fahrenheit = 212
>>> t.celsius
100.0

dict et slots

Le __dict__ dont je parle plus haut est le dictionnaire contenant les attributs d’un objet Python. Par défaut, il contient tous les attributs que vous définissez sur un objet (si vous ne modifiez pas le fonctionnement de setattr). En effet, chaque fois que vous créez un attribut (foo.bar = value), celui-ci est enregistré dans le dictionnaire des attributs de l’objet (foo.__dict__['bar'] = value). La méthode __getattribute__ de l’objet se contente donc de rechercher l’attribut dans le dictionnaire de l’objet et de ses parents (type de l’objet et classes dont ce type hérite).

Les slots sont une seconde manière de procéder, en vue de pouvoir optimiser le stockage de l’objet. Par défaut, lors de la création d’un objet, le dictionnaire __dict__ est créé afin de pouvoir y stocker l’ensemble des attributs. Si la classe définit un itérable __slots__ contenant les noms des attributs possibles de l’objet, une structure dynamique telle que le dictionnaire n’est plus nécessaire, __dict__ ne sera donc pas instancié lors de la création d’un nouvel objet. Notez tout de même que si votre classe définit un __slots__, vous ne pourrez plus définir d’autres attributs sur l’objet que ceux décrits dans les slots.

Je vous invite à consulter la section de la documentation consacrée aux slots pour plus d’informations :https://docs.python.org/3/reference/datamodel.html#slots

MRO

J’évoquais précédemment le comportement de __getattribute__, qui consiste à consulter le dictionnaire de l’objet puis de ses parents. Ce mécanisme est appelé method resolution order ou plus généralement MRO.

Chaque classe que vous définissez possède une méthode mro. Elle retourne un tuple contenant l’ordre des classes à interroger lors de la résolution d’un appel sur l’objet. C’est ce MRO qui définit la priorité des classes parentes lors d’un héritage multiple (quelle classe interroger en priorité), c’est encore lui qui est utilisé lors d’un appel à super, afin de savoir à quelle classe super fait référence. En interne, la méthode mro fait appel à l’attribut __mro__ de la classe.

Le comportement par défaut de foo.__getattribute__('bar') est donc assez simple :

  1. On recherche dans foo.__dict__ la présence d’une clef 'bar', dont on retourne la valeur si la clef existe ;
  2. On recherche dans les __dict__ de toutes les classes référencées par type(foo).mro(), en s’arrêtant à la première valeur trouvée ;
  3. On lève une exception AttributeError si l’attribut n’a pu être trouvé.

Pour bien comprendre le fonctionnement du MRO, je vous propose de regarder quelques exemples d’héritage.

Premièrement, définissons plusieurs classes :

1
2
3
4
5
6
7
class A: pass
class B(A): pass
class C: pass
class D(A, C): pass
class E(B, C): pass
class F(D, E): pass
class G(E, D): pass

Puis observons.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
>>> object.mro()
(<class 'object'>,)
>>> A.mro()
(<class '__main__.A'>, <class 'object'>)
>>> B.mro()
(<class '__main__.B'>, <class '__main__.A'>, <class 'object'>)
>>> C.mro()
(<class '__main__.C'>, <class 'object'>)
>>> D.mro()
(<class '__main__.D'>, <class '__main__.A'>, <class '__main__.C'>, <class 'object'>)
>>> E.mro()
(<class '__main__.E'>, <class '__main__.B'>, <class '__main__.A'>, <class '__main__.C'>,
<class 'object'>)
>>> F.mro()
(<class '__main__.F'>, <class '__main__.D'>, <class '__main__.E'>, <class '__main__.B'>,
<class '__main__.A'>, <class '__main__.C'>, <class 'object'>)
>>> G.mro()
(<class '__main__.G'>, <class '__main__.E'>, <class '__main__.B'>, <class '__main__.D'>,
<class '__main__.A'>, <class '__main__.C'>, <class 'object'>)

On constate bien que les classes les plus à gauche sont proritaires lors d’un héritage, mais aussi que le mécanisme de MRO évite la présence de doublons dans la hiérarchie.

On remarque qu’en cas de doublon, les classes sont placées le plus loin possible du début de la liste : par exemple, A est placée après B et non après D dans le MRO de F.

Cela peut nous poser problème dans certains cas.

1
2
3
4
5
6
>>> class H(A, B): pass
...
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: Cannot create a consistent method resolution
order (MRO) for bases B, A

En effet, nous cherchons à hériter d’abord de A en la plaçant à gauche, mais A étant aussi la mère de B, le MRO souheterait la placer à la fin, ce qui provoque le conflit.

Tout fonctionne très bien dans l’autre sens :

1
2
>>> class H(B, A): pass
...

Les descripteurs

Les descripteurs sont une manière de placer des comportements plus évolués derrière des attributs. En effet, plutôt que toujours recourir à __getattr__ et consorts, ils sont un autre moyen d’avoir des attributs dynamiques. Les propriétés (properties) sont des exemples de descripteurs.

Un descripteur se définit comme attribut d’une classe, et devient accessible en tant qu’attribut de ses instances. Pour cela, le descripteur peut implémenter des méthodes spéciales __get__/__set__/__delete__ qui seront respectivement appelées lors de la lecture/écriture/suppression de l’attribut sur une instance de la classe.

Par exemple, si une classe Foo définit un descripteur de type Descriptor sous son attribut attr, alors, avec foo instance de Foo :

  • foo.attr fera appel à Descriptor.__get__ ;
  • foo.attr = value à Descriptor.__set__ ;
  • et del foo.attr à Descriptor.__delete__.

La méthode __get__ du descripteur prend deux paramètres : instance et owner. instance correspond à l’objet depuis lequel on accède à l’attribut. Dans le cas où l’attribut est récupéré depuis depuis la classe (Foo.attr plutôt que foo.attr), instance vaudra None. C’est alors que owner intervient, ce paramètre contient toujours la classe définissant le descripteur (Foo).

__set__ prend simplement l’instance et la nouvelle valeur, et __delete__ se contente de l’instance. Contrairement à __get__, ces deux dernières méthodes ne peuvent s’utiliser que sur les instances, et non sur la classe1, d’où l’absence du paramètre owner.

Pour reprendre notre exemple précédent sur les températures, nous pourrions avoir deux descripteurs Celsius et Fahrenheit, qui modifieraient à leur manière la valeur de notre objet Temperature.

 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
class Celsius:
    def __get__(self, instance, owner):
        # Dans le cas où on appellerait `Temperature.celsius`
        # On préfère retourner le descripteur lui-même
        if instance is None:
            return self
        return instance.value
    def __set__(self, instance, value):
        instance.value = value

class Fahrenheit:
    def __get__(self, instance, owner):
        if instance is None:
            return self
        return instance.value * 1.8 + 32
    def __set__(self, instance, value):
        instance.value = (value - 32) / 1.8

class Temperature:
    # On instancie les deux attributs de la classe
    celsius = Celsius()
    fahrenheit = Fahrenheit()

    def __init__(self):
        self.value = 0

Je vous laisse exécuter à nouveau les exemples précédents pour constater que le comportement est le même.

La méthode __set_name__

Depuis Python 3.62, les descripteurs peuvent aussi être pourvus d’une méthode __set_name__. Cette méthode est appelée pour chaque assignation d’un descripteur à un attribut dans le corps de la classe. La méthode reçoit en paramètres la classe et le nom de l’attribut auquel le descripteur est assigné.

1
2
3
4
5
6
7
8
>>> class Descriptor:
...     def __set_name__(self, owner, name):
...         print(name)
...
>>> class A:
...     value = Descriptor()
...
value

Le descripteur peut ainsi agir dynamiquement sur la classe en fonction du nom de son attribut.

Nous pouvons imaginer un descripteur PositiveValue, qui assurera qu’un attribut sera toujours positif. Le descripteur stockera ici sa valeur dans un attribut de l’instance, en utilisant pour cela son nom préfixé d’un underscore.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class PositiveValue:
    def __get__(self, instance, owner):
        return getattr(instance, self.attr)

    def __set__(self, instance, value):
        setattr(instance, self.attr, max(0, value))

    def __set_name__(self, owner, name):
        self.attr = '_' + name

class A:
    x = PositiveValue()
    y = PositiveValue()
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
>>> a = A()
>>> a.x = 15
>>> a.x
15
>>> a._x
15
>>> a.x -= 20
>>> a.x
0
>>> a.y = -1
>>> a.y
0

  1. En effet, redéfinir A.attr ou le supprimer ne doit déclencher aucune méthode spéciale du descripteur, ça revient juste à redéfinir/supprimer l’attribut de classe. 

  2. <https://zestedesavoir.com/articles/1540/sortie-de-python-3-6/#principales-nouveautes> 

Les propriétés

Les propriétés (ou properties) sont un moyen de simplifier l’écriture de descripteurs et de leurs 3 méthodes spéciales.

En effet, property est une classe qui, à la création d’un objet, prend en paramètre les fonctions fget, fset et fdel qui seront respectivement appelées par __get__, __set__ et __delete__.

On pourrait ainsi définir une version simplifiée de property comme ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class my_property:
    def __init__(self, fget, fset, fdel):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
    def __get__(self, instance, owner):
        return self.fget(instance)
    def __set__(self, instance, value):
        return self.fset(instance, value)
    def __delete__(self, instance):
        return self.fdel(instance)

Pour faire de my_property un clone parfait de property, il nous faudrait gérer le cas où instance vaut None dans la méthode __get__ ; et permettre à my_property d’être utilisé en tant que décorateur autour du getter. Nous verrons dans la section exercices comment compléter notre classe à cet effet.

Les propriétés disposent aussi de décorateurs getter, setter et deleter pour redéfinir les fonctions fget/fset/fdel.

À l’utilisation, les propriétés nous offrent donc un moyen simple et élégant de réécrire notre classe Temperature.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class Temperature:
    def __init__(self):
        self.value = 0

    @property
    def celsius(self): # le nom de la méthode devient le nom de la propriété
        return self.value
    @celsius.setter
    def celsius(self, value): # le setter doit porter le même nom
        self.value = value

    @property
    def fahrenheit(self):
        return self.value * 1.8 + 32
    @fahrenheit.setter
    def fahrenheit(self, value):
        self.value = (value - 32) / 1.8

Pour plus d’informations sur l’utilisation des propriétés, je vous renvoie ici.

Les méthodes

Les méthodes en Python vous réservent aussi bien des surprises. Si vous avez déjà rencontré les termes de méthodes de classe (class methods), méthodes statiques (static methods), ou méthodes préparées (bound methods), vous avez pu vous demander comment cela fonctionnait.

En fait, les méthodes sont des descripteurs vers les fonctions que vous définissez à l’intérieur de votre classe. Elles sont même ce qu’on appelle des non-data descriptors, c’est-à-dire des descripteurs qui ne définissent ni setter, ni deleter.

Définissons une simple classe A possédant différents types de méthodes.

1
2
3
4
5
6
7
8
9
class A:
    def method(self):
        return self
    @staticmethod
    def staticmeth():
        pass
    @classmethod
    def clsmeth(cls):
        return cls

Puis observons à quoi correspondent les différents accès à ces méthodes.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
>>> a = A() # on crée une instance `a` de `A`
>>> A.method # méthode depuis la classe
<function A.method at 0x7fd412ad5f28>
>>> a.method # méthode depuis l'instance
<bound method A.method of <__main__.A object at 0x7fd412a3ad68>>
>>> A.staticmeth # méthode statique depuis la classe
<function A.staticmeth at 0x7fd412a41048>
>>> a.staticmeth # depuis l'instance
<function A.staticmeth at 0x7fd412a41048>
>>> A.clsmeth # méthode de classe depuis la classe
<bound method type.clsmeth of <class '__main__.A'>>
>>> a.clsmeth # depuis l'instance
<bound method type.clsmeth of <class '__main__.A'>>

On remarque que certains accès retournent des fonctions, et d’autres des bound methods, mais quelle différence ? En fait, la différence survient lors de l’appel, pour le passage du premier paramètre.

Ne vous êtes-vous jamais demandé comment l’objet courant arrivait dans self lors de l’appel d’une méthode ? C’est justement parce qu’il s’agit d’une bound method. C’est en fait une méthode dont le premier paramètre est déjà préparé, et qu’il n’y aura donc pas besoin de spécifier à l’appel. C’est le descripteur qui joue ce rôle, il est le seul à savoir si vous utilisez la méthode depuis une instance ou depuis la classe (instance valant None dans ce second cas), et connaît toujours le premier paramètre à passer (instance, owner, ou rien). Il peut ainsi construire un nouvel objet (bound method), qui lorsqu’il sera appelé se chargera de relayer l’appel à la vraie méthode en lui ajoutant ce paramètre.

Le même comportement est utilisé pour les méthodes de classes, où la classe de l’objet doit être passée en premier paramètre (cls). Le cas des méthodes statiques est en fait le plus simple, il ne s’agit que de fonctions qui ne prennent pas de paramètres spéciaux, donc qui ne nécessitent pas d’être décorées par le descripteur.

On remarque aussi que, A.method retournant une fonction et non une méthode préparée, il nous faudra indiquer une instance lors de l’appel.

Pour rappel, voici comment s’utilisent ces différentes méthodes :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> A.method(a)
<__main__.A object at 0x7fd412a3ad68>
>>> a.method()
<__main__.A object at 0x7fd412a3ad68>
>>> A.staticmeth()
>>> a.staticmeth()
>>> A.clsmeth()
<class '__main__.A'>
>>> a.clsmeth()
<class '__main__.A'>

TP : Méthodes

Pour clore ce chapitre, je vous propose d’implémenter les descripteurs staticmethod et classmethod. J’ajouterai à cela un descripteur method qui reproduirait le comportement par défaut des méthodes en Python.

Pour résumer :

  • Ces trois descripteurs sont de type non-data (n’implémentent que __get__) ;
  • my_staticmethod
    • Retourne la fonction cible, qu’elle soit utilisée depuis la classe ou depuis l’instance ;
  • my_classmethod
    • Retourne une méthode préparée avec la classe en premier paramètre ;
    • Même comportement que l’on utilise la méthode de classe depuis la classe ou l’instance ;
  • my_method
    • Si utilisée depuis la classe, retourne la fonction ;
    • Sinon, retourne une méthode préparée avec l’instance en premier paramètre.

Notez que vous pouvez vous aider du type MethodType (from types import MethodType) pour créer vos bound methods. Il s’utilise très facilement, prenant en paramètres la fonction cible et le premier paramètre de cette fonction.

1
2
3
4
5
class my_staticmethod:
    def __init__(self, func):
        self.func = func
    def __get__(self, instance, owner):
        return self.func
1
2
3
4
5
6
7
from types import MethodType

class my_classmethod:
    def __init__(self, func):
        self.func = func
    def __get__(self, instance, owner):
        return MethodType(self.func, owner)
1
2
3
4
5
6
7
8
9
from types import MethodType

class my_method:
    def __init__(self, func):
        self.func = func
    def __get__(self, instance, owner):
        if instance is None:
            return self.func
        return MethodType(self.func, instance)

La documentation est cette fois bien plus fournie, je vous souhaite donc une bonne lecture. Les liens de ce chapitre sont particulièrement intéressants, notamment conernant le protocole des descripteurs et le MRO.