# Pratique guidée : d'une exigence verbale à une structure de classes<no value>

## 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

| 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

```text
            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

```python
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` :

```python
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 :

```python
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 :**

```text
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 :

```python
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")
```

```text
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.

<!-- exercises:start — generated by scripts/intro6/sync_site_exercises.py; edit scripts/intro6/exercises/ -->
## 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.

{{< 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.
{{< /solution >}}

### 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 :**

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

**La sortie souhaitée :**

```text
15
25
```

{{< solution >}}
```python
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))
```

```text
15
25
```

`self.PER_MINUTE` lit la valeur dans la classe **de l'objet** — `FastScooter` utilise donc 2 sans redéfinir `price` du tout.
{{< /solution >}}

### 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 :**

```python
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 :**

```text
error: battery too low: 15%
True
error: scooter 1 is in use
```

{{< solution >}}
```python
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)
```

```text
error: battery too low: 15%
True
error: scooter 1 is in use
```
{{< /solution >}}

### 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 :**

```python
scooter = FastScooter(7, 90)
ride = Ride("Dana", scooter)
print(scooter.in_use)
print(ride.finish(12))
print(scooter.in_use)
```

**La sortie souhaitée :**

```text
True
29
False
```

{{< solution >}}
```python
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)
```

```text
True
29
False
```

`Ride` ne vérifie pas les règles lui-même — il appelle `unlock`, et les règles restent dans `Scooter`.
{{< /solution >}}

### 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 :**

```python
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 :**

```text
[1]
```

{{< solution >}}
```python
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())
```

```text
[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.
{{< /solution >}}

<!-- exercises:end -->

## 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.

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

## Documentation officielle

- [A First Look at Classes](https://docs.python.org/3/tutorial/classes.html#a-first-look-at-classes)
- [Class and Instance Variables](https://docs.python.org/3/tutorial/classes.html#class-and-instance-variables)

<!-- docs:end -->
