Pratique guidée : d'une exigence verbale à une structure de classes

Objectifs de la leçon
  • 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 :

  1. Lire et marquer.
  2. Décider des classes.
  3. La responsabilité et les données de chaque classe.
  4. Les relations entre les classes.
  5. Un squelette de code.
  6. L’implémentation.
  7. 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

NomsVerbesRè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, revenusS’inscrire, annuler, afficherBasic : 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 ?

CandidatDécisionRaisonnement
Membre✓ Classe MemberIdentité, données, règles (un quota).
Type d’abonnement✓ Une hiérarchie : Plan → BasicPlan, PremiumPlanChaque 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 GymClassIdentité, 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 GymContient tous les membres et les cours, et coordonne entre eux.
Coach, jour et heure, nomAttributsDes valeurs, sans comportement.
RevenusMéthodeCalculés à partir de la somme des prix.

Étape 3 — la responsabilité et les données

ClasseResponsabilité (une phrase)DonnéesInterface
PlanDéfinit le prix et la limite de visites d’un type d’abonnement—monthly_price(), visit_limit()
MemberGère le quota mensuel d’un membreNom, numéro, abonnement, visites utiliséescan_visit(), use_visit(), return_visit()
GymClassGère la liste des inscrits à un coursNom, coach, heure, capacité, membres inscritshas_room(), add(), remove(), is_registered()
GymCoordonne les inscriptions et fournit des rapportsMembres, coursadd_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 ── PremiumPlan
  • Gym a plusieurs Member et GymClass → la composition (des dicts internes).
  • Member a un Plan → la composition. (Un membre n’est pas une sorte d’abonnement !)
  • BasicPlan est un Plan → l’héritage + le polymorphisme.
  • GymClass contient des références vers des Member, 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 :

ExigenceQui 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: 400

Qu’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 visits

La 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 :

  1. Quelles classes y a-t-il ici ? Pour chacune — que sait-elle (les attributs) ?
  2. Quelles règles y a-t-il, et dans quelle classe chaque règle doit-elle se trouver ?
  3. 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
  1. Classes : User, Scooter, FastScooter, Ride, et RentalService qui les gère toutes.
  2. 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).
  3. Relations :
    • FastScooter est une sorte de Scooter (is-a) — seul le tarif est différent.
    • Ride contient un utilisateur et une trottinette (has-a).
    • RentalService contient 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 :

  1. Une classe nommée Scooter, avec un numéro, un pourcentage de batterie, et in_use qui commence à False.
  2. Dans Scooter : un Class Attribute nommé PER_MINUTE avec la valeur 1, et price qui reçoit des minutes et renvoie 5 plus le tarif par minute multiplié par les minutes.
  3. Une classe nommée FastScooter qui hérite de Scooter, et ne change que PER_MINUTE en 2.

Code de test :

print(Scooter(1, 80).price(10))
print(FastScooter(2, 90).price(10))

La sortie souhaitée :

15
25
Afficher 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
25

self.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 :

  1. Dans Scooter, ajoutez unlock.
  2. Si la batterie est inférieure à 20 % : une ValueError avec le premier message de la sortie.
  3. Si la trottinette est déjà utilisée : une ValueError avec le deuxième message de la sortie.
  4. 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 use
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 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 use

Exercice 4: Un trajet

Ce que vous construisez : un trajet — qui relie un utilisateur à une trottinette.

Ce qu’il faut écrire :

  1. Une classe nommée Ride, qui reçoit le nom d’un utilisateur et une trottinette et les contient.
  2. Dans le __init__ de Ride : appelez le unlock de la trottinette — le trajet ne s’ouvre que si c’est permis.
  3. 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
False
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 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
False

Ride 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 :

  1. Une classe nommée RentalService, qui contient un dictionnaire des trottinettes par numéro.
  2. add — ajoute une trottinette au dictionnaire.
  3. get — reçoit un numéro et renvoie la trottinette.
  4. 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.

Documentation officielle