Sur cette page
- Dans cette leçon
- Chapitre 1 : Les objets et l’état — de nombreux objets et un état qui change
- Chapitre 2 : Le masquage de l’information — l’encapsulation
- Chapitre 3 : La validation — Getters, Setters et
@property - Chapitre 4 : Une classe complète — responsabilité, règles et interface
- Exercices
- Mini-projet: L’inventaire d’une boutique
Les objets au travail : état, masquage de l'information, properties et une classe complète
- 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
_nameet__name - Imposer des règles à chaque affectation avec
@propertyet 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 :
- Chapitre 1 — les objets et l’état : de nombreux objets, un état qui change, et un objet qui contient un objet
- Chapitre 2 — le masquage de l’information : n’importe quel code peut casser les données, alors nous les cachons derrière une interface
- Chapitre 3 — la validation : la syntaxe simple d’un champ, et une vérification à chaque affectation avec
@property - 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 :
| Action | Avec des dicts (les thèmes Python) | Avec des objets |
|---|---|---|
| Accéder à un champ | item["price"] | item.price |
| Trier | sorted(items, key=lambda i: i["price"]) | sorted(items, key=lambda i: i.price) |
| Filtrer | if item["price"] > 100 | if item.price > 100 |
| Une action | deposit(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 customerorder.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.
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 itUne 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: 15Analyse du code ligne par ligne
| Code | Explication |
|---|---|
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’objetdana. 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.citydans__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—isest 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 lemaxdes thèmes Python).
Résumé
- Les objets dans une liste fonctionnent comme n’importe quelle valeur : parcourir, rechercher,
sorted/maxaveckey=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.isvé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 :
_protectedet__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_000Une 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 :
- 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 ».
- 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 Interface | Implementation | |
|---|---|---|
| Quoi | Les méthodes et attributs destinés à être utilisés de l’extérieur | Les données et méthodes internes qui permettent à l’interface de fonctionner |
| Exemple dans un compte bancaire | deposit(), withdraw(), get_balance() | Comment le solde est stocké, la liste des transactions, les vérifications d’aide |
| Stabilité | Change rarement. D’autres en dépendent | Peut changer librement |
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’écrit | Nom | La signification | Ce que fait Python |
|---|---|---|---|
name | Public | Fait partie de l’interface. Utilisable de l’extérieur | Rien |
_name | Protected (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 |
__name | Private (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: 120Analyse du code ligne par ligne
| Code | Explication |
|---|---|
self.owner | Public. On peut le lire, et il n’y a pas de règle particulière à protéger. |
self._balance | Protected (une convention). Le tiret bas dit à chaque développeur : « le solde ne se modifie pas directement ». |
deposit / withdraw | L’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) échoue | Et 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: 1234Analyse :
self.__code— dans la classe on écrit__code, et Python le stocke en fait sous_Vault__code.vault.__codede 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.
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 + 1De 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_valueVersion 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). @propertyau-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: 25Analyse du code ligne par ligne
| Code | Explication |
|---|---|
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: 25 | Les 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 0Analyse :
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 = pricedans__init__— cela passe aussi par le Setter. C’est pourquoiProduct("A2", "Ghost", 0)échoue dès la création. Une seule validation pour les deux cas.sku—@propertysans Setter → lecture seule. Un code produit n’est pas censé changer après la création. Une tentative de le modifier lance uneAttributeError.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 ladescriptionqui 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__, écrivezself.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
| Étape | Question | Produit |
|---|---|---|
| 1. Responsabilité | Que fait cette classe, en une phrase ? | Une phrase |
| 2. Données | De quoi doit-elle se souvenir ? | Une liste d’attributs |
| 3. Interface publique | Que 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émentation | Comment les données sont-elles conservées à l’intérieur ? Quelles méthodes d’aide ? | Du code |
| 6. Tests | Chaque 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-readablePremier 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’interface | Type | Description |
|---|---|---|
code, title, capacity | properties (lecture seule) | Les informations du cours |
enroll(student) | Setter | Inscrit. Si c’est complet, place l’étudiant sur la liste d’attente. Renvoie "enrolled" ou "waitlisted" |
drop(student) | Setter | Annule une inscription. Le premier de la liste d’attente prend la place |
is_enrolled(student) | Getter | |
available_seats | property calculée | |
is_full | property calculée | |
roster() | Getter | Une copie de la liste des étudiants inscrits |
__str__ | Affichage |
Étape 4 — les règles :
- Le nombre d’étudiants inscrits ≤
capacity, toujours. - Un étudiant n’est pas inscrit deux fois, et n’est pas à la fois inscrit et en attente.
capacityest un nombre entier positif.- 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
| Partie | Comment 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 Setter | Lecture seule : les informations du cours ne changent pas après la création. |
available_seats, is_full sont calculées | Il n’y a pas de données dupliquées qui risquent de se désynchroniser. Elles dérivent toujours de _enrolled. |
enroll | Règle 2 (via _ensure_not_registered), et règle 1 (vérification de is_full avant l’ajout). |
drop | Règle 4 : pop(0) = le premier entré dans la file sort en premier. |
roster() / waitlist() renvoient une copie | Celui qui a reçu une liste ne peut pas modifier la liste interne. |
_ensure_not_registered | Une 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? TrueCe 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 :
appendsur 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 :
- Une classe nommée
Player. Le__init__reçoit un nom, et le score commence à 0. add_points— reçoit un nombre de points et les ajoute au score.__str__— renvoie le nom et le score, par exempleDana: 50.- 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: 50Afficher 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: 50Exercice 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 :
- Une classe nommée
Customeravec un nom et une ville. - Une classe nommée
Orderavec un client (un objetCustomer) et un total. - Créez un client, et deux commandes qui reçoivent le même objet client.
- 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 AvivCe 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 AvivLes 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 :
- 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 ». deposit— ajoute un montant.withdraw— retire un montant. S’il n’y a pas assez d’argent : uneValueErroravec le message de la sortie.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
150Afficher 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
150Exercice 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 :
- Une classe nommée
User, dont le__init__reçoit un âge et l’enregistre via la property. - Un Getter avec
@propertynomméage— renvoie l’âge enregistré dans_age. - Un Setter avec
@age.setter— vérifie que l’âge est entre 0 et 120. Sinon : uneValueErroravec le message de la sortie. - 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
31Afficher 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
31self.age = agedans__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 :
- 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. enroll— reçoit le nom d’un étudiant :- s’il est déjà inscrit ou en attente : une
ValueErroravec 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".
- s’il est déjà inscrit ou en attente : une
__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 registeredAfficher 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 registeredMini-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 :ValueErroravec 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 existsCode 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: 270Inventorycontient des objetsProduct(un objet qui contient des objets), etby_valuerenvoie une nouvelle liste —sortedne touche pas à la liste interne._name,_price,_quantitysont cachés. De l’extérieur, la quantité ne se modifie que paradd_stocketsell— qui vérifient les règles.pricea un setter qui vérifie — y compris dans__init__.nameetquantitysont en lecture seule, ettotal_valueest 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'].