Accéder à r.width et écrire r.width = 15 avec la syntaxe d’un attribut normal, tout en exécutant de la validation ou du calcul derrière : c’est ce que @property apporte. Et quand cette logique doit se partager entre plusieurs classes, les descripteurs entrent en jeu.
@property : getters et setters sans friction
@property permet d’exposer un attribut calculé ou validé avec la même syntaxe qu’un attribut simple.
class Rectangle:
def __init__(self, width: float, height: float):
self._width = width
self._height = height
@property
def width(self) -> float:
return self._width
@width.setter
def width(self, value: float) -> None:
if value <= 0:
raise ValueError("La largeur doit être positive.")
self._width = value
@property
def area(self) -> float:
return self._width * self._height
L’avantage concret : l’API publique ne change pas. Ajouter de la validation sur un attribut existant ne casse aucun code appelant. @property sans setter crée un attribut en lecture seule. @nom.deleter gère la suppression via del.
@property est un descripteur
Ce que Python fait en coulisses avec @property, c’est créer un objet de type property qui implémente le protocole descripteur. Ces deux formes sont strictement équivalentes :
@property
def width(self):
return self._width
# équivalent exact :
def width(self):
return self._width
width = property(width)
Un descripteur est n’importe quel objet qui définit __get__, __set__ ou __delete__, et qui est assigné comme attribut de classe (pas d’instance). La classe property implémente ces trois méthodes : c’est un descripteur complet.
Quand @property ne suffit plus
@property répond bien à un attribut dans une classe. Si plusieurs classes ont besoin de la même logique de validation, un descripteur personnalisé s’impose. Dupliquer des properties n’est pas la bonne réponse.
class PositiveNumber:
def __set_name__(self, owner, name):
self.public_name = name
self.private_name = f"_{name}"
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.private_name, None)
def __set__(self, instance, value: float) -> None:
if value <= 0:
raise ValueError(f"{self.public_name} doit être un nombre positif.")
setattr(instance, self.private_name, value)
class Rectangle:
width = PositiveNumber()
height = PositiveNumber()
def __init__(self, width: float, height: float):
self.width = width
self.height = height
class Cylinder:
radius = PositiveNumber()
height = PositiveNumber()
def __init__(self, radius: float, height: float):
self.radius = radius
self.height = height
__set_name__ est appelé automatiquement à la création de la classe (Python 3.6+). Il donne au descripteur accès à son propre nom, ce qui évite de le répéter en dur.
Le check instance is None dans __get__ est essentiel : il gère l’accès depuis la classe elle-même (Rectangle.width) en renvoyant le descripteur plutôt que de tenter un getattr(None, ...).
Data descriptor vs non-data descriptor
Un descripteur qui définit __set__ ou __delete__ est un data descriptor. Il a priorité sur le __dict__ de l’instance, ce qui garantit que la validation ne peut pas être contournée par assignation directe à l’instance.
Un descripteur qui n’a que __get__ est un non-data descriptor et peut être écrasé. C’est le cas des fonctions Python : elles deviennent des méthodes liées via ce mécanisme.
Quand utiliser quoi
| Besoin | Solution |
|---|---|
| Validation ou calcul sur un attribut | @property |
| Même logique sur plusieurs attributs ou classes | Descripteur custom |
| Attribut en lecture seule | @property sans setter |
| ORM, frameworks, métaprogrammation | Descripteur custom |
La progression naturelle : commencer par @property. Dès que la logique se répète, extraire un descripteur.
