Les objets au travail : état, masquage de l'information, properties et une classe complète

Objectifs de la leçon
  • Travailler avec une liste d’objets — parcourir, rechercher et trier — et avec un objet qui contient un objet
  • Expliquer ce qui se passe quand deux noms pointent vers le même objet
  • Cacher des données derrière une interface publique, et distinguer _name et __name
  • Imposer des règles à chaque affectation avec @property et un setter, et écrire un champ en lecture seule et un champ calculé
  • Concevoir et construire une classe complète — d’une responsabilité en une phrase, en passant par les règles, jusqu’à une interface claire

Dans cette leçon

Cette leçon est construite comme une seule histoire, dans le même ordre que les diapositives. Chaque chapitre s’appuie sur le précédent :

  1. Chapitre 1 — les objets et l’état : de nombreux objets, un état qui change, et un objet qui contient un objet
  2. Chapitre 2 — le masquage de l’information : n’importe quel code peut casser les données, alors nous les cachons derrière une interface
  3. Chapitre 3 — la validation : la syntaxe simple d’un champ, et une vérification à chaque affectation avec @property
  4. Chapitre 4 — une classe complète : tout relier en une seule classe avec une responsabilité, des règles et une interface

À la fin de la leçon — de la pratique, et un mini-projet qui relie tous les chapitres.

Chapitre 1 : Les objets et l’état — de nombreux objets et un état qui change

Qu’allons-nous apprendre ?

  • Comment gérer de nombreux objets : des listes, la recherche, le filtrage et le tri.
  • Comment l’état (State) des objets change tout au long du programme.
  • Un objet qui contient un autre objet (par exemple : une commande qui contient un client).
  • Les références : deux noms pour le même objet, face à deux objets.

Pourquoi en avons-nous besoin ?

Un vrai système n’est pas un seul objet. C’est des centaines d’objets qui communiquent entre eux : des clients, des commandes, des produits. Il faut savoir trouver le client d’identifiant 7, toutes les commandes non payées, et mettre à jour le stock quand une commande est expédiée. Et aussi comprendre ce qui se passe quand on modifie un objet que plusieurs parties du système « détiennent ».

Explication à partir des bases

Des objets dans une liste

Une liste peut contenir des objets exactement comme elle contient des nombres ou des dicts. Tout ce que nous avons appris dans les thèmes Python fonctionne :

ActionAvec des dicts (les thèmes Python)Avec des objets
Accéder à un champitem["price"]item.price
Triersorted(items, key=lambda i: i["price"])sorted(items, key=lambda i: i.price)
Filtrerif item["price"] > 100if item.price > 100
Une actiondeposit(account, 50)account.deposit(50)

L’état au fil du temps

L’état = les valeurs actuelles des attributs. Un objet « vit » tout au long du programme, et son état change par ses méthodes : une commande est créée à l’état "new", passe à "paid", puis à "shipped". Chaque objet suit ce parcours séparément.

Un objet qui contient un objet

Un attribut peut contenir n’importe quelle valeur, y compris un autre objet :

order.customer = dana      # the order "knows" its customer

order.customer est un objet Customer, on peut donc continuer : order.customer.name.

Les références — un rappel essentiel

Souvenez-vous des thèmes Python : une variable est une étiquette sur un objet. C’est aussi vrai pour nos propres objets :

dana ──────────────►┌──────────────┐
order1.customer ───►│ Customer     │   ONE object, three names
order2.customer ───►│ name: "Dana" │
                    └──────────────┘

Si Dana change d’adresse (dana.city = "Tel Aviv"), les deux commandes voient immédiatement la nouvelle adresse. Elles ne contiennent pas une copie, elles contiennent une référence vers la même Dana. C’est généralement exactement ce que l’on veut : il y a une seule Dana dans le système.

Analogie : un contact dans le téléphone

Plusieurs groupes WhatsApp contiennent « Dana ». Quand Dana change sa photo de profil, elle est mise à jour dans tous les groupes, car les groupes ne contiennent pas une photo de Dana. Ils contiennent un lien vers la vraie Dana.

Syntaxe

objects = [ClassName(...), ClassName(...)]

for obj in objects:                       # loop
    obj.method()

found = None
for obj in objects:                       # search
    if obj.attr == target:
        found = obj
        break

matching = [obj for obj in objects if obj.attr > 10]   # filter (list comprehension)

ordered = sorted(objects, key=lambda obj: obj.attr)    # sort

holder.other = some_object                # an attribute that holds another object
holder.other.attr                         # chain through it

Une List Comprehension (la 3e ligne en partant du bas) est une façon courte de construire une liste filtrée. Elle équivaut à une boucle :

matching = []
for obj in objects:
    if obj.attr > 10:
        matching.append(obj)

Utilisez la version qui vous paraît la plus lisible. Les deux sont correctes.

Premier exemple de code

week-3/team.py :

class Player:
    def __init__(self, name, score=0):
        self.name = name
        self.score = score

    def add_points(self, points):
        self.score = self.score + points

    def __str__(self):
        return self.name + ": " + str(self.score)

team = [Player("Dana"), Player("Avi"), Player("Noa")]

team[0].add_points(30)
team[1].add_points(15)
team[2].add_points(40)
team[0].add_points(20)

for player in team:
    print(player)

leader = max(team, key=lambda player: player.score)
print("Leader:", leader.name)

ranking = sorted(team, key=lambda player: player.score, reverse=True)
for place, player in enumerate(ranking, start=1):
    print(place, player)

Sortie :

Dana: 50
Avi: 15
Noa: 40
Leader: Dana
1 Dana: 50
2 Noa: 40
3 Avi: 15

Analyse du code ligne par ligne

CodeExplication
team = [Player("Dana"), ...]Une liste de 3 objets séparés, chacun avec son propre score.
team[0].add_points(30)team[0] est un objet Player. Nous appelons sa méthode. Seul son état change.
team[0].add_points(20)Le même objet à nouveau. Son score monte à 50.
max(team, key=lambda p: p.score)Trouve l’objet au score le plus élevé, et renvoie l’objet entier.
sorted(..., reverse=True)Une nouvelle liste triée. team lui-même n’a pas changé. Mais les objets sont les mêmes objets, seul leur ordre dans la nouvelle liste est différent.

Un autre exemple

Des clients et des commandes — des objets qui contiennent des objets, et un état qui change. week-3/orders.py :

class Customer:
    def __init__(self, name, city):
        self.name = name
        self.city = city

