Aller au contenu
Développement medium

Descriptors Python : le protocole __get__/__set__ expliqué

18 min de lecture

logo python

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.

  • 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, staticmethod et 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.

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 = 21
print("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 = 21
lecture de temperature
valeur lue : 21

Point 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.

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 descriptor
y (non-data) : valeur dans le dict de l'instance

Cette 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__ : True
property a __set__ : True
staticmethod get : True
classmethod get : True
fonction get : True
type de decrire : function

Deux 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.

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.

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_unitaire

Le 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 = -5
except 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 : 3
prix_unitaire : 250
ValueError: quantite doit etre positif, recu -5
TypeError: prix_unitaire doit etre un entier, recu str
ValueError: quantite doit etre positif, recu 0

L'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.

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.0
deuxieme acces : 25.0
troisieme acces: 25.0

Ce 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.

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.surnom
print("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 : ada
suppression via __delete__
apres del : None

En 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.

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.

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

6 questions
6 min.
70% requis

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

  • 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, staticmethod et les méthodes sont tous des descriptors ; le self implicite 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.
  • Décorateurs Python : la syntaxe @ qui accompagne souvent la déclaration d'un descriptor comme property.
  • 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.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn