Cours de Python / Valider les attributs avec property et des méthodes

Une méthode représente une opération sur un objet ; une propriété, créée avec property, applique des règles tout en conservant la syntaxe d’un attribut. Un même setter peut ainsi vérifier le montant d’une dépense lors de son initialisation puis lors de chaque affectation par cette propriété.

Séparer interface publique et stockage interne

Dans l’exemple, amount est le nom public utilisé par l’appelant. La valeur est conservée dans un autre attribut, _amount. Le décorateur @property transforme la méthode amount en fonction de lecture, appelée getter ; @amount.setter définit la fonction d’affectation, appelée setter. Ces deux définitions conservent le même nom amount.

Expression Effet dans cet exemple
item.amount Le getter renvoie _amount.
item.amount = 100 Le setter valide 100 avant de le stocker dans _amount.
item.add_fee(500) La méthode calcule un nouveau montant puis l’affecte par le setter.

La syntaxe reste item.amount, sans parenthèses : item.amount() tenterait ici d’appeler le nombre renvoyé par le getter. Les accesseurs sont décrits dans la documentation officielle de property.

Le préfixe _ signale par convention un détail d’implémentation, pas une protection de sécurité. Une affectation directe à _amount peut contourner cette règle. La documentation sur les attributs internes des classes précise les limites de cette convention. Une propriété ne constitue donc pas un coffre-fort pour des secrets.

Exécuter une mise à jour validée et un rejet

Créez un dossier d’exercice et enregistrez le programme complet en UTF-8 sous properties.py. Aucun paquet externe n’est nécessaire. Depuis ce dossier, lancez python properties.py avec l’environnement virtuel du projet sélectionné. Hors environnement virtuel sous macOS ou Ubuntu, utilisez python3 properties.py si python3 est le nom de votre interpréteur.

class Expense:
    def __init__(self, amount):
        self.amount = amount

    @property
    def amount(self):
        return self._amount

    @amount.setter
    def amount(self, value):
        if type(value) is not int or value < 0:
            raise ValueError("amount must be a nonnegative integer")
        self._amount = value

    def add_fee(self, fee):
        self.amount = self.amount + fee

item = Expense(8000)
item.add_fee(500)
print(item.amount)
try:
    item.amount = -1
except ValueError:
    print("rejected")
print(item.amount)

Résultat attendu :

8500
rejected
8500

Le constructeur initialise la dépense à 8000. La méthode add_fee calcule 8500 et passe ce nouveau montant au setter. La tentative d’affectation de -1 déclenche ensuite ValueError ; le gestionnaire affiche rejected, c’est-à-dire « rejeté ». Comme le setter vérifie avant d’écrire, la dernière lecture renvoie toujours 8500.

Dans __init__, self.amount = amount emprunte le même chemin que les affectations ultérieures. Remplacer cette ligne par une écriture directe dans _amount supprimerait le contrôle initial. Cette continuité complète la distinction entre classe, instance et initialisation.

Valider avant de modifier et éviter la récursion

Le test type(value) is not int or value < 0 accepte uniquement une valeur dont le type exact est int et dont la valeur est positive ou nulle. Il rejette donc True, même si bool est un sous-type de int, ainsi que 3.5 et la chaîne "8000". Grâce au court-circuit de or, une valeur d’un autre type est rejetée avant la comparaison avec zéro. Le choix de lever ValueError pour tous ces rejets appartient à cet exemple.

L’écriture self._amount = value vient après le contrôle. En cas de rejet, elle n’est pas exécutée : la valeur précédente reste intacte. Cette organisation protège cette affectation unique ; elle ne garantit pas l’annulation automatique d’autres changements si une méthode plus complexe effectue plusieurs opérations.

Ne remplacez pas cette écriture interne par self.amount = value dans le setter : elle rappellerait ce même setter indéfiniment jusqu’à une erreur de récursion. Le getter doit lui aussi lire _amount, et non rappeler amount. Distinguer le nom public du stockage évite ces deux boucles.

Une propriété sans setter refuse l’affectation ordinaire par son nom public, mais ne rend pas tout l’objet immuable. Pour comparer, dans une copie du fichier, retirez le bloc @amount.setter et remplacez dans __init__ l’affectation initiale par self._amount = amount. Une lecture fonctionne encore ; une affectation à amount échoue avec AttributeError. Dans ce fichier complet, add_fee échoue alors lui aussi lorsqu’il tente d’affecter le montant. Revenez au programme original après l’essai.

Tester la règle du montant et celle des frais

  1. Dans le bloc try existant, remplacez -1 par True. Relancez et vérifiez les trois mêmes lignes : 8500, rejected, 8500.
  2. Faites le même essai avec 3.5, puis avec la chaîne "8000". Chaque rejet doit laisser le montant à 8500.
  3. Essayez 0 dans ce bloc try : cette fois, aucun rejet n’est affiché et la dernière lecture vaut 0. Rétablissez ensuite -1 pour retrouver le résultat initial.
  4. Remplacez seulement item.add_fee(500) par item.add_fee(-500). Le résultat 7500 est accepté par le setter. Rétablissez 500 après observation.

Valider le résultat ne valide pas nécessairement le sens du paramètre fee. La méthode actuelle accepte une réduction négative si le total reste valide ; elle accepte même True, car l’addition à un entier produit un entier. Si votre règle métier exige des frais entiers non négatifs hors booléens, vérifiez fee au début de add_fee, avant le calcul. Un texte tel que "500" provoque quant à lui TypeError pendant l’addition, avant d’atteindre le setter.

Choisissez une propriété pour une valeur que le lecteur s’attend à consulter ou à affecter simplement. Une opération explicite telle que add_fee se lit comme une action. Évitez de dissimuler une requête réseau ou un traitement de fichier volumineux derrière une lecture d’attribut apparemment anodine : la méthode explicite permet de rendre son coût et ses échecs plus visibles.