class Order:
    VALID_STATUSES = ["new", "paid", "shipped"]
    next_number = 1

    def __init__(self, customer, total):
        self.number = Order.next_number
        Order.next_number = Order.next_number + 1
        self.customer = customer
        self.total = total
        self.status = "new"

    def advance(self):
        position = Order.VALID_STATUSES.index(self.status)
        if position == len(Order.VALID_STATUSES) - 1:
            raise ValueError("order " + str(self.number) + " is already shipped")
        self.status = Order.VALID_STATUSES[position + 1]

    def __str__(self):
        return ("Order #" + str(self.number) + " | " + self.customer.name + " (" + self.customer.city
                + ") | " + str(self.total) + " NIS | " + self.status)

dana = Customer("Dana", "Haifa")
avi = Customer("Avi", "Eilat")

orders = [Order(dana, 120), Order(avi, 80), Order(dana, 300)]

orders[0].advance()
orders[0].advance()
orders[2].advance()

dana.city = "Tel Aviv"

for order in orders:
    print(order)

dana_orders = [order for order in orders if order.customer is dana]
print("Dana's total:", sum(order.total for order in dana_orders))

not_shipped = [order.number for order in orders if order.status != "shipped"]
print("Not shipped yet:", not_shipped)

Sortie :

Order #1 | Dana (Tel Aviv) | 120 NIS | shipped
Order #2 | Avi (Eilat) | 80 NIS | new
Order #3 | Dana (Tel Aviv) | 300 NIS | paid
Dana's total: 420
Not shipped yet: [2, 3]

Analyse :

  • Order(dana, 120) — la commande contient une référence vers l’objet dana. Elle ne copie ni le nom ni la ville.
  • self.customer.name — le chaînage : de la commande au client, et de là au nom du client.
  • dana.city = "Tel Aviv" — une seule modification, et les deux commandes de Dana affichent la nouvelle ville. Si nous avions stocké self.city = customer.city dans __init__, les commandes seraient « bloquées » sur Haïfa.
  • advance() — une machine à états (State Machine) simple : new → paid → shipped, et on ne peut ni sauter ni revenir en arrière. Chaque commande avance séparément.
  • order.customer is dana — is est correct ici : nous demandons vraiment « est-ce le même objet ? » (et non « a-t-il le même nom ? », car il peut y avoir deux clientes nommées Dana).
  • sum(order.total for order in dana_orders) — une somme en une ligne (une Generator Expression, comme dans le max des thèmes Python).

Résumé

  • Les objets dans une liste fonctionnent comme n’importe quelle valeur : parcourir, rechercher, sorted/max avec key=lambda obj: obj.attr.
  • Chaque objet contient son propre état, qui change par ses méthodes.
  • Un attribut peut contenir un autre objet (order.customer), et on y accède par chaînage : order.customer.name.
  • C’est une référence, pas une copie. Une modification de l’objet est visible par tous ceux qui le détiennent.
  • b = a = un autre nom, pas un nouvel objet. is vérifie « le même objet ».
  • Modifier l’état « de l’extérieur » contourne les règles. Au chapitre suivant : l’encapsulation.

Documentation officielle

Chapitre 2 : Le masquage de l’information — l’encapsulation

Qu’allons-nous apprendre ?

  • Ce qu’est l’encapsulation (Encapsulation) et pourquoi elle existe.
  • Une interface publique (Public Interface) face à l’implémentation interne.
  • Les conventions de Python : _protected et __private.
  • Ce qu’est le Name Mangling.

Pourquoi en avons-nous besoin ?

Jusqu’ici, nous avons écrit des méthodes avec de belles vérifications, par exemple withdraw qui vérifie qu’il y a assez d’argent. Mais :

account = BankAccount("Dana", 100)
account.balance = -1_000_000

Une seule ligne a contourné toutes les règles. Pas d’erreur, pas de journal. Le withdraw soigneusement écrit ne s’est tout simplement pas exécuté. Dans un vrai système, avec des dizaines de développeurs et des centaines de fichiers, quelqu’un écrira une ligne comme celle-ci (peut-être vous, dans un mois, « juste pour un test »).

Un autre problème : supposons que nous ayons décidé de stocker le solde en agorot (les centimes du shekel, balance_cents) pour éviter les problèmes d’arrondi. Si 30 endroits du code accèdent directement à account.balance, ils cassent tous. S’ils avaient utilisé des méthodes, nous ne changerions que les méthodes.

L’encapsulation résout les deux problèmes.

Explication à partir des bases

Qu’est-ce que l’encapsulation ?

L’encapsulation = emballer les données avec le code qui les gère, et cacher les détails internes, pour que le monde extérieur ne travaille qu’à travers une interface définie.

Deux idées :

  1. Protéger les règles (les invariants) — les données sensibles ne changent que par des méthodes qui vérifient qu’elles restent valides. « Le solde n’est pas négatif », « une note est entre 0 et 100 », « le statut ne fait qu’avancer ».
  2. Cacher l’implémentation — celui qui utilise la classe doit savoir ce qu’elle fait, pas comment. Ainsi, on peut changer le « comment » sans casser ceux qui l’utilisent.

Interface publique face à implémentation

Public InterfaceImplementation
QuoiLes méthodes et attributs destinés à être utilisés de l’extérieurLes données et méthodes internes qui permettent à l’interface de fonctionner
Exemple dans un compte bancairedeposit(), withdraw(), get_balance()Comment le solde est stocké, la liste des transactions, les vérifications d’aide
StabilitéChange rarement. D’autres en dépendentPeut changer librement
Analogie : un distributeur de billets

Un distributeur a une interface publique : un écran, des touches, une fente pour la carte, une ouverture pour les billets. Vous pouvez « retirer 200 », et le distributeur vérifie le code, le solde et la limite quotidienne. Le coffre à l’intérieur (l’implémentation) est verrouillé. Si n’importe qui pouvait ouvrir le coffre et prendre l’argent directement, toutes les vérifications n’auraient aucun sens. Et la banque peut remplacer le mécanisme de comptage à l’intérieur sans que vous le remarquiez, car l’interface reste la même.

Comment Python le fait : des conventions

Dans des langages comme Java, il y a un vrai « verrouillage » (private). En Python, il n’y a pas de verrouillage absolu. Il y a des conventions que tous les développeurs Python respectent :

