Pratique guidée : d'une exigence verbale à une structure de classes
- Suivre un processus de conception complet — d’un texte d’exigence jusqu’à un code qui fonctionne
- Documenter les décisions de conception — pourquoi X est une classe, pourquoi Y appartient à Z
- Dessiner les relations entre les classes — has-a, is-a et la cardinalité (un-à-plusieurs)
- Implémenter selon la conception, et découvrir et corriger des trous dans la conception pendant les tests
Qu’allons-nous apprendre ?
C’est une leçon atelier. Nous allons prendre une exigence verbale, et suivre ensemble tout le processus :
- Lire et marquer.
- Décider des classes.
- La responsabilité et les données de chaque classe.
- Les relations entre les classes.
- Un squelette de code.
- L’implémentation.
- Les tests, et la correction de la conception.
Pourquoi en avons-nous besoin ?
Dans le projet de POO que vous construirez plus tard, avant d’écrire le code, il faut une étape de conception (Design). Cette leçon est la répétition générale : exactement le même processus, sur un problème différent, avec le formateur aux commandes.
Explication à partir des bases
L’exigence
Un système pour la salle de sport « KodFit »
La salle de sport a des membres. Chaque membre a un nom, un numéro de membre et un type d’abonnement : Basic (jusqu’à 8 visites par mois) ou Premium (illimité). Premium coûte 250 NIS par mois et Basic 150.
La salle de sport a des cours (yoga, spinning…). Chaque cours a un nom, un coach, un jour et une heure, et un nombre limité de places.
Un membre peut s’inscrire à un cours s’il y a de la place, et s’il n’a pas épuisé son quota mensuel de visites. Une inscription compte comme une visite. Un membre peut annuler une inscription, et la visite lui est rendue.
Le système doit afficher : tous les cours avec les places libres, les cours d’un membre donné, et les revenus mensuels de tous les membres.
Syntaxe
Il n’y a pas de nouvelle syntaxe. Nous utiliserons tout ce que nous avons appris : les classes, __init__, les properties, l’encapsulation, l’héritage + le polymorphisme, la composition et nos propres exceptions.
Premier exemple de code
Étape 1 — lire et marquer
| Noms | Verbes | Règles et nombres |
|---|---|---|
| Salle de sport, membre, nom, numéro de membre, type d’abonnement, Basic, Premium, cours, coach, jour et heure, places, inscription, visite, revenus | S’inscrire, annuler, afficher | Basic : jusqu’à 8 visites, 150 NIS. Premium : illimité, 250 NIS. Places limitées. Inscription = une visite. L’annulation rend une visite |
Étape 2 — quelles sont les classes ?
| Candidat | Décision | Raisonnement |
|---|---|---|
| Membre | ✓ Classe Member | Identité, données, règles (un quota). |
| Type d’abonnement | ✓ Une hiérarchie : Plan → BasicPlan, PremiumPlan | Chaque type a un comportement différent (prix, limite), et c’est exactement du polymorphisme. Si « Étudiant » est ajouté à l’avenir, on ajoute une classe. |
| Cours | ✓ Classe GymClass | Identité, données, une règle (les places). (Class est un nom problématique, d’où GymClass.) |
| Inscription | ✗ Pas une classe séparée (pour l’instant) | Elle n’a pas de données propres au-delà de « qui et où ». Il suffit que le cours contienne ses membres. |
| Salle de sport | ✓ Classe Gym | Contient tous les membres et les cours, et coordonne entre eux. |
| Coach, jour et heure, nom | Attributs | Des valeurs, sans comportement. |
| Revenus | Méthode | Calculés à partir de la somme des prix. |
Étape 3 — la responsabilité et les données
| Classe | Responsabilité (une phrase) | Données | Interface |
|---|---|---|---|
Plan | Définit le prix et la limite de visites d’un type d’abonnement | — | monthly_price(), visit_limit() |
Member | Gère le quota mensuel d’un membre | Nom, numéro, abonnement, visites utilisées | can_visit(), use_visit(), return_visit() |
GymClass | Gère la liste des inscrits à un cours | Nom, coach, heure, capacité, membres inscrits | has_room(), add(), remove(), is_registered() |
Gym | Coordonne les inscriptions et fournit des rapports | Membres, cours | add_member(), add_class(), register(), cancel(), schedule(), classes_of(), monthly_revenue() |
Une décision importante : où vérifie-t-on « a-t-il le droit de s’inscrire » ? Il y a deux règles : de la place dans le cours (appartient à GymClass) et le quota (appartient à Member). Gym.register coordonne : il interroge les deux, et seulement si les deux sont d’accord, il effectue les modifications dans les deux.
Étape 4 — les relations
Gym
/ \
has many has many
/ \
Member ◄──────── GymClass
│ (class holds its registered members)
has-a
│
Plan ◄── is-a ── BasicPlan
◄── is-a ── PremiumPlanGyma plusieursMemberetGymClass→ la composition (des dicts internes).Membera unPlan→ la composition. (Un membre n’est pas une sorte d’abonnement !)BasicPlanest unPlan→ l’héritage + le polymorphisme.GymClasscontient des références vers desMember, pas des copies.
Étape 5 — un squelette
class Plan:
def monthly_price(self): ...
def visit_limit(self): ...
class BasicPlan(Plan): ...
class PremiumPlan(Plan): ...
class Member:
def __init__(self, member_id, name, plan): ...
def can_visit(self): ...
def use_visit(self): ...
def return_visit(self): ...
class GymClass:
def __init__(self, class_id, name, coach, time, capacity): ...
def has_room(self): ...
def add(self, member): ...
def remove(self, member): ...
class Gym:
def register(self, member_id, class_id): ...
def cancel(self, member_id, class_id): ...
def schedule(self): ...
def monthly_revenue(self): ...Analyse du code ligne par ligne
Juste avant d’implémenter — vérifier la conception par rapport à l’exigence, ligne par ligne :
| Exigence | Qui la gère |
|---|---|
| « Basic jusqu’à 8 visites, Premium illimité » | BasicPlan.visit_limit() → 8. PremiumPlan.visit_limit() → None (illimité). |
| « Un prix selon le type » | Un monthly_price() polymorphe. |
| « S’inscrire s’il y a de la place et si le quota n’est pas épuisé » | Gym.register → GymClass.has_room() + Member.can_visit(). |
| « Inscription = une visite » | register appelle member.use_visit(). |
| « L’annulation rend une visite » | cancel → class.remove() + member.return_visit(). |
| « Afficher les cours avec les places libres » | Gym.schedule(). |
| « Les cours d’un membre » | Gym.classes_of(member_id). |
| « Les revenus mensuels » | Gym.monthly_revenue() → la somme de member.plan.monthly_price(). |
Chaque ligne de l’exigence « atterrit » dans une classe et une méthode. Aucune ligne sans foyer, et aucune classe sans raison.
Un autre exemple
Étape 6 — l’implémentation. week-3/gym.py :
class GymError(Exception):
pass
class Plan:
def monthly_price(self):
raise NotImplementedError
def visit_limit(self):
raise NotImplementedError
def name(self):
return type(self).__name__.replace("Plan", "")
class BasicPlan(Plan):
def monthly_price(self):
return 150
def visit_limit(self):
return 8
class PremiumPlan(Plan):
def monthly_price(self):
return 250
def visit_limit(self):
return None
class Member:
def __init__(self, member_id, name, plan):
self.member_id = member_id
self.name = name
self.plan = plan
self._visits_used = 0
@property
def visits_used(self):
return self._visits_used
def can_visit(self):
limit = self.plan.visit_limit()
return limit is None or self._visits_used < limit
def use_visit(self):
if not self.can_visit():
raise GymError(self.name + " reached the monthly limit of " + str(self.plan.visit_limit()))
self._visits_used = self._visits_used + 1
def return_visit(self):
if self._visits_used > 0:
self._visits_used = self._visits_used - 1
class GymClass:
def __init__(self, class_id, name, coach, time, capacity):
if capacity <= 0:
raise ValueError("capacity must be positive")
self.class_id = class_id
self.name = name
self.coach = coach
self.time = time
self.capacity = capacity
self._members = []
@property
def free_spots(self):
return self.capacity - len(self._members)
def has_room(self):
return self.free_spots > 0
def is_registered(self, member):
return member in self._members
def add(self, member):
if self.is_registered(member):
raise GymError(member.name + " is already in " + self.name)
if not self.has_room():
raise GymError(self.name + " is full")
self._members.append(member)
def remove(self, member):
if not self.is_registered(member):
raise GymError(member.name + " is not registered to " + self.name)
self._members.remove(member)
class Gym:
def __init__(self, name):
self.name = name
self._members = {}
self._classes = {}
def add_member(self, member):
self._members[member.member_id] = member
def add_class(self, gym_class):
self._classes[gym_class.class_id] = gym_class
def _member(self, member_id):
if member_id not in self._members:
raise GymError("no member " + str(member_id))
return self._members[member_id]
def _class(self, class_id):
if class_id not in self._classes:
raise GymError("no class " + str(class_id))
return self._classes[class_id]
def register(self, member_id, class_id):
member = self._member(member_id)
gym_class = self._class(class_id)
if not member.can_visit():
raise GymError(member.name + " reached the monthly limit")
gym_class.add(member)
member.use_visit()
def cancel(self, member_id, class_id):
member = self._member(member_id)
self._class(class_id).remove(member)
member.return_visit()
def schedule(self):
lines = []
for gym_class in self._classes.values():
lines.append(gym_class.time + " " + gym_class.name + " with " + gym_class.coach
+ " - " + str(gym_class.free_spots) + " free")
return lines
def classes_of(self, member_id):
member = self._member(member_id)
return [c.name for c in self._classes.values() if c.is_registered(member)]
def monthly_revenue(self):
return sum(member.plan.monthly_price() for member in self._members.values())Étape 7 — les tests. À la fin du fichier :
def main():
gym = Gym("KodFit")
gym.add_member(Member(1, "Dana", PremiumPlan()))
gym.add_member(Member(2, "Avi", BasicPlan()))
gym.add_class(GymClass("Y1", "Yoga", "Noa", "Sun 08:00", 2))
gym.add_class(GymClass("S1", "Spinning", "Tom", "Mon 19:00", 10))
gym.register(1, "Y1")
gym.register(2, "Y1")
gym.register(1, "S1")
attempts = [(1, "Y1"), (2, "Y9"), (9, "S1")]
for member_id, class_id in attempts:
try:
gym.register(member_id, class_id)
except GymError as error:
print("Rejected:", error)
gym.cancel(2, "Y1")
for line in gym.schedule():
print(line)
print("Dana's classes:", gym.classes_of(1))
print("Monthly revenue:", gym.monthly_revenue())
if __name__ == "__main__":
main()Sortie :
Rejected: Dana is already in Yoga
Rejected: no class Y9
Rejected: no member 9
Sun 08:00 Yoga with Noa - 1 free
Mon 19:00 Spinning with Tom - 9 free
Dana's classes: ['Yoga', 'Spinning']
Monthly revenue: 400Qu’ont révélé les tests ? — corriger la conception
Vérifions la règle du quota avec un membre Basic :
gym2 = Gym("Test")
avi = Member(2, "Avi", BasicPlan())
gym2.add_member(avi)
for i in range(9):
gym2.add_class(GymClass("C" + str(i), "Class " + str(i), "Coach", "Tue", 5))
for i in range(9):
try:
gym2.register(2, "C" + str(i))
except GymError as error:
print("Rejected:", error)
print("Avi used", avi.visits_used, "visits")Rejected: Avi reached the monthly limit
Avi used 8 visitsLa règle fonctionne. Mais remarquez le trou : et si gym_class.add() réussissait, et que member.use_visit() échouait ? Alors le membre serait inscrit au cours, mais aucune visite n’aurait été utilisée. Dans notre implémentation, cela n’arrive pas, car nous avons vérifié can_visit() avant add. Mais cela montre une règle importante : quand une méthode modifie deux objets, vérifiez toutes les conditions avant de modifier quoi que ce soit. Exactement comme transfer_to de la banque appelle withdraw (qui peut échouer) avant deposit.
Erreur courante
Erreur 1 : sauter les étapes 1 à 4 « parce que c’est évident pour moi ». Après 200 lignes, vous découvrez que « l’inscription » aurait dû être ailleurs, et que Member et GymClass se « connaissent » d’une façon qui provoque des mises à jour en double. 15 minutes de conception font gagner des heures.
Erreur 2 : l’héritage au lieu de la composition — class PremiumMember(Member) semble naturel, mais que se passe-t-il quand un membre passe de Basic à Premium ? On ne peut pas « changer la classe » d’un objet existant. Avec la composition (member.plan = PremiumPlan()), c’est une seule ligne. Ce qui peut changer pendant la vie de l’objet → la composition.
Erreur 3 : une règle vérifiée au mauvais endroit. Si GymClass.add vérifiait aussi le quota du membre, le cours devrait « connaître » les règles des abonnements. Chaque classe vérifie ses propres règles, et Gym coordonne.
Erreur 4 : ne pas tester les cas d’erreur. Les tests incluaient des identifiants inexistants, une double inscription et un dépassement du quota. Sans eux, nous saurions seulement que le cas où tout va bien fonctionne.
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 : tout le chemin d’une exigence verbale jusqu’au code — avec l’exemple d’un service de location de trottinettes. Chaque exercice est une étape du processus : d’abord la conception, puis classe après classe.
À la fin de la page, vous saurez :
- Décomposer une exigence en classes, règles et relations avant d’écrire du code
- Construire une petite hiérarchie : un parent et un enfant qui ne change qu’un tarif
- Placer chaque règle dans la classe qui détient ses données
- Relier plusieurs classes en un seul système qui fonctionne
Exercice 1: D’une exigence à une liste de classes
Ce que vous faites : l’étape de conception — sans code.
L’exigence :
« Un système de location de trottinettes : un utilisateur loue une trottinette pour un trajet. Chaque trottinette a un numéro et une batterie en pourcentage. On ne doit pas louer une trottinette avec moins de 20 % de batterie, ni une trottinette déjà utilisée. Un trajet coûte 5 NIS de déverrouillage plus 1 NIS par minute. Une trottinette rapide coûte 2 NIS par minute. »
La tâche :
- Quelles classes y a-t-il ici ? Pour chacune — que sait-elle (les attributs) ?
- Quelles règles y a-t-il, et dans quelle classe chaque règle doit-elle se trouver ?
- Quelles sont les relations entre elles : qui contient qui (has-a), et qui est une sorte de qui (is-a) ?
Ce qu’il faut rendre : une liste de classes avec leurs attributs, une liste de règles, et une liste de relations.
Afficher la solution
- Classes :
User,Scooter,FastScooter,Ride, etRentalServicequi les gère toutes. - Règles :
- Pas de location en dessous de 20 % de batterie.
- Pas de location d’une trottinette déjà utilisée.
- Le prix d’un trajet : 5 + minutes × le tarif par minute (1 ordinaire, 2 rapide).
- Relations :
FastScooterest une sorte deScooter(is-a) — seul le tarif est différent.Ridecontient un utilisateur et une trottinette (has-a).RentalServicecontient de nombreuses trottinettes et de nombreux utilisateurs.
Exercice 2: Scooter et FastScooter
Ce que vous construisez : la trottinette — et un prix de trajet qui dépend de son type.
Ce qu’il faut écrire :
- Une classe nommée
Scooter, avec un numéro, un pourcentage de batterie, etin_usequi commence àFalse. - Dans
Scooter: un Class Attribute nomméPER_MINUTEavec la valeur 1, etpricequi reçoit des minutes et renvoie 5 plus le tarif par minute multiplié par les minutes. - Une classe nommée
FastScooterqui hérite deScooter, et ne change quePER_MINUTEen 2.
Code de test :
print(Scooter(1, 80).price(10))
print(FastScooter(2, 90).price(10))La sortie souhaitée :
15
25Afficher la solution
UNLOCK_FEE = 5
class Scooter:
PER_MINUTE = 1
def __init__(self, number, battery):
self.number = number
self.battery = battery
self.in_use = False
def price(self, minutes):
return UNLOCK_FEE + minutes * self.PER_MINUTE
class FastScooter(Scooter):
PER_MINUTE = 2
print(Scooter(1, 80).price(10))
print(FastScooter(2, 90).price(10))15
25self.PER_MINUTE lit la valeur dans la classe de l’objet — FastScooter utilise donc 2 sans redéfinir price du tout.
Exercice 3: Les règles à un seul endroit
Ce que vous construisez : la suite de Scooter — les deux règles de location, à un seul endroit.
Ce qu’il faut écrire :
- Dans
Scooter, ajoutezunlock. - Si la batterie est inférieure à 20 % : une
ValueErroravec le premier message de la sortie. - Si la trottinette est déjà utilisée : une
ValueErroravec le deuxième message de la sortie. - Sinon : marquez la trottinette comme utilisée.
Code de test :
s = Scooter(1, 15)
try:
s.unlock()
except ValueError as error:
print("error:", error)
s.battery = 60
s.unlock()
print(s.in_use)
try:
s.unlock()
except ValueError as error:
print("error:", error)La sortie souhaitée :
error: battery too low: 15%
True
error: scooter 1 is in useAfficher la solution
UNLOCK_FEE = 5
MIN_BATTERY = 20
class Scooter:
PER_MINUTE = 1
def __init__(self, number, battery):
self.number = number
self.battery = battery
self.in_use = False
def price(self, minutes):
return UNLOCK_FEE + minutes * self.PER_MINUTE
def unlock(self):
if self.battery < MIN_BATTERY:
raise ValueError("battery too low: " + str(self.battery) + "%")
if self.in_use:
raise ValueError("scooter " + str(self.number) + " is in use")
self.in_use = True
s = Scooter(1, 15)
try:
s.unlock()
except ValueError as error:
print("error:", error)
s.battery = 60
s.unlock()
print(s.in_use)
try:
s.unlock()
except ValueError as error:
print("error:", error)error: battery too low: 15%
True
error: scooter 1 is in useExercice 4: Un trajet
Ce que vous construisez : un trajet — qui relie un utilisateur à une trottinette.
Ce qu’il faut écrire :
- Une classe nommée
Ride, qui reçoit le nom d’un utilisateur et une trottinette et les contient. - Dans le
__init__deRide: appelez leunlockde la trottinette — le trajet ne s’ouvre que si c’est permis. finish— reçoit des minutes, libère la trottinette et renvoie le prix du trajet.
Code de test :
scooter = FastScooter(7, 90)
ride = Ride("Dana", scooter)
print(scooter.in_use)
print(ride.finish(12))
print(scooter.in_use)La sortie souhaitée :
True
29
FalseAfficher la solution
UNLOCK_FEE = 5
MIN_BATTERY = 20
class Scooter:
PER_MINUTE = 1
def __init__(self, number, battery):
self.number = number
self.battery = battery
self.in_use = False
def price(self, minutes):
return UNLOCK_FEE + minutes * self.PER_MINUTE
def unlock(self):
if self.battery < MIN_BATTERY:
raise ValueError("battery too low: " + str(self.battery) + "%")
if self.in_use:
raise ValueError("scooter " + str(self.number) + " is in use")
self.in_use = True
class FastScooter(Scooter):
PER_MINUTE = 2
class Ride:
def __init__(self, user, scooter):
scooter.unlock()
self.user = user
self.scooter = scooter
def finish(self, minutes):
self.scooter.in_use = False
return self.scooter.price(minutes)
scooter = FastScooter(7, 90)
ride = Ride("Dana", scooter)
print(scooter.in_use)
print(ride.finish(12))
print(scooter.in_use)True
29
FalseRide ne vérifie pas les règles lui-même — il appelle unlock, et les règles restent dans Scooter.
Exercice 5: Le service de location
Ce que vous construisez : tout le service — il contient toutes les trottinettes et sait lesquelles sont libres.
Ce qu’il faut écrire :
- Une classe nommée
RentalService, qui contient un dictionnaire des trottinettes par numéro. add— ajoute une trottinette au dictionnaire.get— reçoit un numéro et renvoie la trottinette.available— renvoie la liste des numéros des trottinettes que l’on peut louer en ce moment : une batterie d’au moins 20 %, et pas utilisée.
Code de test :
service = RentalService()
service.add(Scooter(1, 80))
service.add(Scooter(2, 10))
service.add(FastScooter(3, 70))
Ride("Dana", service.get(3))
print(service.available())La sortie souhaitée :
[1]Afficher la solution
UNLOCK_FEE = 5
MIN_BATTERY = 20
class Scooter:
PER_MINUTE = 1
def __init__(self, number, battery):
self.number = number
self.battery = battery
self.in_use = False
def can_rent(self):
return self.battery >= MIN_BATTERY and not self.in_use
def unlock(self):
if self.battery < MIN_BATTERY:
raise ValueError("battery too low: " + str(self.battery) + "%")
if self.in_use:
raise ValueError("scooter " + str(self.number) + " is in use")
self.in_use = True
class FastScooter(Scooter):
PER_MINUTE = 2
class Ride:
def __init__(self, user, scooter):
scooter.unlock()
self.user = user
self.scooter = scooter
class RentalService:
def __init__(self):
self._scooters = {}
def add(self, scooter):
self._scooters[scooter.number] = scooter
def get(self, number):
return self._scooters[number]
def available(self):
result = []
for number, scooter in self._scooters.items():
if scooter.can_rent():
result.append(number)
return result
service = RentalService()
service.add(Scooter(1, 80))
service.add(Scooter(2, 10))
service.add(FastScooter(3, 70))
Ride("Dana", service.get(3))
print(service.available())[1]La trottinette 2 — une batterie faible. La trottinette 3 — utilisée. Nous avons ajouté can_rent() à Scooter, pour que le service interroge la trottinette et ne vérifie pas ses données lui-même.
Résumé
- Le processus de conception : marquage → classes → responsabilité et données → relations → squelette → implémentation → tests.
- Chaque ligne de l’exigence doit « atterrir » dans une classe et une méthode. Chaque classe a besoin d’une raison.
- Un comportement différent selon le type → une hiérarchie + le polymorphisme (Plan). Quelque chose qui peut changer pendant la vie de l’objet → la composition (member.plan).
- Chaque classe vérifie ses propres règles, et une classe coordinatrice (Gym) les relie.
- Quand une action modifie plusieurs objets : vérifiez toutes les conditions avant de modifier quoi que ce soit.
- Une nouvelle exigence = une mise à jour de la conception. Et une bonne conception permet d’ajouter sans casser.