Les descripteurs Python, ou plus généralement les descripteurs, offrent une technique puissante pour écrire du code réutilisable et partageable entre plusieurs classes. Ils peuvent rappeler l’héritage, mais techniquement, ce n’en est pas. Il s’agit d’un mécanisme générique pour intercepter l’accès aux attributs. Les descripteurs sont le mécanisme sous-jacent des propriétés, des méthodes statiques, des méthodes de classe, de super, etc.
Les descripteurs ont été introduits avec Python 2.2 et, depuis, sont souvent considérés comme une « magie » ayant donné un nouveau style aux classes traditionnelles. Ce sont des classes qui permettent de gérer des propriétés au sein d’une autre classe. Concrètement, elles implémentent les méthodes d’interface __get__(), __set__() et __delete__(), ce qui les rend intéressantes pour de nombreuses raisons. Par exemple, les décorateurs de classes et de propriétés que vous avez sans doute déjà vus en Python.
Pour faire simple, une classe qui implémente __get__(), __set()__ ou __delete()__ pour un objet est appelée un « descripteur ». Pour citer directement la documentation officielle de Python, un descripteur est un attribut d’objet doté d’un comportement de liaison, dont l’accès a été redéfini par les méthodes du protocole de descripteur. Ces méthodes sont __get__(), __set__() et __delete__() (Source).
Le comportement de liaison appliqué aux descripteurs signifie que l’on contrôle la façon dont une valeur peut être affectée, lue (get) ou supprimée pour une variable, un objet ou un jeu de données donné. Toute l’interaction est ainsi liée à cette donnée précise : elle ne s’applique qu’aux données sur lesquelles vous l’avez définie, ce qui la lie à cette partie spécifique de l’état.
On distingue les descripteurs de données et les descripteurs non liés aux données. Si votre descripteur n’implémente que __get__(), c’est un descripteur non lié aux données ; à l’inverse, une implémentation comprenant __set__() et __delete__() est un descripteur de données. Les descripteurs non liés aux données sont uniquement en lecture, tandis que les descripteurs de données sont en lecture et en écriture.
Notez bien que les descripteurs sont attachés à une classe, et non aux instances. Modifier la classe remplace ou supprime le descripteur lui‑même, au lieu de déclencher son code (IBM Developer).
Enfin, une classe de descripteur n’est pas limitée à ces trois méthodes : elle peut contenir d’autres attributs et méthodes en plus de get, set et delete.
Voyons plus en détail les méthodes get, set et delete, en nous inspirant de cette page IBM Developer :
self est l’instance du descripteur que vous créez (Real Python).
object est l’instance de l’objet auquel votre descripteur est rattaché (Real Python).
type est le type de l’objet auquel le descripteur est rattaché (Real Python).
value est la valeur affectée à l’attribut du descripteur. get(self, object, type) set(self, object, value) delete(self, object)
__get__() est invoquée lors de l’accès à l’attribut ou lorsque vous souhaitez extraire une information. Elle renvoie la valeur de l’attribut ou lève une exception AttributeError si l’attribut demandé n’existe pas.
__set__() est appelée lors d’une affectation à l’attribut et définit sa valeur. Elle ne renvoie rien, mais peut lever une exception AttributeError.
__delete__() contrôle l’opération de suppression, c’est‑à‑dire lorsque vous supprimez l’attribut d’un objet. Elle ne renvoie rien.
Passons maintenant à des exemples pour comprendre à quoi servent les descripteurs.
Objectif des descripteurs
Définissons une classe car avec trois attributs : make, model et fuel_cap. Vous utiliserez la méthode __init__() pour initialiser les attributs. Puis vous utiliserez la méthode magique __str__(), qui renverra simplement une chaîne résumant les trois attributs passés à la classe lors de la création de l’objet.
Notez que la méthode __str__() renvoie la représentation textuelle de l’objet. Elle est appelée lorsque l’on invoque print() ou str() sur un objet de la classe.
classCar:def__init__(self,make,model,fuel_cap):
self.make = make
self.model = model
self.fuel_cap = fuel_cap
def__str__(self):return"{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)Propulsé par
BMW model X7with a fuel capacity of-40 ltr.Propulsé par
Minute… quelque chose cloche, non ? La capacité d’un réservoir ne peut pas être négative. Pourtant, Python accepte l’entrée sans erreur. C’est parce que Python est un langage dynamique qui ne fait pas de vérification de type explicite par défaut.
Pour éviter ce problème, ajoutons une condition if dans __init__() pour vérifier si la capacité saisie est valide. Si elle est invalide, levons une exception ValueError.
classCar:def__init__(self,make,model,fuel_cap):
self.make = make
self.model = model
self.fuel_cap = fuel_cap
if self.fuel_cap <0:raise ValueError("Fuel Capacity can never be less than zero")def__str__(self):return"{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)Propulsé par
----------------------------------------ValueErrorTraceback(most recent call last)<ipython-input-22-1c3d23be72f7>in<module>---->1 car2 =Car("BMW","X7",-40)2print(car2)<ipython-input-20-1e154783588d>in__init__(self, make, model, fuel_cap)5 self.fuel_cap = fuel_cap
6if self.fuel_cap <0:---->7 raise ValueError("Fuel Capacity can never be less than zero")89 def __str__(self):ValueError: Fuel Capacity can never be less than zero
Propulsé par
Comme on le voit, tout fonctionne pour l’instant : le programme lève une ValueError si la capacité est négative.
Mais un autre problème peut survenir : et si la fuel capacity était un flottant ou une chaîne ? Et si make ou model étaient des entiers ? Dans tous ces cas, le programme ne lèverait pas l’exception attendue.
classCar:def__init__(self,make,model,fuel_cap):
self.make = make
self.model = model
self.fuel_cap = fuel_cap
if self.fuel_cap <0:raise ValueError("Fuel Capacity can never be less than zero")def__str__(self):return"{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)Propulsé par
-40 model X7with a fuel capacity of40 ltr.Propulsé par
Pour gérer ce cas, vous pourriez ajouter une autre condition if ou utiliser isinstance pour le contrôle de type.
Utilisons cette fois la fonction native isinstance.
classCar:def__init__(self,make,model,fuel_cap):
self.make = make
self.model = model
self.fuel_cap = fuel_cap
ifisinstance(self.make,str):print(self.make)else:raise ValueError("Make of the car can never be an integer")if self.fuel_cap <0:raise ValueError("Fuel Capacity can never be less than zero")def__str__(self):return"{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)Propulsé par
----------------------------------------ValueErrorTraceback(most recent call last)<ipython-input-34-75b08cba454f>in<module>---->1 car2 =Car(-40,"X7",40)2print(car2)<ipython-input-31-175690bf3b98>in__init__(self, make, model, fuel_cap)7print(self.make)8else:---->9 raise ValueError("Make of the car can never be an integer")1011if self.fuel_cap <0:ValueError: Make of the car can never be an integer
Propulsé par
Parfait, cette erreur est elle aussi correctement gérée.
Mais si vous modifiez plus tard l’attribut fuel capacity pour le passer à moins 40, cela ne fonctionnera pas : la vérification de type n’a lieu qu’une seule fois dans __init__(). Comme vous le savez, __init__() est un constructeur appelé uniquement lors de la création de l’objet. La vérification personnalisée échouera donc ensuite.
Illustrons‑le.
classCar:def__init__(self,make,model,fuel_cap):
self.make = make
self.model = model
self.fuel_cap = fuel_cap
ifisinstance(self.make,str):print(self.make)else:raise ValueError("Make of the car can never be an integer")if self.fuel_cap <0:raise ValueError("Fuel Capacity can never be less than zero")def__str__(self):return"{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)Propulsé par
-40 model X7with a fuel capacity of40 ltr.Propulsé par
Et voilà, vous avez contourné la vérification de type.
Imaginez maintenant que vous ayez bien d’autres attributs (consommation, prix, options, etc.) nécessitant aussi un contrôle de type, avec en plus des règles d’accès en lecture seule pour certains d’entre eux. Fastidieux, n’est‑ce pas ?
Heureusement, les descripteurs Python permettent de traiter tout cela efficacement.
Comme vous l’avez vu, toute classe qui implémente les méthodes magiques __get__(), __set()__ ou __delete()__ du protocole de descripteur est un descripteur. Ils vous donnent aussi un contrôle fin sur le fonctionnement d’un attribut : accès en lecture seule, lecture/écriture, etc.
Prolongeons maintenant l’exemple ci‑dessus en ajoutant les méthodes de descripteur Python.
classDescriptor:def__init__(self):
self.__fuel_cap =0def__get__(self, instance, owner):return self.__fuel_cap
def__set__(self, instance, value):ifisinstance(value,int):print(value)else:raise TypeError("Fuel Capacity can only be an integer")if value <0:raise ValueError("Fuel Capacity can never be less than zero")
self.__fuel_cap = value
def__delete__(self, instance):del self.__fuel_cap
Propulsé par
classCar:
fuel_cap = Descriptor()def__init__(self,make,model,fuel_cap):
self.make = make
self.model = model
self.fuel_cap = fuel_cap
def__str__(self):return"{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)Propulsé par
40BMW model X7with a fuel capacity of40 ltr.Propulsé par
Pas d’inquiétude si la classe Descriptor vous paraît obscure : découpons‑la.
La méthode __init__() de Descriptor initialise la variable locale __fuel_cap à zéro. Le double underscore (dunder) en début de nom la rend privée. Ici, il sert aussi à distinguer l’attribut de capacité du descripteur de celui de la classe Car.
Comme vous le savez désormais, __get__() sert à récupérer l’attribut et renvoie la capacité. Elle prend trois arguments : l’objet descripteur, l’instance de la classe qui contient ce descripteur (ici car2) et enfin le propriétaire, c’est‑à‑dire la classe dont l’instance dépend, ici Car. Dans cette méthode, vous retournez simplement la valeur de fuel_cap, définie dans __set__().
__set__() est appelée lors de l’affectation d’une valeur à l’attribut et, contrairement à __get__(), ne renvoie rien. Outre l’objet descripteur lui‑même, elle reçoit l’instance (comme dans __get__()) et la valeur à affecter. Ici, vous vérifiez que la valeur assignée à fuel_cap est bien un entier ; sinon, vous levez une TypeError. Vous vérifiez ensuite qu’elle n’est pas négative ; le cas échéant, vous levez une ValueError. Après ces contrôles, vous mettez à jour fuel_cap avec la valeur.
Enfin, __delete__() est appelée lors de la suppression de l’attribut de l’objet et, comme __set__(), ne renvoie rien.
La classe Car reste inchangée, à ceci près que vous y ajoutez l’instance fuel_cap de la classe Descriptor(). Comme indiqué plus haut, l’instance de descripteur doit être ajoutée en tant qu’attribut de classe, et non d’instance.
Dès que vous affectez la variable locale fuel_cap dans __init__() à l’instance fuel_cap, cela invoque la méthode __set__() du descripteur.
Modifions maintenant la capacité du réservoir avec une valeur négative pour vérifier que l’exception ValueError est bien levée.
-40----------------------------------------ValueErrorTraceback(most recent call last)<ipython-input-115-1c3d23be72f7>in<module>---->1 car2 =Car("BMW","X7",-40)2print(car2)<ipython-input-107-3e1f3e97d3c7>in__init__(self, make, model, fuel_cap)4 self.make = make
5 self.model = model
---->6 self.fuel_cap = fuel_cap
78 def __str__(self):<ipython-input-106-0b252695aeed>in__set__(self, instance, value)1112if value <0:--->13 raise ValueError("Fuel Capacity can never be less than zero")1415 self.__fuel_cap = value
ValueError: Fuel Capacity can never be less than zero
Propulsé par
Si vous vous souvenez ici, la vérification échouait lorsque vous modifiiez l’attribut après coup, car elle n’avait lieu qu’une fois dans __init__(). Essayons maintenant d’affecter une chaîne pour voir si une erreur se produit.
-1----------------------------------------ValueErrorTraceback(most recent call last)<ipython-input-120-dea9dbe96ebe>in<module>---->1 car2.fuel_cap =-1<ipython-input-106-0b252695aeed>in__set__(self, instance, value)1112if value <0:--->13 raise ValueError("Fuel Capacity can never be less than zero")1415 self.__fuel_cap = value
ValueError: Fuel Capacity can never be less than zero
Propulsé par
----------------------------------------TypeErrorTraceback(most recent call last)<ipython-input-121-0b316a9872c6>in<module>---->1 car2.fuel_cap ="BMW"<ipython-input-106-0b252695aeed>in__set__(self, instance, value)8print(value)9else:--->10 raise TypeError("Fuel Capacity can only be an integer")1112if value <0:TypeError: Fuel Capacity can only be an integer
Propulsé par
Impeccable ! Comme vous le voyez, cela fonctionne aussi lors d’une mise à jour ultérieure de l’attribut.
Il subsiste toutefois un léger écueil : lorsque vous créez une nouvelle instance de la classe, la valeur précédente est écrasée. La raison est que les descripteurs sont liés à la classe et non à l’instance.
Voyons cela avec l’exemple ci‑dessous.
car3 = Car("BMW","X7",48)#created a new instance 'car3' with different valuesPropulsé par
BMW model X7with a fuel capacity of48 ltr.Propulsé par
Conclusion
Félicitations pour avoir terminé ce tutoriel.
Ce tutoriel s’adresse aux personnes déjà à l’aise avec Python et souhaitant monter en puissance sur des concepts avancés. Comme bon exercice, vous pouvez chercher comment résoudre le problème d’écrasement entre instances évoqué aujourd’hui.
N’hésitez pas à poser vos questions dans les commentaires ci‑dessous.
Pour aller plus loin en Python, suivez le parcours de compétences complet Python Programming de DataCamp.
Tutoriel complet d'introduction aux boucles Python. Apprenez et pratiquez les boucles while et for, les boucles imbriquées, les mots-clés break et continue, la fonction range et bien plus encore.
Découvrez les fonctions et méthodes des listes Python. Veuillez suivre les exemples de code pour list() et d'autres fonctions et méthodes Python dès maintenant.
Initiez-vous aux structures de données de Python : apprenez-en plus sur les types de données et les structures de données primitives et non primitives, telles que les chaînes de caractères, les listes, les piles, etc.
Les descripteurs Python, ou plus généralement les descripteurs, offrent une technique puissante pour écrire du code réutilisable et partageable entre plusieurs classes. Ils peuvent rappeler l’héritage, mais techniquement, ce n’en est pas. Il s’agit d’un mécanisme générique pour intercepter l’accès aux attributs. Les descripteurs sont le mécanisme sous-jacent des propriétés, des méthodes statiques, des méthodes de classe, de
super, etc.Les descripteurs ont été introduits avec Python 2.2 et, depuis, sont souvent considérés comme une « magie » ayant donné un nouveau style aux classes traditionnelles. Ce sont des classes qui permettent de gérer des propriétés au sein d’une autre classe. Concrètement, elles implémentent les méthodes d’interface
__get__(),__set__()et__delete__(), ce qui les rend intéressantes pour de nombreuses raisons. Par exemple, les décorateurs de classes et de propriétés que vous avez sans doute déjà vus en Python.Pour faire simple, une classe qui implémente
__get__(),__set()__ou__delete()__pour un objet est appelée un « descripteur ». Pour citer directement la documentation officielle de Python, un descripteur est un attribut d’objet doté d’uncomportement de liaison, dont l’accès a été redéfini par les méthodes du protocole de descripteur. Ces méthodes sont__get__(),__set__()et__delete__()(Source).Le
comportement de liaisonappliqué aux descripteurs signifie que l’on contrôle la façon dont une valeur peut être affectée, lue (get) ou supprimée pour une variable, un objet ou un jeu de données donné. Toute l’interaction est ainsiliéeà cette donnée précise : elle ne s’applique qu’aux données sur lesquelles vous l’avez définie, ce qui la lie à cette partie spécifique de l’état.On distingue les descripteurs de données et les descripteurs non liés aux données. Si votre descripteur n’implémente que
__get__(), c’est un descripteur non lié aux données ; à l’inverse, une implémentation comprenant__set__()et__delete__()est un descripteur de données. Les descripteurs non liés aux données sont uniquement en lecture, tandis que les descripteurs de données sont en lecture et en écriture.Notez bien que les descripteurs sont attachés à une classe, et non aux instances. Modifier la classe remplace ou supprime le descripteur lui‑même, au lieu de déclencher son code (IBM Developer).
Enfin, une classe de descripteur n’est pas limitée à ces trois méthodes : elle peut contenir d’autres attributs et méthodes en plus de
get,setetdelete.Voyons plus en détail les méthodes
get,setetdelete, en nous inspirant de cette page IBM Developer :selfest l’instance du descripteur que vous créez (Real Python).objectest l’instance de l’objet auquel votre descripteur est rattaché (Real Python).typeest le type de l’objet auquel le descripteur est rattaché (Real Python).valueest la valeur affectée à l’attribut du descripteur. get(self, object, type) set(self, object, value) delete(self, object)__get__()est invoquée lors de l’accès à l’attribut ou lorsque vous souhaitez extraire une information. Elle renvoie la valeur de l’attribut ou lève une exception AttributeError si l’attribut demandé n’existe pas.__set__()est appelée lors d’une affectation à l’attribut et définit sa valeur. Elle ne renvoie rien, mais peut lever une exception AttributeError.__delete__()contrôle l’opération de suppression, c’est‑à‑dire lorsque vous supprimez l’attribut d’un objet. Elle ne renvoie rien.Passons maintenant à des exemples pour comprendre à quoi servent les descripteurs.
Objectif des descripteurs
Définissons une classe
caravec trois attributs :make,modeletfuel_cap. Vous utiliserez la méthode__init__()pour initialiser les attributs. Puis vous utiliserez la méthode magique__str__(), qui renverra simplement une chaîne résumant les trois attributs passés à la classe lors de la création de l’objet.Notez que la méthode
__str__()renvoie la représentation textuelle de l’objet. Elle est appelée lorsque l’on invoqueprint()oustr()sur un objet de la classe.Comme vous le voyez dans la sortie ci‑dessus, tout semble parfait.
Changeons maintenant la capacité du réservoir à moins 40.
Minute… quelque chose cloche, non ? La capacité d’un réservoir ne peut pas être négative. Pourtant, Python accepte l’entrée sans erreur. C’est parce que Python est un langage dynamique qui ne fait pas de vérification de type explicite par défaut.
Pour éviter ce problème, ajoutons une condition
ifdans__init__()pour vérifier si la capacité saisie est valide. Si elle est invalide, levons une exception ValueError.Comme on le voit, tout fonctionne pour l’instant : le programme lève une
ValueErrorsi la capacité est négative.Mais un autre problème peut survenir : et si la
fuel capacityétait un flottant ou une chaîne ? Et simakeoumodelétaient des entiers ? Dans tous ces cas, le programme ne lèverait pas l’exception attendue.Pour gérer ce cas, vous pourriez ajouter une autre condition
ifou utiliserisinstancepour le contrôle de type.Utilisons cette fois la fonction native
isinstance.Parfait, cette erreur est elle aussi correctement gérée.
Mais si vous modifiez plus tard l’attribut fuel capacity pour le passer à moins 40, cela ne fonctionnera pas : la vérification de type n’a lieu qu’une seule fois dans
__init__(). Comme vous le savez,__init__()est un constructeur appelé uniquement lors de la création de l’objet. La vérification personnalisée échouera donc ensuite.Illustrons‑le.
Et voilà, vous avez contourné la vérification de type.
Imaginez maintenant que vous ayez bien d’autres attributs (consommation, prix, options, etc.) nécessitant aussi un contrôle de type, avec en plus des règles d’accès en lecture seule pour certains d’entre eux. Fastidieux, n’est‑ce pas ?
Heureusement, les descripteurs Python permettent de traiter tout cela efficacement.
Comme vous l’avez vu, toute classe qui implémente les méthodes magiques
__get__(),__set()__ou__delete()__du protocole de descripteur est un descripteur. Ils vous donnent aussi un contrôle fin sur le fonctionnement d’un attribut : accès en lecture seule, lecture/écriture, etc.Prolongeons maintenant l’exemple ci‑dessus en ajoutant les méthodes de descripteur Python.
Pas d’inquiétude si la classe Descriptor vous paraît obscure : découpons‑la.
__init__()de Descriptor initialise la variable locale__fuel_capà zéro. Le double underscore (dunder) en début de nom la rend privée. Ici, il sert aussi à distinguer l’attribut de capacité du descripteur de celui de la classe Car.__get__()sert à récupérer l’attribut et renvoie la capacité. Elle prend trois arguments : l’objet descripteur, l’instance de la classe qui contient ce descripteur (icicar2) et enfin le propriétaire, c’est‑à‑dire la classe dont l’instance dépend, iciCar. Dans cette méthode, vous retournez simplement la valeur defuel_cap, définie dans__set__().__set__()est appelée lors de l’affectation d’une valeur à l’attribut et, contrairement à__get__(), ne renvoie rien. Outre l’objet descripteur lui‑même, elle reçoit l’instance (comme dans__get__()) et la valeur à affecter. Ici, vous vérifiez que la valeur assignée àfuel_capest bien un entier ; sinon, vous levez uneTypeError. Vous vérifiez ensuite qu’elle n’est pas négative ; le cas échéant, vous levez uneValueError. Après ces contrôles, vous mettez à jourfuel_capavec la valeur.__delete__()est appelée lors de la suppression de l’attribut de l’objet et, comme__set__(), ne renvoie rien.La classe
Carreste inchangée, à ceci près que vous y ajoutez l’instancefuel_capde la classeDescriptor(). Comme indiqué plus haut, l’instance de descripteur doit être ajoutée en tant qu’attribut de classe, et non d’instance.Dès que vous affectez la variable locale
fuel_capdans__init__()à l’instancefuel_cap, cela invoque la méthode__set__()du descripteur.Modifions maintenant la capacité du réservoir avec une valeur négative pour vérifier que l’exception ValueError est bien levée.
Si vous vous souvenez ici, la vérification échouait lorsque vous modifiiez l’attribut après coup, car elle n’avait lieu qu’une fois dans
__init__(). Essayons maintenant d’affecter une chaîne pour voir si une erreur se produit.Impeccable ! Comme vous le voyez, cela fonctionne aussi lors d’une mise à jour ultérieure de l’attribut.
Il subsiste toutefois un léger écueil : lorsque vous créez une nouvelle instance de la classe, la valeur précédente est écrasée. La raison est que les descripteurs sont liés à la classe et non à l’instance.
Voyons cela avec l’exemple ci‑dessous.
Si vous affichez l’instance
car2, vous constaterez que ses valeurs ont été écrasées par celles decar3.Conclusion
Félicitations pour avoir terminé ce tutoriel.
Ce tutoriel s’adresse aux personnes déjà à l’aise avec Python et souhaitant monter en puissance sur des concepts avancés. Comme bon exercice, vous pouvez chercher comment résoudre le problème d’écrasement entre instances évoqué aujourd’hui.
N’hésitez pas à poser vos questions dans les commentaires ci‑dessous.
Pour aller plus loin en Python, suivez le parcours de compétences complet Python Programming de DataCamp.
Découvrez aussi notre tutoriel sur les fonctions Python.