S’écritNomLa significationCe que fait Python
namePublicFait partie de l’interface. Utilisable de l’extérieurRien
_nameProtected (une convention)« Interne, ne pas toucher de l’extérieur »Rien. C’est seulement un message aux développeurs. VS Code ne le proposera pas non plus dans l’autocomplétion
__namePrivate (Name Mangling)« Très interne »Change le nom en coulisses, pour qu’un accès de l’extérieur « par erreur » échoue

La philosophie de Python : « Nous sommes tous des adultes consentants ici ». Le langage vous fait confiance. Un tiret bas dit « ce n’est pas pour toi ». Celui qui y touche quand même en prend la responsabilité.

Le Name Mangling

Quand vous écrivez self.__balance dans une classe nommée BankAccount, Python change en fait le nom en _BankAccount__balance. C’est pourquoi account.__balance de l’extérieur échoue avec une AttributeError. Ce n’est pas de la sécurité (on peut toujours y accéder via account._BankAccount__balance), mais cela empêche l’accès par erreur, et rend l’intention très claire.

Dans ce cours : nous utiliserons surtout _name (un seul tiret bas), qui est la façon la plus répandue dans le vrai code Python. Nous découvrons __name pour savoir ce qu’il fait quand nous le rencontrons.

Syntaxe

class ClassName:
    def __init__(self, value):
        self.public_value = value      # public: part of the interface
        self._internal = []            # protected by convention: "don't touch from outside"
        self.__secret = 0              # private: name-mangled to _ClassName__secret

    def public_method(self):           # public interface
        self._helper()
        return self.__secret

    def _helper(self):                 # internal helper method
        ...

Premier exemple de code

week-3/encapsulated_account.py :

class BankAccount:
    def __init__(self, owner, balance=0):
        if balance < 0:
            raise ValueError("initial balance cannot be negative")
        self.owner = owner
        self._balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("deposit must be positive")
        self._balance = self._balance + amount

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("withdrawal must be positive")
        if amount > self._balance:
            raise ValueError("insufficient funds")
        self._balance = self._balance - amount

    def get_balance(self):
        return self._balance

account = BankAccount("Dana", 100)
account.deposit(50)
account.withdraw(30)
print("Balance:", account.get_balance())

try:
    account.withdraw(1000)
except ValueError as error:
    print("Rejected:", error)
print("Balance still:", account.get_balance())

Sortie :

Balance: 120
Rejected: insufficient funds
Balance still: 120

Analyse du code ligne par ligne

CodeExplication
self.ownerPublic. On peut le lire, et il n’y a pas de règle particulière à protéger.
self._balanceProtected (une convention). Le tiret bas dit à chaque développeur : « le solde ne se modifie pas directement ».
deposit / withdrawL’interface publique pour les modifications : la seule façon respectable de modifier le solde. Chaque modification passe par une vérification.
get_balance()Un Getter : une méthode qui renvoie la valeur en lecture. De l’extérieur, on peut lire le solde, mais pas le modifier directement.
account.withdraw(1000) échoueEt le solde n’a pas changé. La règle a été respectée.

Et si quelqu’un écrit quand même account._balance = -999 ? Python ne l’arrêtera pas. Mais le tiret bas en fait une erreur claire et visible dans le code : quiconque fait une Code Review verra immédiatement « on touche _balance de l’extérieur — interdit ». C’est exactement le but.

Un autre exemple

__private et le Name Mangling en action, et une méthode d’aide interne. week-3/secure_vault.py :

class Vault:
    MAX_ATTEMPTS = 3

    def __init__(self, code):
        self.__code = code
        self._failed_attempts = 0
        self.is_locked = False

    def open(self, code):
        if self.is_locked:
            return "Vault is locked"
        if self._check_code(code):
            self._failed_attempts = 0
            return "Vault opened"
        self._failed_attempts = self._failed_attempts + 1
        if self._failed_attempts >= Vault.MAX_ATTEMPTS:
            self.is_locked = True
            return "Wrong code - vault is now LOCKED"
        return "Wrong code (" + str(self._failed_attempts) + "/" + str(Vault.MAX_ATTEMPTS) + ")"

    def _check_code(self, code):
        return code == self.__code

vault = Vault("1234")
print(vault.open("0000"))
print(vault.open("1234"))

try:
    print(vault.__code)
except AttributeError as error:
    print("AttributeError:", error)

print("Mangled name:", vault._Vault__code)

Sortie :

Wrong code (1/3)
Vault opened
AttributeError: 'Vault' object has no attribute '__code'
Mangled name: 1234

Analyse :

  • self.__code — dans la classe on écrit __code, et Python le stocke en fait sous _Vault__code.
  • vault.__code de l’extérieur → AttributeError. On ne peut pas y accéder « par erreur ».
  • vault._Vault__code → fonctionne ! Le Name Mangling n’est pas de la sécurité. Un vrai mot de passe n’est jamais conservé dans le code (et certainement pas en clair). Ici, ce n’est qu’un exemple de démonstration.
  • _check_code — une méthode d’aide interne. Elle fait partie de l’implémentation, pas de l’interface. Demain, on pourra la remplacer par une vérification chiffrée, et aucun code extérieur ne cassera, car personne à l’extérieur n’est censé l’appeler.
  • is_locked — public, en lecture. (De l’extérieur, on peut aussi y écrire, et c’est un petit « trou ». Au chapitre suivant, nous verrons comment rendre un attribut en lecture seule avec @property.)

Résumé

  • L’encapsulation = les données + le code qui les gère ensemble, en n’exposant qu’une interface.
  • Pourquoi : (1) protéger les règles : les modifications ne passent que par des méthodes qui vérifient. (2) cacher l’implémentation : on peut changer le « comment » sans casser ceux qui l’utilisent.
  • name = Public. _name = « interne, ne pas toucher » (une convention, la plus répandue). __name = Name Mangling (_Class__name).
  • Python ne verrouille pas. Les conventions rendent l’intention claire et évitent les erreurs. Ce n’est pas de la sécurité.
  • Renvoyez une copie d’une liste/d’un dict interne, pas l’original.
  • Ne cachez pas tout. Cachez quand il y a une règle ou un détail d’implémentation.

Documentation officielle

Chapitre 3 : La validation — Getters, Setters et @property

Qu’allons-nous apprendre ?

  • Ce que sont un Getter et un Setter.
  • La validation : vérifier chaque nouvelle valeur avant de l’enregistrer.
  • @property : la façon élégante de le faire en Python.
  • Un attribut en lecture seule et un attribut calculé.

