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

BesoinSolution
Validation ou calcul sur un attribut@property
Même logique sur plusieurs attributs ou classesDescripteur custom
Attribut en lecture seule@property sans setter
ORM, frameworks, métaprogrammationDescripteur custom

La progression naturelle : commencer par @property. Dès que la logique se répète, extraire un descripteur.