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)
pourfoo.bar = value
delattr(foo, 'bar')
pourdel 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 :
- On recherche dans
foo.__dict__
la présence d’une clef'bar'
, dont on retourne la valeur si la clef existe ; - On recherche dans les
__dict__
de toutes les classes référencées partype(foo).mro()
, en s’arrêtant à la première valeur trouvée ; - 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 |
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.
- Définition du terme descripteur : https://docs.python.org/3/glossary.html#term-descriptor
- Personnaliser l’accès aux attributs : https://docs.python.org/3/reference/datamodel.html#customizing-attribute-access
- Mise en œuvre des descripteurs : https://docs.python.org/3/howto/descriptor.html
- Protocole des descripteurs : https://docs.python.org/3/reference/datamodel.html#implementing-descriptors
- Fonction
property
: https://docs.python.org/3/library/functions.html#property - Description du MRO : https://www.python.org/download/releases/2.3/mro/