Pourquoi en avons-nous besoin ?

Dans le chapitre précédent, nous avons protégé _balance, car il avait des méthodes naturelles (deposit, withdraw). Mais qu’en est-il des données qui sont censées changer directement, seulement dans le respect de règles ?

  • L’âge d’un utilisateur : on peut le mettre à jour, mais pas à (-5) ni à 300.
  • Le prix d’un produit : on peut le changer, mais pas en un prix négatif.
  • Un e-mail : on peut le mettre à jour, mais seulement vers une adresse avec @.

Si age est un attribut ordinaire, n’importe qui peut écrire user.age = -5. Si on le cache (_age), il faut une façon ordonnée de le lire et de le modifier. C’est le Getter et le Setter.

Explication à partir des bases

Getter et Setter — l’idée

  • Getter — une méthode qui renvoie la valeur d’une donnée interne. get_age().
  • Setter — une méthode qui reçoit une nouvelle valeur, la vérifie, et seulement si elle est valide, l’enregistre. set_age(new_age).

Ainsi, la classe a le contrôle total : chaque lecture et chaque écriture passent par elle.

Analogie : un employé de banque

Vous n’allez pas vous-même dans votre dossier aux archives de la banque pour changer votre adresse au stylo. Vous allez voir un employé (un Setter) : il vérifie que l’adresse est valide et que vous êtes autorisé, et seulement ensuite il la met à jour. Et celui qui veut voir l’adresse la demande à l’employé (un Getter). L’employé peut décider quoi montrer, et peut-être cacher certains détails.

Le problème avec get_ et set_ en Python

Cela fonctionne, mais c’est lourd :

user.set_age(user.get_age() + 1)

Comparé à :

user.age = user.age + 1

De plus, si au départ age était un attribut ordinaire et que 50 endroits du code écrivent user.age, et que plus tard vous décidez qu’il faut une validation, il faut changer les 50 endroits en get_/set_.

@property — la solution de Python

@property permet de définir une méthode qui ressemble de l’extérieur à un attribut ordinaire :

  • Quand vous écrivez user.age, la méthode Getter est appelée.
  • Quand vous écrivez user.age = 30, la méthode Setter est appelée, avec la validation.

Le code qui utilise la classe écrit user.age, comme d’habitude, mais en coulisses chaque accès passe par les vérifications. Le meilleur des deux mondes.

@property est un décorateur — exactement ce que nous avons appris dans la leçon sur les décorateurs : une ligne qui commence par @ au-dessus d’un def, et qui « enveloppe » la fonction avec un comportement supplémentaire. Ici, il suffit de connaître le modèle de @property. (Dans les thèmes backend et bases de données, FastAPI utilisera des décorateurs comme @app.get(...).)

Syntaxe

Version 1 — les méthodes Getter/Setter classiques (l’idée) :

class ClassName:
    def __init__(self, value):
        self._value = None
        self.set_value(value)          # validation also on creation

    def get_value(self):
        return self._value

    def set_value(self, new_value):
        if not valid(new_value):
            raise ValueError("...")
        self._value = new_value

Version 2 — @property (la façon en usage en Python) :

class ClassName:
    def __init__(self, value):
        self.value = value             # goes through the setter below!

    @property
    def value(self):                   # getter: runs on  obj.value
        return self._value

    @value.setter
    def value(self, new_value):        # setter: runs on  obj.value = x
        if not valid(new_value):
            raise ValueError("...")
        self._value = new_value
  • Les deux méthodes ont le même nom (value), le nom de l’« attribut » public.
  • La vraie donnée est stockée sous un autre nom, avec un tiret bas (_value).
  • @property au-dessus du Getter. @value.setter (le nom de la property + .setter) au-dessus du Setter.
  • Lecture seule : ne définissez que @property, sans Setter.

Premier exemple de code

L’idée — un Getter et un Setter classiques. week-3/user_classic.py :

class User:
    MIN_AGE = 0
    MAX_AGE = 120

    def __init__(self, name, age):
        self.name = name
        self._age = None
        self.set_age(age)

    def get_age(self):
        return self._age

    def set_age(self, new_age):
        if not isinstance(new_age, int):
            raise TypeError("age must be an int, got " + type(new_age).__name__)
        if new_age < User.MIN_AGE or new_age > User.MAX_AGE:
            raise ValueError("age must be between 0 and 120, got " + str(new_age))
        self._age = new_age

dana = User("Dana", 24)
dana.set_age(dana.get_age() + 1)
print(dana.name, dana.get_age())

for bad_value in [-5, "thirty"]:
    try:
        dana.set_age(bad_value)
    except (ValueError, TypeError) as error:
        print("Rejected:", error)

print("Age unchanged:", dana.get_age())

Sortie :

Dana 25
Rejected: age must be between 0 and 120, got -5
Rejected: age must be an int, got str
Age unchanged: 25

Analyse du code ligne par ligne

CodeExplication
self._age = None puis self.set_age(age)Même à la création, l’âge passe par le Setter, on ne peut donc pas créer un utilisateur avec un âge invalide. La validation à un seul endroit.
get_age()Un Getter : renvoie la valeur interne.
set_age(new_age)Un Setter : vérifie d’abord le type (isinstance), puis la plage, et seulement ensuite enregistre.
type(new_age).__name__Le nom du type sous forme de texte propre ("str"), au lieu de <class 'str'>.
dana.set_age(dana.get_age() + 1)Cela fonctionne, mais c’est lourd. C’est la motivation de @property.
Age unchanged: 25Les valeurs invalides n’ont pas été enregistrées.

Un autre exemple

La même idée avec @property, plus un attribut en lecture seule et un attribut calculé. week-3/product_property.py :

class Product:
    def __init__(self, sku, name, price):
        self._sku = sku
        self.name = name
        self.price = price

    @property
    def sku(self):
        return self._sku

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, new_price):
        if not isinstance(new_price, (int, float)):
            raise TypeError("price must be a number")
        if new_price <= 0:
            raise ValueError("price must be positive, got " + str(new_price))
        self._price = new_price

    @property
    def price_with_vat(self):
        return round(self._price * 1.17, 2)

laptop = Product("A1", "Laptop", 3000)
print(laptop.sku, laptop.name, laptop.price, laptop.price_with_vat)

laptop.price = laptop.price * 0.9
print("After discount:", laptop.price, laptop.price_with_vat)

try:
    laptop.price = -100
except ValueError as error:
    print("Rejected:", error)

