
Un descriptor est un objet qui contrôle ce qui se passe quand on lit, écrit ou supprime un attribut d'une autre classe, en implémentant au moins l'une des méthodes __get__, __set__ ou __delete__. C'est le mécanisme bas niveau sur lequel reposent property, classmethod, staticmethod et même les méthodes ordinaires de Python. Comprendre le protocole descriptor permet d'écrire de la validation d'attributs réutilisable, des attributs calculés mis en cache et de la journalisation d'accès, sans dupliquer la logique dans chaque classe. Les exemples ont été exécutés avec Python 3.12.
Ce guide s'adresse aux développeurs qui connaissent déjà la programmation objet en Python et veulent comprendre la machinerie derrière l'accès aux attributs. On y couvre le protocole complet, la distinction cruciale entre data descriptor et non-data descriptor avec son ordre de résolution, le rôle de __set_name__ et trois cas d'usage réels et testés.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Implémenter le protocole descriptor (
__get__,__set__,__delete__). - Distinguer data descriptor et non-data descriptor, et prévoir l'ordre de résolution des attributs.
- Comprendre pourquoi
property,classmethod,staticmethodet les méthodes sont des descriptors. - Utiliser
__set_name__pour connaître le nom de l'attribut décoré. - Écrire un descriptor de validation réutilisable, un attribut lazy avec cache et un descriptor de journalisation.
Qu'est-ce qu'un descriptor
Section intitulée « Qu'est-ce qu'un descriptor »Un descriptor est une instance de classe placée comme attribut de classe dans une autre classe, et qui définit au moins une des trois méthodes du protocole : __get__, __set__ ou __delete__. Quand vous accédez à cet attribut depuis une instance, Python ne renvoie pas l'objet descriptor lui-même : il délègue l'opération à ces méthodes. C'est ce détournement qui rend les descriptors si puissants pour centraliser une logique d'accès.
Les trois méthodes ont une signature précise. __get__(self, instance, owner) est appelée à la lecture : instance est l'objet concret (ou None si l'accès se fait via la classe) et owner est la classe propriétaire. __set__(self, instance, valeur) intercepte l'affectation. __delete__(self, instance) intercepte la suppression avec del. Voici un descriptor de traçage minimal qui range la valeur dans le dictionnaire de l'instance.
class Trace: def __init__(self, nom): self.nom = nom
def __get__(self, instance, owner): print(f"lecture de {self.nom}") return instance.__dict__.get(self.nom)
def __set__(self, instance, valeur): print(f"ecriture de {self.nom} = {valeur}") instance.__dict__[self.nom] = valeur
class Capteur: temperature = Trace("temperature")
c = Capteur()c.temperature = 21print("valeur lue :", c.temperature)L'exécution montre que chaque accès passe par le descriptor, alors que le code appelant écrit c.temperature comme un attribut banal.
ecriture de temperature = 21lecture de temperaturevaleur lue : 21Point important : le descriptor est partagé par toutes les instances de Capteur, puisqu'il vit sur la classe. C'est pourquoi il ne stocke jamais la donnée dans son propre self, mais dans le __dict__ de l'instance reçue en paramètre. Oublier cette règle est le piège numéro un : toutes les instances se marcheraient dessus.
Data descriptor contre non-data descriptor
Section intitulée « Data descriptor contre non-data descriptor »La distinction la plus importante du protocole détermine qui gagne entre le descriptor et le dictionnaire de l'instance. Un data descriptor définit __set__ ou __delete__ (en plus ou non de __get__). Un non-data descriptor ne définit que __get__. Cette différence, purement structurelle, change complètement l'ordre de résolution des attributs.
L'ordre appliqué par Python à la lecture d'un attribut d'instance est le suivant. Les data descriptors de la classe passent en premier, avant même le __dict__ de l'instance. Vient ensuite le __dict__ de l'instance. Puis les non-data descriptors et les autres attributs de classe. Autrement dit, un data descriptor ne peut pas être masqué par une valeur d'instance, alors qu'un non-data descriptor le peut.
class DataDescriptor: def __get__(self, instance, owner): return "valeur du data descriptor"
def __set__(self, instance, valeur): instance.__dict__["x"] = valeur
class NonDataDescriptor: def __get__(self, instance, owner): return "valeur du non-data descriptor"
class Demo: x = DataDescriptor() y = NonDataDescriptor()
d = Demo()d.__dict__["x"] = "valeur dans le dict de l'instance"d.__dict__["y"] = "valeur dans le dict de l'instance"
print("x (data) :", d.x)print("y (non-data) :", d.y)Le résultat rend la règle tangible : malgré une entrée dans d.__dict__, l'attribut x reste servi par son data descriptor, tandis que y est masqué par la valeur d'instance.
x (data) : valeur du data descriptory (non-data) : valeur dans le dict de l'instanceCette hiérarchie n'est pas une subtilité gratuite : elle est le fondement de deux patterns opposés. La validation exige un data descriptor, pour garantir que chaque écriture soit contrôlée. Le cache lazy exploite au contraire un non-data descriptor, pour qu'une valeur écrite dans l'instance court-circuite le calcul lors des accès suivants.
property, classmethod, staticmethod et les méthodes sont des descriptors
Section intitulée « property, classmethod, staticmethod et les méthodes sont des descriptors »L'intérêt de connaître ce protocole, c'est qu'il n'a rien d'exotique : vous l'utilisez déjà sans le savoir. Les briques les plus courantes de la programmation objet en Python sont implémentées comme des descriptors. La preuve tient en une inspection des méthodes du protocole sur les objets rangés dans le __dict__ de la classe.
class Cercle: def __init__(self, rayon): self.rayon = rayon
@property def aire(self): return 3.14159 * self.rayon ** 2
@staticmethod def unite(): return "cm2"
@classmethod def unitaire(cls): return cls(1)
def decrire(self): return f"cercle de rayon {self.rayon}"
print("property a __get__ :", hasattr(Cercle.__dict__["aire"], "__get__"))print("property a __set__ :", hasattr(Cercle.__dict__["aire"], "__set__"))print("staticmethod get :", hasattr(Cercle.__dict__["unite"], "__get__"))print("classmethod get :", hasattr(Cercle.__dict__["unitaire"], "__get__"))print("fonction get :", hasattr(Cercle.__dict__["decrire"], "__get__"))print("type de decrire :", type(Cercle.__dict__["decrire"]).__name__)Chaque ligne confirme que ces objets exposent bien le protocole.
property a __get__ : Trueproperty a __set__ : Truestaticmethod get : Trueclassmethod get : Truefonction get : Truetype de decrire : functionDeux enseignements majeurs. D'abord, property est un data descriptor : il définit __get__ et __set__, ce qui explique qu'une propriété prime toujours sur le dictionnaire d'instance et qu'affecter sans setter lève une erreur. Ensuite, une fonction ordinaire est un non-data descriptor : son __get__ est précisément ce qui transforme, au moment de l'accès, la fonction en méthode liée à l'instance. Le fameux self que vous ne passez jamais explicitement est injecté là, par le protocole descriptor.
set_name pour connaître le nom de l'attribut
Section intitulée « set_name pour connaître le nom de l'attribut »Dans le premier exemple, on passait le nom "temperature" à la main au constructeur du descriptor. C'est fragile et redondant : le nom est déjà écrit à gauche du signe égal. Depuis Python 3.6 (PEP 487), la méthode __set_name__(self, owner, nom) résout ce problème. Python l'appelle automatiquement à la création de la classe, en lui passant la classe propriétaire et le nom sous lequel le descriptor est déclaré.
Cela permet au descriptor de calculer, une fois pour toutes, le nom de la clé où il rangera la donnée dans l'instance. La convention consiste à préfixer par un tiret bas pour éviter toute collision avec le nom public. C'est exactement ce que fait le descriptor de validation de la section suivante, sans jamais que l'utilisateur ait à répéter le nom de l'attribut.
Un descriptor de validation réutilisable
Section intitulée « Un descriptor de validation réutilisable »Le cas d'usage le plus répandu est la validation d'attributs typés et bornés. Plutôt que de répéter un setter de property sur chaque champ, on écrit un descriptor EntierPositif et on le pose sur autant d'attributs que nécessaire. Chaque affectation invalide lève une exception avant que la donnée corrompue n'entre dans l'objet. Comme il définit __set__, c'est un data descriptor : aucune écriture ne peut le contourner.
class EntierPositif: def __set_name__(self, owner, nom): self.nom_prive = f"_{nom}" self.nom_public = nom
def __get__(self, instance, owner): if instance is None: return self return getattr(instance, self.nom_prive)
def __set__(self, instance, valeur): if not isinstance(valeur, int): raise TypeError( f"{self.nom_public} doit etre un entier, recu {type(valeur).__name__}" ) if valeur <= 0: raise ValueError(f"{self.nom_public} doit etre positif, recu {valeur}") setattr(instance, self.nom_prive, valeur)
class Commande: quantite = EntierPositif() prix_unitaire = EntierPositif()
def __init__(self, quantite, prix_unitaire): self.quantite = quantite self.prix_unitaire = prix_unitaireLe test instance is None dans __get__ gère l'accès via la classe (par exemple Commande.quantite), où l'on veut récupérer le descriptor lui-même plutôt que planter. Vérifions maintenant que les affectations valides passent et que les invalides sont rejetées à la source.
c = Commande(quantite=3, prix_unitaire=250)print("quantite :", c.quantite)print("prix_unitaire :", c.prix_unitaire)
try: c.quantite = -5except ValueError as e: print("ValueError:", e)
try: c.prix_unitaire = "cher"except TypeError as e: print("TypeError:", e)
try: Commande(quantite=0, prix_unitaire=10)except ValueError as e: print("ValueError:", e)La sortie confirme le comportement attendu : les valeurs correctes sont stockées, et toute affectation invalide est interceptée, y compris celle qui passe par le constructeur.
quantite : 3prix_unitaire : 250ValueError: quantite doit etre positif, recu -5TypeError: prix_unitaire doit etre un entier, recu strValueError: quantite doit etre positif, recu 0L'atout décisif est la réutilisation : la règle « entier strictement positif » est écrite une seule fois. Le même descriptor sécurise quantite, prix_unitaire et n'importe quel autre champ d'une autre classe, sans copier-coller. C'est le patron que reprennent des bibliothèques de modélisation de données comme Pydantic ou les dataclasses validées, en le poussant à grande échelle.
Un attribut calculé et mis en cache
Section intitulée « Un attribut calculé et mis en cache »Le deuxième cas classique est l'attribut lazy : une valeur coûteuse à calculer, que l'on veut produire une seule fois au premier accès puis mémoriser. Ici on veut au contraire un non-data descriptor : il ne définit que __get__. Après le premier calcul, il écrit le résultat dans le __dict__ de l'instance, et grâce à l'ordre de résolution vu plus haut, cette valeur d'instance masque le descriptor pour tous les accès suivants. Le calcul ne se relance jamais.
class ProprieteLazy: def __init__(self, fonction): self.fonction = fonction self.nom = fonction.__name__
def __set_name__(self, owner, nom): self.nom = nom
def __get__(self, instance, owner): if instance is None: return self print(f"calcul de {self.nom} (une seule fois)") valeur = self.fonction(instance) instance.__dict__[self.nom] = valeur return valeur
class JeuDeDonnees: def __init__(self, nombres): self.nombres = nombres
@ProprieteLazy def moyenne(self): return sum(self.nombres) / len(self.nombres)
jeu = JeuDeDonnees([10, 20, 30, 40])print("premier acces :", jeu.moyenne)print("deuxieme acces :", jeu.moyenne)print("troisieme acces:", jeu.moyenne)Le message de calcul n'apparaît qu'une fois, ce qui prouve que le cache fonctionne par masquage du non-data descriptor.
calcul de moyenne (une seule fois)premier acces : 25.0deuxieme acces : 25.0troisieme acces: 25.0Ce patron porte un nom dans la bibliothèque standard : functools.cached_property en est l'implémentation officielle depuis Python 3.8. La réécrire soi-même n'a d'intérêt que pour comprendre le mécanisme ou ajouter un comportement spécifique, comme une invalidation manuelle du cache. En production, préférez functools.cached_property : c'est l'implémentation standard, éprouvée et maintenue. Notez que depuis Python 3.12 elle ne pose plus de verrou : sous plusieurs threads, le calcul initial peut s'exécuter plus d'une fois.
Journaliser les accès à un attribut
Section intitulée « Journaliser les accès à un attribut »Le troisième usage concret est la journalisation d'accès : tracer chaque lecture, écriture ou suppression d'un champ sensible, par exemple pour un audit de sécurité ou du débogage. Un data descriptor complet, avec les trois méthodes du protocole, capture tout le cycle de vie de l'attribut. La méthode __delete__ intercepte le del, ce qui permet de journaliser aussi les suppressions.
class Champ: def __set_name__(self, owner, nom): self.nom_prive = f"_{nom}"
def __get__(self, instance, owner): if instance is None: return self return getattr(instance, self.nom_prive, None)
def __set__(self, instance, valeur): setattr(instance, self.nom_prive, valeur)
def __delete__(self, instance): print("suppression via __delete__") delattr(instance, self.nom_prive)
class Profil: surnom = Champ()
p = Profil()p.surnom = "ada"print("avant del :", p.surnom)del p.surnomprint("apres del :", p.surnom)La sortie montre le cycle complet, du renseignement à la suppression tracée, puis la lecture qui renvoie la valeur par défaut.
avant del : adasuppression via __delete__apres del : NoneEn pratique, on remplacerait les print par un appel au module logging, avec un niveau et un format adaptés. L'avantage du descriptor est de centraliser cette instrumentation : le code métier continue d'écrire p.surnom = "ada" sans se soucier de l'audit, qui vit entièrement dans le descriptor.
Pièges courants
Section intitulée « Pièges courants »Le premier piège est le stockage dans le descriptor. Comme il est partagé par toutes les instances, ranger la valeur dans self ferait que toutes les instances partagent le même état. La donnée va toujours dans instance.__dict__ ou via setattr sur l'instance, jamais sur le descriptor.
Le deuxième piège est la confusion data / non-data. Choisir un non-data descriptor pour de la validation laisserait une écriture directe dans instance.__dict__ contourner le contrôle. À l'inverse, un data descriptor casserait le pattern de cache lazy, puisqu'il primerait toujours sur la valeur mémorisée. Le choix des méthodes du protocole est la conception.
Le troisième piège est la récursion infinie dans __get__ ou __set__ quand on lit ou écrit l'attribut public au lieu d'une clé privée distincte. Enfin, un descriptor ne fonctionne que posé comme attribut de classe : une instance de descriptor rangée dans instance.__dict__ reste un objet ordinaire, sans aucun déclenchement du protocole.
Contrôle de connaissances
Section intitulée « Contrôle de connaissances »Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.
Contrôle de connaissances
Validez vos connaissances avec ce quiz interactif
Informations
- Le chronomètre démarre au clic sur Démarrer
- Questions à choix multiples, vrai/faux et réponses courtes
- Vous pouvez naviguer entre les questions
- Les résultats détaillés sont affichés à la fin
Lance le quiz et démarre le chronomètre
Vérification
(0/0)Profil de compétences
Quoi faire maintenant
Ressources pour progresser
Des indices pour retenter votre chance ?
Nouveau quiz complet avec des questions aléatoires
Retravailler uniquement les questions ratées
Retour à la liste des certifications
À retenir
Section intitulée « À retenir »- Un descriptor est un objet posé en attribut de classe qui implémente
__get__,__set__ou__delete__pour piloter l'accès à un attribut. - Un data descriptor définit
__set__ou__delete__et prime sur le__dict__de l'instance ; un non-data descriptor ne définit que__get__et peut être masqué par une valeur d'instance. - L'ordre de résolution est : data descriptor, puis dict d'instance, puis non-data descriptor et attributs de classe.
property,classmethod,staticmethodet les méthodes sont tous des descriptors ; leselfimplicite vient du__get__des fonctions.__set_name__(Python 3.6+) fournit automatiquement le nom de l'attribut, ce qui évite de le répéter.- La validation demande un data descriptor ; l'attribut lazy avec cache exploite un non-data descriptor ; la journalisation utilise les trois méthodes du protocole.
- Ne jamais stocker la donnée dans le descriptor lui-même : elle va dans l'instance.
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Décorateurs Python : la syntaxe
@qui accompagne souvent la déclaration d'un descriptor commeproperty. - Programmation orientée objet avancée : héritage,
super()et attributs de classe, le contexte où vivent les descriptors. - Dataclasses Python : une couche de plus haut niveau qui génère champs et validation à partir d'annotations.
- Type hints en Python : annoter les attributs pilotés par un descriptor pour documenter le type attendu.
- Le module logging : brancher une vraie journalisation dans un descriptor d'audit plutôt que des
print. - Les tests avancés : soumettre un descriptor de validation à des tests basés sur les propriétés pour couvrir tous les cas limites.