try:
    laptop.sku = "HACKED"
except AttributeError as error:
    print("Rejected:", error)

try:
    Product("A2", "Ghost", 0)
except ValueError as error:
    print("Cannot create:", error)

Sortie :

A1 Laptop 3000 3510.0
After discount: 2700.0 3159.0
Rejected: price must be positive, got -100
Rejected: property 'sku' of 'Product' object has no setter
Cannot create: price must be positive, got 0

Analyse :

  • laptop.price — ressemble à un attribut, mais exécute en fait la méthode Getter. Pas de parenthèses !
  • laptop.price = laptop.price * 0.9 — une syntaxe tout à fait naturelle, mais qui passe par le Setter avec les vérifications.
  • self.price = price dans __init__ — cela passe aussi par le Setter. C’est pourquoi Product("A2", "Ghost", 0) échoue dès la création. Une seule validation pour les deux cas.
  • sku — @property sans Setter → lecture seule. Un code produit n’est pas censé changer après la création. Une tentative de le modifier lance une AttributeError.
  • price_with_vat — une property calculée : elle n’a pas de données propres, et se calcule à partir de _price à chaque accès. Toujours à jour (vous vous souvenez de la description qui devenait obsolète ? Voici une solution encore plus élégante).
  • name — reste un attribut ordinaire, car il n’y a aucune règle dessus. N’ajoutez pas de property sans raison.

Résumé

  • Un Getter renvoie une donnée interne. Un Setter reçoit une valeur, la vérifie, et l’enregistre. Ainsi, la classe contrôle chaque lecture et chaque écriture.
  • En Python, on utilise @property (Getter) et @name.setter (Setter) : de l’extérieur, cela ressemble à un attribut ordinaire.
  • La vraie donnée est stockée sous un nom avec un tiret bas (_price). Le même nom crée une récursion.
  • Dans __init__, écrivez self.price = price, pour que la création passe aussi par la validation.
  • Une property sans Setter = lecture seule. Une property calculée = toujours à jour.
  • N’ajoutez pas de property sans règle. Commencez par un attribut ordinaire, et faites évoluer quand c’est nécessaire.

Documentation officielle

Chapitre 4 : Une classe complète — responsabilité, règles et interface

Qu’allons-nous apprendre ?

Jusqu’ici, nous avons appris des parties : __init__, les méthodes, les Class Attributes, l’encapsulation et les properties. Maintenant, nous allons toutes les relier en un seul processus de travail : comment construire une bonne classe complète à partir de zéro.

Pourquoi en avons-nous besoin ?

La vraie difficulté de la POO n’est pas la syntaxe, mais les décisions : de quoi la classe est-elle responsable ? Qu’expose-t-on ? Que cache-t-on ? Quelles règles doit-elle respecter ? Les débutants qui sautent directement dans le code obtiennent une classe qui fait tout, expose tout, et enfreint les règles. Un processus fixe l’évite.

Explication à partir des bases

Le processus — 6 étapes

ÉtapeQuestionProduit
1. ResponsabilitéQue fait cette classe, en une phrase ?Une phrase
2. DonnéesDe quoi doit-elle se souvenir ?Une liste d’attributs
3. Interface publiqueQue doit faire le monde extérieur avec elle, et que doit-il savoir d’elle ?Une liste de méthodes et de properties
4. Règles (invariants)Qu’est-ce qui doit rester vrai toujours ?Une liste de règles
5. ImplémentationComment les données sont-elles conservées à l’intérieur ? Quelles méthodes d’aide ?Du code
6. TestsChaque règle est-elle respectée ? Et les cas limites ?Un script de test

Invariant = une condition qui doit être vraie à chaque instant de la vie de l’objet. Par exemple « solde ≥ 0 », ou « le nombre d’étudiants inscrits ≤ le nombre de places ». Chaque méthode qui modifie l’état doit laisser tous les invariants vrais.

« Une seule phrase » est un test de SRP : si la phrase a besoin de « et aussi… et aussi… », ce sont probablement deux classes.

Syntaxe

Il n’y a pas de nouvelle syntaxe. Nous allons combiner tout ce que nous avons appris :

class Name:
    CLASS_CONSTANT = ...                 # shared rules / constants

    def __init__(self, ...):             # validation through properties
        ...

    @property                            # read access / read-only / computed
    def value(self): ...

    def command(self, ...):              # changes state, keeps invariants
        ...

    def query(self): ...                 # returns info, changes nothing

    def _helper(self): ...               # internal

    def __str__(self): ...               # human-readable

Premier exemple de code

La tâche : un système d’inscription aux cours. Nous allons construire une classe nommée Course selon les 6 étapes.

Étape 1 — la responsabilité : « Course gère l’inscription à un cours : qui est inscrit, combien de places restent, et la liste d’attente. »

Étape 2 — les données : un code de cours, un titre, le nombre maximum de places, une liste d’étudiants inscrits, une liste d’attente.

Étape 3 — l’interface publique :

Membre de l’interfaceTypeDescription
code, title, capacityproperties (lecture seule)Les informations du cours
enroll(student)SetterInscrit. Si c’est complet, place l’étudiant sur la liste d’attente. Renvoie "enrolled" ou "waitlisted"
drop(student)SetterAnnule une inscription. Le premier de la liste d’attente prend la place
is_enrolled(student)Getter
available_seatsproperty calculée
is_fullproperty calculée
roster()GetterUne copie de la liste des étudiants inscrits
__str__Affichage

Étape 4 — les règles :

  1. Le nombre d’étudiants inscrits ≤ capacity, toujours.
  2. Un étudiant n’est pas inscrit deux fois, et n’est pas à la fois inscrit et en attente.
  3. capacity est un nombre entier positif.
  4. Quand une place se libère et que des étudiants attendent, le premier de la file entre (FIFO, First In First Out).

Étape 5 — l’implémentation. week-3/course.py :

class Course:
    MAX_CAPACITY = 500

    def __init__(self, code, title, capacity):
        if not isinstance(capacity, int) or capacity <= 0 or capacity > Course.MAX_CAPACITY:
            raise ValueError("capacity must be a whole number between 1 and " + str(Course.MAX_CAPACITY))
        self._code = code
        self._title = title
        self._capacity = capacity
        self._enrolled = []
        self._waitlist = []

    @property
    def code(self):
        return self._code

    @property
    def title(self):
        return self._title

    @property
    def capacity(self):
        return self._capacity

    @property
    def available_seats(self):
        return self._capacity - len(self._enrolled)

    @property
    def is_full(self):
        return self.available_seats == 0

    def is_enrolled(self, student):
        return student in self._enrolled

    def enroll(self, student):
        self._ensure_not_registered(student)
        if self.is_full:
            self._waitlist.append(student)
            return "waitlisted"
        self._enrolled.append(student)
        return "enrolled"

    def drop(self, student):
        if student in self._waitlist:
            self._waitlist.remove(student)
            return
        if student not in self._enrolled:
            raise ValueError(student + " is not registered to " + self._code)
        self._enrolled.remove(student)
        if self._waitlist:
            next_student = self._waitlist.pop(0)
            self._enrolled.append(next_student)

    def roster(self):
        return list(self._enrolled)

    def waitlist(self):
        return list(self._waitlist)

    def _ensure_not_registered(self, student):
        if student in self._enrolled or student in self._waitlist:
            raise ValueError(student + " is already registered to " + self._code)

    def __str__(self):
        return (self._code + " " + self._title + " | " + str(len(self._enrolled)) + "/"
                + str(self._capacity) + " enrolled | waitlist: " + str(len(self._waitlist)))

Analyse du code ligne par ligne

PartieComment elle sert la conception
La validation dans __init__Règle 3 : on ne peut pas créer un cours avec une capacity invalide.
_enrolled, _waitlist (internes)L’encapsulation : personne à l’extérieur ne peut faire append et contourner la règle 1.
code, title, capacity comme properties sans SetterLecture seule : les informations du cours ne changent pas après la création.
available_seats, is_full sont calculéesIl n’y a pas de données dupliquées qui risquent de se désynchroniser. Elles dérivent toujours de _enrolled.
enrollRègle 2 (via _ensure_not_registered), et règle 1 (vérification de is_full avant l’ajout).
dropRègle 4 : pop(0) = le premier entré dans la file sort en premier.
roster() / waitlist() renvoient une copieCelui qui a reçu une liste ne peut pas modifier la liste interne.
_ensure_not_registeredUne méthode d’aide interne : une règle, à un seul endroit.
__str__Un instantané lisible, pour le débogage et l’affichage.

Un autre exemple

Étape 6 — les tests. Un script qui vérifie chaque règle, y compris les cas limites. À la fin de course.py :

def main():
    course = Course("PY101", "Intro to Python", 2)

    print(course.enroll("Dana"))
    print(course.enroll("Avi"))
    print(course.enroll("Noa"))
    print(course.enroll("Tom"))
    print(course)

    try:
        course.enroll("Dana")
    except ValueError as error:
        print("Rule 2 holds:", error)

    course.drop("Avi")
    print("After Avi dropped:", course.roster(), "| waitlist:", course.waitlist())

    leaked = course.roster()
    leaked.append("Hacker")
    print("Roster after outside append:", course.roster())

    try:
        course.drop("Ghost")
    except ValueError as error:
        print("Drop unknown:", error)

    try:
        Course("BAD1", "Broken", 0)
    except ValueError as error:
        print("Rule 3 holds:", error)

    print("Seats:", course.available_seats, "| full?", course.is_full)

if __name__ == "__main__":
    main()

Sortie :

enrolled
enrolled
waitlisted
waitlisted
PY101 Intro to Python | 2/2 enrolled | waitlist: 2
Rule 2 holds: Dana is already registered to PY101
After Avi dropped: ['Dana', 'Noa'] | waitlist: ['Tom']
Roster after outside append: ['Dana', 'Noa']
Drop unknown: Ghost is not registered to PY101
Rule 3 holds: capacity must be a whole number between 1 and 500
Seats: 0 | full? True

Ce que le test a prouvé :

  • Règle 1 : jamais plus de 2 étudiants inscrits. Le troisième et le quatrième sont allés sur la liste d’attente.
  • Règle 2 : Dana n’a pas été inscrite deux fois.
  • Règle 4 : quand Avi est parti, Noa (la première de la file) est entrée, et Tom est resté sur la liste d’attente.
  • L’encapsulation : append sur la copie n’a pas modifié la vraie liste.
  • Règle 3 : un cours avec 0 place n’a pas été créé.

Résumé

  • Le processus : responsabilité (une phrase) → données → interface publique → règles (invariants) → implémentation → tests.
  • Invariant = ce qui doit toujours être vrai. Chaque Setter doit tous les respecter.
  • Les données internes sont cachées (_x). Les informations fixes sont des properties en lecture seule. Les informations dérivées sont des properties calculées.
  • Renvoyez des copies des collections internes. Une règle → une méthode d’aide (_ensure_...).
  • Testez chaque règle et les cas limites, pas seulement le cas où tout va bien.

Documentation officielle

Exercices

Les exercices n’utilisent que ce qui a été vu dans cette leçon et les précédentes. Essayez d’abord seul, et n’ouvrez la solution qu’ensuite.

Ce que vous pratiquez sur cette page : une liste d’objets, un objet qui contient un autre objet, le masquage des données (encapsulation), une property avec une vérification, et une classe complète avec des règles.

À la fin de la page, vous saurez :

  • Parcourir une liste d’objets et en trouver un selon un attribut
  • Construire un objet qui contient une référence vers un autre objet
  • Cacher des données derrière des méthodes qui vérifient chaque modification
  • Écrire une property avec un setter qui impose une règle
  • Construire une classe complète qui impose ses propres règles

Exercice 1: Une liste de joueurs

Ce que vous construisez : un tableau des joueurs d’un jeu — et trouver le meneur.

Ce qu’il faut écrire :

  1. Une classe nommée Player. Le __init__ reçoit un nom, et le score commence à 0.
  2. add_points — reçoit un nombre de points et les ajoute au score.
  3. __str__ — renvoie le nom et le score, par exemple Dana: 50.
  4. Après le code de test : parcourez la liste, trouvez le joueur au score le plus élevé, et affichez-le.

Code de test :

players = [Player("Dana"), Player("Avi"), Player("Noa")]
players[0].add_points(50)
players[1].add_points(15)
players[2].add_points(40)

La sortie souhaitée :

Leader: Dana: 50
Afficher la solution
class Player:
    def __init__(self, name):
        self.name = name
        self.score = 0

    def add_points(self, points):
        self.score = self.score + points

    def __str__(self):
        return self.name + ": " + str(self.score)


players = [Player("Dana"), Player("Avi"), Player("Noa")]
players[0].add_points(50)
players[1].add_points(15)
players[2].add_points(40)

leader = players[0]
for player in players:
    if player.score > leader.score:
        leader = player
print("Leader:", leader)
Leader: Dana: 50

Exercice 2: Un objet qui contient un objet

Ce que vous construisez : un client et des commandes — quand plusieurs commandes contiennent le même client.

Ce qu’il faut écrire :

  1. Une classe nommée Customer avec un nom et une ville.
  2. Une classe nommée Order avec un client (un objet Customer) et un total.
  3. Créez un client, et deux commandes qui reçoivent le même objet client.
  4. Affichez la ville via les deux commandes, changez la ville du client en Tel Aviv, et affichez à nouveau.

La sortie souhaitée :

Haifa Haifa
Tel Aviv Tel Aviv

Ce que vous en apprenez : la commande ne contient pas une copie du client, mais une référence vers lui.

Afficher la solution
class Customer:
    def __init__(self, name, city):
        self.name = name
        self.city = city


class Order:
    def __init__(self, customer, total):
        self.customer = customer
        self.total = total


dana = Customer("Dana", "Haifa")
order1 = Order(dana, 120)
order2 = Order(dana, 80)
print(order1.customer.city, order2.customer.city)

dana.city = "Tel Aviv"
print(order1.customer.city, order2.customer.city)
Haifa Haifa
Tel Aviv Tel Aviv

Les commandes ne contiennent pas une copie du client, mais une référence vers lui. Une modification du client est visible depuis chaque commande qui le contient.

Exercice 3: Un BankAccount protégé

Ce que vous construisez : un compte bancaire qu’on ne peut pas casser — le solde ne change que par des méthodes qui le vérifient.

Ce qu’il faut écrire :

  1. Une classe nommée BankAccount, avec un titulaire et un solde. Gardez le solde dans _balance — un tiret bas dit « interne, ne pas toucher de l’extérieur ».
  2. deposit — ajoute un montant.
  3. withdraw — retire un montant. S’il n’y a pas assez d’argent : une ValueError avec le message de la sortie.
  4. get_balance — renvoie le solde.

Code de test :

account = BankAccount("Dana", 100)
account.deposit(50)
try:
    account.withdraw(500)
except ValueError as error:
    print("error:", error)
print(account.get_balance())

La sortie souhaitée :

error: not enough money
150
Afficher la solution
class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("amount must be positive")
        self._balance = self._balance + amount

    def withdraw(self, amount):
        if amount > self._balance:
            raise ValueError("not enough money")
        self._balance = self._balance - amount

    def get_balance(self):
        return self._balance


account = BankAccount("Dana", 100)
account.deposit(50)
try:
    account.withdraw(500)
except ValueError as error:
    print("error:", error)
print(account.get_balance())
error: not enough money
150

Exercice 4: Un Setter qui vérifie

Ce que vous construisez : un utilisateur dont l’âge est toujours valide — avec une property qui vérifie chaque affectation.

Ce qu’il faut écrire :

  1. Une classe nommée User, dont le __init__ reçoit un âge et l’enregistre via la property.
  2. Un Getter avec @property nommé age — renvoie l’âge enregistré dans _age.
  3. Un Setter avec @age.setter — vérifie que l’âge est entre 0 et 120. Sinon : une ValueError avec le message de la sortie.
  4. Remarquez : l’affectation dans __init__ passe aussi par la vérification.

Code de test :

user = User(30)
user.age = user.age + 1
print(user.age)
try:
    user.age = -5
except ValueError as error:
    print("error:", error)
print(user.age)

La sortie souhaitée :

31
error: age must be 0-120
31
Afficher la solution
class User:
    def __init__(self, age):
        self.age = age

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if value < 0 or value > 120:
            raise ValueError("age must be 0-120")
        self._age = value


user = User(30)
user.age = user.age + 1
print(user.age)
try:
    user.age = -5
except ValueError as error:
    print("error:", error)
print(user.age)
31
error: age must be 0-120
31
  • self.age = age dans __init__ passe par le setter — un nouvel objet est donc lui aussi vérifié.
  • La valeur est enregistrée dans _age, et la property l’expose comme s’il s’agissait d’un attribut ordinaire.

Exercice 5: L’inscription

Ce que vous construisez : l’inscription à un cours avec un nombre de places limité et une liste d’attente.

Ce qu’il faut écrire :

  1. Une classe nommée Course, avec un code de cours, un nom et une capacité, et deux listes internes : les inscrits et les personnes en attente.
  2. enroll — reçoit le nom d’un étudiant :
    • s’il est déjà inscrit ou en attente : une ValueError avec le message de la sortie ;
    • s’il y a une place : l’ajoute aux inscrits et renvoie "enrolled" ;
    • sinon : l’ajoute à la liste d’attente et renvoie "waitlisted".
  3. __str__ — le code, le nom, combien sont inscrits sur la capacité, et combien sont en attente.

Code de test :

course = Course("PY101", "Intro to Python", 2)
for student in ["Dana", "Avi", "Noa"]:
    print(student, course.enroll(student))
print(course)
try:
    course.enroll("Dana")
except ValueError as error:
    print("error:", error)

La sortie souhaitée :

Dana enrolled
Avi enrolled
Noa waitlisted
PY101 Intro to Python | 2/2 enrolled | waitlist: 1
error: Dana is already registered
Afficher la solution
class Course:
    def __init__(self, code, name, capacity):
        if capacity <= 0:
            raise ValueError("capacity must be positive")
        self.code = code
        self.name = name
        self.capacity = capacity
        self._enrolled = []
        self._waitlist = []

    def enroll(self, student):
        if student in self._enrolled or student in self._waitlist:
            raise ValueError(student + " is already registered")
        if len(self._enrolled) < self.capacity:
            self._enrolled.append(student)
            return "enrolled"
        self._waitlist.append(student)
        return "waitlisted"

    def __str__(self):
        return (self.code + " " + self.name + " | " + str(len(self._enrolled)) + "/"
                + str(self.capacity) + " enrolled | waitlist: " + str(len(self._waitlist)))


course = Course("PY101", "Intro to Python", 2)
for student in ["Dana", "Avi", "Noa"]:
    print(student, course.enroll(student))
print(course)
try:
    course.enroll("Dana")
except ValueError as error:
    print("error:", error)
Dana enrolled
Avi enrolled
Noa waitlisted
PY101 Intro to Python | 2/2 enrolled | waitlist: 1
error: Dana is already registered

Mini-projet: L’inventaire d’une boutique

Quel système vous construisez : un système d’inventaire pour une petite boutique. Chaque produit a un nom, un prix et une quantité en stock. On peut vendre et réapprovisionner, et le système s’assure que rien d’invalide ne se produit — un prix négatif, vendre plus qu’il n’y en a, ou deux produits du même nom. À la fin, il affiche les produits par valeur, et la valeur de tout l’inventaire.

Ce que vous apprendrez du projet :

  • Cacher des données (l’encapsulation) : ne les modifier que par des méthodes qui vérifient
  • Utiliser une property : avec un setter qui vérifie, en lecture seule, et calculée
  • Construire une classe qui contient une liste d’autres objets
  • Trier des objets selon une valeur calculée

Ce qu’il faut construire — étape par étape :

Partie A — un produit (Product)

Étape 1 — la création. Le __init__ reçoit un nom, un prix et une quantité (0 par défaut). Le nom est enregistré dans _name, la quantité dans _quantity, et le prix passe par le setter de price.

Étape 2 — le prix. price est une property avec un setter qui vérifie que le prix est supérieur à 0. Sinon : ValueError.

Étape 3 — les champs en lecture seule. name et quantity sont des properties sans setter — de l’extérieur, on peut les lire, mais pas les modifier.

Étape 4 — modifier la quantité. La quantité ne change que par deux méthodes :

  • add_stock — ajoute de la quantité. Si le montant n’est pas positif : ValueError.
  • sell — retire de la quantité. S’il n’y a pas assez en stock : ValueError avec le nom du produit.

Étape 5 — une valeur calculée et l’affichage. total_value est une property calculée : le prix multiplié par la quantité. __str__ affiche le produit comme dans la sortie.

Partie B — l’inventaire (Inventory)

Étape 6 — une liste interne. Inventory contient une liste interne nommée _products.

Étape 7 — ajouter et rechercher. add ajoute un produit. S’il existe déjà un produit du même nom : ValueError. find reçoit un nom et renvoie le produit, ou None.

Étape 8 — les rapports. by_value renvoie une copie de la liste des produits, triée par total_value de la plus haute à la plus basse. Le total_value de l’inventaire est une property calculée : la somme des valeurs de tous les produits.

Les messages d’erreur — exactement ainsi :

price must be positive
amount must be positive
not enough <product name>
<product name> already exists

Code de test :

shop = Inventory()
shop.add(Product("Pen", 5, 10))
shop.add(Product("Bag", 60, 2))
shop.add(Product("Notebook", 12, 5))

shop.find("Pen").sell(4)
shop.find("Bag").add_stock(1)

try:
    shop.find("Notebook").sell(50)
except ValueError as error:
    print("error:", error)

try:
    shop.add(Product("Pen", 7))
except ValueError as error:
    print("error:", error)

try:
    shop.find("Bag").price = -3
except ValueError as error:
    print("error:", error)

for product in shop.by_value():
    print(product)
print("Total:", shop.total_value)

La sortie souhaitée :

error: not enough Notebook
error: Pen already exists
error: price must be positive
Bag: 3 x 60 = 180
Notebook: 5 x 12 = 60
Pen: 6 x 5 = 30
Total: 270

À la fin du projet, vous saurez :

  • Construire un objet qui protège ses propres données — de l’extérieur, on ne peut pas le casser
  • Choisir entre un champ ordinaire, une property avec vérification, un champ en lecture seule et une valeur calculée
  • Construire un « gestionnaire » qui contient des objets, les recherche et les résume

Bonus : ajoutez à Inventory une méthode low_stock qui reçoit une limite, et renvoie les noms des produits dont la quantité lui est inférieure.

Afficher la solution
class Product:
    def __init__(self, name, price, quantity=0):
        self._name = name
        self.price = price
        self._quantity = quantity

    @property
    def name(self):
        return self._name

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if value <= 0:
            raise ValueError("price must be positive")
        self._price = value

    @property
    def quantity(self):
        return self._quantity

    def add_stock(self, amount):
        if amount <= 0:
            raise ValueError("amount must be positive")
        self._quantity = self._quantity + amount

    def sell(self, amount):
        if amount > self._quantity:
            raise ValueError("not enough " + self._name)
        self._quantity = self._quantity - amount

    @property
    def total_value(self):
        return self._price * self._quantity

    def __str__(self):
        return (self._name + ": " + str(self._quantity) + " x " + str(self._price)
                + " = " + str(self.total_value))


class Inventory:
    def __init__(self):
        self._products = []

    def add(self, product):
        if self.find(product.name) is not None:
            raise ValueError(product.name + " already exists")
        self._products.append(product)

    def find(self, name):
        for product in self._products:
            if product.name == name:
                return product
        return None

    def by_value(self):
        return sorted(self._products, key=lambda p: p.total_value, reverse=True)

    @property
    def total_value(self):
        total = 0
        for product in self._products:
            total = total + product.total_value
        return total


shop = Inventory()
shop.add(Product("Pen", 5, 10))
shop.add(Product("Bag", 60, 2))
shop.add(Product("Notebook", 12, 5))

shop.find("Pen").sell(4)
shop.find("Bag").add_stock(1)

try:
    shop.find("Notebook").sell(50)
except ValueError as error:
    print("error:", error)

try:
    shop.add(Product("Pen", 7))
except ValueError as error:
    print("error:", error)

try:
    shop.find("Bag").price = -3
except ValueError as error:
    print("error:", error)

for product in shop.by_value():
    print(product)
print("Total:", shop.total_value)
error: not enough Notebook
error: Pen already exists
error: price must be positive
Bag: 3 x 60 = 180
Notebook: 5 x 12 = 60
Pen: 6 x 5 = 30
Total: 270
  • Inventory contient des objets Product (un objet qui contient des objets), et by_value renvoie une nouvelle liste — sorted ne touche pas à la liste interne.
  • _name, _price, _quantity sont cachés. De l’extérieur, la quantité ne se modifie que par add_stock et sell — qui vérifient les règles.
  • price a un setter qui vérifie — y compris dans __init__. name et quantity sont en lecture seule, et total_value est calculé à chaque lecture.

Bonus :

    def low_stock(self, limit):
        names = []
        for product in self._products:
            if product.quantity < limit:
                names.append(product.name)
        return names

(Une méthode dans Inventory.) Avec les données ci-dessus, shop.low_stock(4) renvoie ['Bag'].