# Classes et objets : du problème à une classe complète<no value>

## Dans cette leçon

Cette leçon est construite comme une seule histoire, dans le même ordre que les diapositives. Nous partons d'un petit système bancaire écrit avec des dictionnaires et des fonctions, nous voyons où il casse, et à chaque chapitre nous ajoutons un outil qui résout un problème :

1. **Chapitre 1** — pourquoi nous avons besoin de la POO : les problèmes du code procédural
2. **Chapitre 2** — Class et Object : un modèle et des instances
3. **Chapitre 3** — `__init__` et `self` : un objet qui naît complet
4. **Chapitre 4** — les méthodes : l'objet ne fait pas que savoir, il agit aussi
5. **Chapitre 5** — les données partagées : les Class Attributes
6. **Chapitre 6** — ce qui devient une classe : d'une exigence verbale à des classes
7. **Chapitre 7** — retour à la banque : tout ensemble

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

## Chapitre 1 : Pourquoi nous avons besoin de la POO — code procédural face aux objets

### Qu'allons-nous apprendre ?

Toute cette partie du cours est consacrée à la **POO** — la **programmation orientée objet** (Object-Oriented Programming, OOP). Avant d'écrire une seule ligne de code POO, nous allons répondre à la question la plus importante : **pourquoi ?**

Dans ce chapitre :

- Nous allons écrire un petit système **sans** POO, de la façon que nous connaissons déjà.
- Nous verrons **où** il commence à poser problème.
- Nous comprendrons **l'idée** de la POO comme solution, encore sans syntaxe.

### Pourquoi en avons-nous besoin ?

Tout ce que nous avons appris jusqu'ici fonctionne. On peut écrire n'importe quel programme avec des variables, des dicts, des listes et des fonctions. Cela s'appelle la **programmation procédurale** (Procedural Programming) : les **données** d'un côté, les **fonctions** qui agissent sur elles de l'autre.

Mais quand un système grandit, jusqu'à des dizaines de types de « choses » et des centaines de fonctions, les problèmes commencent. La POO est la façon dont presque tous les grands systèmes sont construits. Et les outils que vous utiliserez (FastAPI, les bibliothèques Python) sont eux aussi écrits en POO. Pour les lire et les utiliser, il faut la comprendre.

### Explication à partir des bases

#### Le problème : un système de comptes bancaires — sans POO

Construisons un petit système de gestion de comptes bancaires, avec ce que nous connaissons déjà :

```python
account1 = {"owner": "Dana", "balance": 1000}
account2 = {"owner": "Avi", "balance": 500}

def deposit(account, amount):
    if amount <= 0:
        raise ValueError("amount must be positive")
    account["balance"] = account["balance"] + amount

def withdraw(account, amount):
    if amount > account["balance"]:
        raise ValueError("insufficient funds")
    account["balance"] = account["balance"] - amount

deposit(account1, 200)
withdraw(account2, 100)
print(account1, account2)
```

```text
{'owner': 'Dana', 'balance': 1200} {'owner': 'Avi', 'balance': 400}
```

Ça fonctionne ! Alors où est le problème ? Agrandissons le système.

#### Problème 1 : tout le monde peut « toucher » aux données

```python
account1["balance"] = -999999
account2["balnce"] = 100
```

- La première ligne **contourne** toutes les vérifications que nous avons écrites dans `withdraw`. Un solde négatif, sans aucune vérification.
- La deuxième ligne contient une faute de frappe. Une nouvelle clé `"balnce"` a été créée, sans aucune erreur (nous l'avons vu dans le thème structures de données et fonctions).

**Les règles** (ne pas retirer plus que ce qu'il y a, un montant positif) n'existent que si tout le monde pense à utiliser les fonctions. Rien ne les **impose**.

#### Problème 2 : les données et les actions sont dispersées

Chaque action sur un compte est une fonction séparée qui reçoit le dict : `deposit(account, ...)`, `withdraw(account, ...)`, `transfer(...)`, `monthly_fee(...)`, `print_statement(...)`. Dans un vrai système, ce sont **des dizaines de fonctions** dans plusieurs fichiers. Celui qui reçoit un dict de compte ne sait pas quelles actions existent dessus ni où elles se trouvent.

#### Problème 3 : il n'y a pas de « structure » définie

À quoi ressemble un compte ? Qu'est-ce qui **doit** s'y trouver ? Peut-être qu'à un endroit du code quelqu'un a créé `{"owner": ..., "balance": ...}`, et à un autre endroit `{"name": ..., "money": ...}`. Il n'y a pas **un seul** endroit qui définit « voici un compte bancaire, voici ses champs, et voici ses actions ».

#### Problème 4 : des types semblables mais différents

Un « compte d'épargne » a été ajouté : comme un compte ordinaire, mais avec des intérêts, et sans possibilité de retirer en dessous de 1 000. Maintenant `withdraw` doit vérifier : `if account["type"] == "savings": ... elif ...`. Et à chaque nouveau type de compte, **toutes** les fonctions se remplissent de `if`.

#### L'idée de la POO

**La POO organise le code autour de « choses » (des objets), et non autour d'actions.** Chaque « chose » **contient ensemble** :

- Ses **données** : les **attributs** (Attributes). Pour un compte : le titulaire, le solde.
- Les **actions** que l'on peut faire avec elle : les **méthodes** (Methods). Pour un compte : déposer, retirer.

```text
PROCEDURAL                          OBJECT-ORIENTED
data:      account dict             ┌──────────────────────────┐
functions: deposit(account, x)      │ BankAccount              │
           withdraw(account, x)     │   data:    owner, balance│
           print_statement(account) │   actions: deposit()     │
                                    │            withdraw()    │
"functions act ON data"             │            statement()   │
                                    └──────────────────────────┘
                                    "the object knows its own data
                                     and its own actions"
```

Et comment résout-elle les problèmes ?

| Problème | La solution POO | Nous l'apprendrons dans... |
|---|---|---|
| Tout le monde touche aux données | L'objet **protège** ses données, et les modifications passent par ses actions (**l'encapsulation**) | La leçon suivante |
| Les données et les actions sont dispersées | Tout est **dans** la classe. `account.` + Tab dans VS Code montre tout ce que l'on peut faire | Aujourd'hui |
| Pas de structure définie | **Une seule classe** définit exactement quelles données a chaque compte | Aujourd'hui |
| Des types semblables | **L'héritage** et **le polymorphisme** : un compte d'épargne « hérite » d'un compte ordinaire et ne change que ce qui est différent | Les leçons sur l'héritage et le polymorphisme |

#### Un modèle face à une chose précise

L'idée la plus fondamentale de la POO existe déjà dans le monde réel :

| Modèle (Class) | Choses précises créées à partir de lui (Objects) |
|---|---|
| Le **plan de construction** d'un appartement | L'appartement de Dana au 3e étage, celui d'Avi au 5e |
| Le **formulaire** « informations de l'étudiant » | Le formulaire rempli par Dana, celui rempli par Avi |
| Un **modèle** de fabrication de voitures sur une chaîne | La voiture rouge immatriculée 12-345-67 |
| Le **concept** « compte bancaire » | Le compte de Dana avec 1 200 NIS, celui d'Avi avec 400 NIS |

- Tous les appartements construits selon le même plan ont **la même structure** (pièces, cuisine), mais **des valeurs différentes** (couleur des murs, qui y habite).
- Une modification de l'appartement de Dana **ne change pas** celui d'Avi.
- Le plan lui-même **n'est pas un appartement**. On ne peut pas habiter dans un plan.

{{< box "analogy" "L'analogie centrale de toute la partie POO" >}}
**Class = un modèle de fabrication de voitures.** Il définit que chaque voiture a une couleur, un modèle et une plaque d'immatriculation (**les attributs**), et que chaque voiture sait rouler, freiner et klaxonner (**les méthodes**).
**Object = une voiture précise** fabriquée à partir du modèle : rouge, modèle 2024, plaque 12-345-67. On peut en fabriquer des milliers, chacune avec sa propre couleur et son propre numéro.
{{< /box >}}

### Syntaxe

Nous n'écrivons pas encore de classe. Dans le chapitre suivant, nous apprendrons la syntaxe complète. Mais voici un « avant-goût », pour voir où nous allons. Le même système bancaire, en POO :

```python
class BankAccount:
    def __init__(self, owner, balance):
        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("insufficient funds")
        self.balance = self.balance - amount

account1 = BankAccount("Dana", 1000)
account1.deposit(200)
print(account1.balance)
```

**N'essayez pas de comprendre chaque mot maintenant** (`class`, `__init__`, `self`), car chacun a un chapitre entier plus loin dans la leçon. Remarquez seulement une chose : **les données (`owner`, `balance`) et les actions (`deposit`, `withdraw`) sont au même endroit**, et on les appelle **à travers** l'objet : `account1.deposit(200)`.

### Premier exemple de code

Vous utilisez des objets depuis le début, sans le savoir ! En Python, **chaque valeur est un objet**, avec ses propres données et méthodes :

```python
name = "dana cohen"
print(name.title())
print(name.upper())
print(type(name))

numbers = [3, 1, 2]
numbers.append(5)
numbers.sort()
print(numbers)
print(type(numbers))
```

**Sortie :**

```text
Dana Cohen
DANA COHEN
<class 'str'>
[1, 2, 3, 5]
<class 'list'>
```

### Analyse du code ligne par ligne

| Code | Ce que c'est en POO |
|---|---|
| `name = "dana cohen"` | Un **objet** de type `str` a été créé. Ses données : les caractères. |
| `name.title()` | Un appel à la **méthode** `title` de l'objet. Elle « sait » travailler sur **son propre** texte. |
| `type(name)` → `<class 'str'>` | La **classe** (le modèle) de l'objet est `str`. |
| `numbers.append(5)` | Une méthode d'un objet `list` qui modifie **ses propres données**. |
| `<class 'list'>` | `list` est aussi une classe. Chaque liste que vous avez créée est un objet de la classe `list`. |

**Point clé :** `str` et `list` sont des **classes** que Python a écrites pour nous. Dans cette partie, nous apprendrons à écrire **nos propres classes** : `BankAccount`, `Student`, `Product`, adaptées à notre problème.

### Un autre exemple

**Un autre problème procédural : un système d'étudiants.** Prenez le code suivant, et essayez de repérer les problèmes vous-même.

```python
student1 = {"name": "Dana", "grades": [90, 85]}
student2 = {"name": "Avi", "grades": [70]}
student3 = {"nmae": "Noa", "grades": []}

def add_grade(student, grade):
    student["grades"].append(grade)

def average(student):
    return sum(student["grades"]) / len(student["grades"])

add_grade(student1, 100)
student2["grades"].append(500)
print(average(student1))
print(average(student2))
print(average(student3))
```

```text
91.66666666666667
285.0
Traceback (most recent call last):
  ...
ZeroDivisionError: division by zero
```

**Les problèmes :**

1. `student3` a été créé avec une **faute de frappe** (`"nmae"`). Aucun endroit ne définit ce qui « doit » se trouver dans un étudiant.
2. `student2["grades"].append(500)` a **contourné** `add_grade`. Même si `add_grade` vérifiait que la note est entre 0 et 100, la vérification ne s'exécuterait pas. Une moyenne de 285 !
3. `average` plante sur un étudiant sans notes. Et celui qui utilise un dict d'étudiant doit **savoir** que cette fonction existe, et penser à gérer ce cas.

En POO, `Student` sera une seule classe avec une **structure définie** (un nom et des notes, **toujours**), avec une méthode `add_grade` qui est **le seul moyen** d'ajouter une note (et qui la vérifie), et avec une méthode `average` qui sait gérer une liste vide. **Les règles se trouvent à côté des données qu'elles protègent.**

### Résumé

- **Programmation procédurale** : les données (dicts) et les fonctions séparément. Ça fonctionne, mais quand le système grandit, les données sont exposées, les actions dispersées, et il n'y a pas de structure contraignante.
- **POO** : organiser le code autour de **choses**. Chaque objet contient ensemble des **données** (attributs) et des **actions** (méthodes).
- **Class** = un modèle (un plan de construction, un modèle de fabrication). **Object** = une chose précise créée à partir de lui.
- En Python, **chaque valeur est un objet** : `str` et `list` sont des classes, et `"hi".upper()` est un appel de méthode.
- La POO est **une façon de penser** : quelles choses existent, ce qu'elles savent, et ce qu'elles font. Tout n'a pas besoin d'être une classe.

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

### Documentation officielle

- [Python Tutorial — Classes](https://docs.python.org/3/tutorial/classes.html)

<!-- docs:end -->

## Chapitre 2 : Class et Object — définir un modèle et créer des objets à partir de lui

### Qu'allons-nous apprendre ?

- Ce qu'est une **classe** (Class) et ce qu'est un **objet** (Object, aussi appelé **Instance**).
- Ce que signifie le mot **`class`**.
- **La syntaxe** pour définir une classe.
- Comment **créer un objet** à partir d'une classe.
- Pourquoi chaque objet est **indépendant**.

### Pourquoi en avons-nous besoin ?

Dans le chapitre précédent, nous avons vu que le problème avec « dict + fonctions » est qu'il n'y a pas de **structure définie** ni d'endroit unique qui rassemble les données et les actions. La classe est l'outil qui crée cette structure. Tout le reste (`__init__`, `self`, les méthodes) est construit sur elle.

### Explication à partir des bases

#### Qu'est-ce qu'une classe ?

Une **classe** est un **modèle** (Blueprint) qui définit **un nouveau type** de chose : quelles informations aura chaque chose de ce type, et ce qu'elle saura faire.

- Une classe ne contient **pas** les données d'une chose précise. C'est seulement **la définition**.
- À partir d'une seule classe, on peut créer **autant de choses qu'on veut**.
- Quand on définit une classe, on crée en fait **un nouveau type** en Python, exactement comme `str` ou `list`.

#### Qu'est-ce qu'un objet (Instance) ?

Un **objet** est **une chose précise créée à partir d'une classe**. Il a des données **qui lui sont propres**.

**Instance** est un synonyme : « un objet **de** la classe `Student` » = « une **instance** de `Student` ». L'acte de créer un objet à partir d'une classe s'appelle l'**instanciation** (Instantiation).

| | Class | Object / Instance |
|---|---|---|
| Ce que c'est | Un modèle, une définition | Une chose précise |
| Combien | **Une seule** pour chaque type | **Autant** qu'on veut |
| Données | Définit **quels** champs il y aura | Contient des **valeurs** précises |
| Exemple | `Student` | Dana, Avi, Noa |
| Analogie | Un modèle de fabrication de voitures | La voiture rouge immatriculée 12-345-67 |

#### Le mot `class`

`class` est un **mot réservé** (Keyword) de Python, comme `def`, `if` et `for`. Il dit à Python : « ici commence la définition d'**un nouveau type** ». Ensuite vient le **nom** de la classe, puis un **corps** indenté.

#### Les noms de classes

Selon PEP 8, les noms de classes s'écrivent en **PascalCase** : chaque mot commence par une majuscule, sans tirets bas. `Student`, `BankAccount`, `ShoppingCart`. C'est différent des fonctions et des variables (`snake_case`), et ainsi on voit tout de suite si quelque chose est une classe.

### Syntaxe

```python
class ClassName:
    # class body - indented
    pass

my_object = ClassName()
```

| Partie | Explication |
|---|---|
| `class` | Un mot réservé : « une définition de classe commence ». |
| `ClassName` | Le nom de la classe, en PascalCase. |
| `:` | Obligatoire, comme après `def` ou `if`. |
| Un corps indenté | Ce que contient le modèle (dans les chapitres suivants : `__init__` et des méthodes). |
| `pass` | « Ne rien faire ». Le corps d'une classe (ou d'une fonction) ne peut pas être vide, donc `pass` occupe la place. |
| `ClassName()` | **Créer un objet** : on appelle la classe **avec des parenthèses**, comme une fonction. On obtient un nouvel objet. |

### Premier exemple de code

`week-3/first_class.py` :

```python
class Person:
    pass

dana = Person()
avi = Person()

print(dana)
print(avi)
print(type(dana))
print(dana is avi)
print(isinstance(dana, Person))
```

**Sortie :**

```text
<__main__.Person object at 0x000001F4A3B2C1D0>
<__main__.Person object at 0x000001F4A3B2C2E0>
<class '__main__.Person'>
False
True
```

### Analyse du code ligne par ligne

| Ligne | Explication |
|---|---|
| `class Person:` | Définit **un nouveau type** nommé `Person`. Aucune personne n'a encore été créée. |
| `pass` | Le modèle est vide pour l'instant. |
| `dana = Person()` | **Instanciation** : un nouvel objet de type `Person` a été créé, et le nom `dana` pointe vers lui. |
| `avi = Person()` | **Un autre** objet, entièrement séparé. |
| `print(dana)` | L'objet n'a pas de « contenu » à afficher, donc Python affiche une description par défaut : le type, et une adresse en mémoire (`0x...`). Votre adresse sera différente. |
| Deux adresses différentes | Deux objets séparés en RAM. |
| `type(dana)` | `<class '__main__.Person'>`. La classe de l'objet est `Person`, définie dans le fichier principal (`__main__`). |
| `dana is avi` | `False` : ce n'est pas le même objet (souvenez-vous de `is` !). |
| `isinstance(dana, Person)` | `True` : « `dana` est-il une instance de `Person` ? » |

### Un autre exemple

**Ajouter des données à un objet — et voir pourquoi ce n'est pas la bonne façon.** On peut attacher un **attribut** à un objet de l'extérieur, avec un point :

```python
class Product:
    pass

laptop = Product()
laptop.name = "Laptop"
laptop.price = 3500

mouse = Product()
mouse.name = "Mouse"
mouse.price = 80

print(laptop.name, laptop.price)
print(mouse.name, mouse.price)

mouse.price = 70
print("After update:", mouse.price, "| laptop unchanged:", laptop.price)
```

**Sortie :**

```text
Laptop 3500
Mouse 80
After update: 70 | laptop unchanged: 3500
```

**Analyse :**

- `laptop.name = "Laptop"` — crée un **attribut** nommé `name` **sur l'objet `laptop`**. Le point signifie « de » : `laptop.name` = « le nom **du** portable ».
- Chaque objet a des attributs **qui lui sont propres**. Modifier `mouse.price` ne touche pas `laptop`.
- **C'est déjà mieux qu'un dict :** `laptop.price` est plus lisible que `laptop["price"]`.

**Mais il y a ici un gros problème.** Qu'est-ce qui garantit que chaque `Product` a un `name` et un `price` ?

```python
keyboard = Product()
keyboard.nmae = "Keyboard"
print(keyboard.name)
```

```text
AttributeError: 'Product' object has no attribute 'name'
```

- Une faute de frappe (`nmae`) a créé un attribut différent.
- Nous avons oublié de définir `price`, et il n'y a aucun avertissement tant que personne n'essaie de l'utiliser.
- C'est **exactement le problème du dict** du chapitre précédent ! La classe vide ne définit toujours pas de **structure**.

**La solution : la classe elle-même doit définir quelles données chaque objet doit avoir, et les remplir au moment de la création.** C'est le rôle de **`__init__`**, le sujet du chapitre suivant.

### Résumé

- **Class** = un modèle qui définit **un nouveau type**. **Object / Instance** = une chose précise créée à partir de lui.
- Syntaxe : `class Name:` + un corps indenté (ou `pass`). Les noms en **PascalCase**.
- **Créer un objet** : `obj = Name()`, **avec des parenthèses**. Sans parenthèses, c'est la classe elle-même.
- Chaque objet est indépendant : `a is b` → `False`. Les attributs de l'un n'affectent pas l'autre.
- `type(obj)` renvoie la classe. `isinstance(obj, Name)` vérifie l'appartenance.
- Ajouter des attributs « de l'extérieur » fonctionne, mais **ne définit pas de structure**. C'est pourquoi nous avons besoin de `__init__`.

<!-- 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 Objects](https://docs.python.org/3/tutorial/classes.html#class-objects)

<!-- docs:end -->

## Chapitre 3 : `__init__` et `self` — un objet qui naît complet

### Qu'allons-nous apprendre ?

- Ce qu'est **`__init__`**, la fonction qui s'exécute automatiquement quand un objet est créé.
- Ce qu'est un **constructeur** (Constructor).
- Ce qu'est **`self`**, le mot qui apparaît partout en POO.
- Les **Instance Attributes** : les données de chaque objet.

### Pourquoi en avons-nous besoin ?

Dans le chapitre précédent, nous avons ajouté des attributs « de l'extérieur » : `laptop.name = "Laptop"`. Nous avons vu que c'est dangereux : on peut oublier un champ, faire une faute de frappe, et il n'y a pas de structure contraignante. Nous avons même écrit une fonction d'aide `make_book` pour garantir que chaque livre a un `title` et des `pages`.

**`__init__` est exactement ce `make_book`, mais à l'intérieur de la classe.** Il garantit que **chaque** objet est créé **initialisé** : avec tous ses champs, et avec des valeurs, dès le tout premier instant.

### Explication à partir des bases

#### `__init__` — la méthode d'initialisation

**`__init__`** (prononcé « dunder init », de **d**ouble **under**score **init**) est une fonction spéciale définie **à l'intérieur** d'une classe. Elle :

- **Est appelée automatiquement** dès qu'un nouvel objet est créé : `Student(...)`.
- **Initialise** l'objet, c'est-à-dire y met les données initiales.
- Ne **renvoie** rien (pas besoin de `return`).

Le nom avec deux tirets bas de chaque côté marque une **méthode spéciale** de Python (appelée **Dunder Method** ou **Magic Method**). Python cherche exactement ce nom, et l'appelle tout seul. Vous n'écrivez jamais `obj.__init__()` vous-même.

#### Le constructeur

**Constructeur** (Constructor) = le code qui s'exécute quand un nouvel objet est construit. En Python, c'est `__init__` qui joue ce rôle, on l'appelle donc généralement « le constructeur ». (Pour être techniquement précis : la construction elle-même, l'allocation de l'objet en mémoire, se fait une étape plus tôt, et `__init__` « ne fait que » le remplir. Pour les besoins du cours, `__init__` = constructeur.)

#### `self` — « cet objet »

**`self`** est **le premier paramètre** de chaque méthode d'une classe, y compris `__init__`. Il pointe vers **l'objet précis** sur lequel la méthode travaille en ce moment.

Pourquoi en a-t-on besoin ? La classe est **un seul modèle**, mais **beaucoup** d'objets sont créés à partir d'elle. Quand le code de `__init__` dit « enregistre le nom », il doit savoir **dans quel objet** l'enregistrer. `self` est la réponse : « dans l'objet en cours de création **maintenant** ».

```python
self.name = name
```

Lisez-le ainsi : « **dans** cet objet (`self`), crée un attribut nommé `name`, et mets-y la valeur reçue dans le paramètre `name` ».

- `self.name` — l'**attribut** de l'objet. Il reste tant que l'objet existe.
- `name` (seul) — **le paramètre** de `__init__` : une variable **locale** (Local, souvenez-vous de la portée !) qui disparaît à la fin de `__init__`.

**D'où vient `self` ?** **Python le passe automatiquement !** Quand vous écrivez `Student("Dana", 24)`, vous passez 2 arguments, mais `__init__` en reçoit 3 : Python ajoute le nouvel objet en tant que `self`, au début.

{{< box "analogy" "Analogie : un formulaire d'inscription" >}}
La classe est **un formulaire vierge** avec des champs : « Nom : ____ Âge : ____ ». `__init__` est **l'employé des inscriptions** qui remplit un nouveau formulaire pour chaque étudiant qui arrive. `self` est **« le formulaire sur le bureau en ce moment »** : l'employé écrit « Nom : Dana » **sur ce formulaire-là**, et non sur tous les formulaires de l'armoire. Demain Avi arrivera, et un **autre** formulaire sera rempli, mais l'employé l'appellera de nouveau « le formulaire sur le bureau » (`self`).
{{< /box >}}

#### Ce qui se passe à la ligne `dana = Student("Dana", 24)`

1. Python **crée un nouvel objet vide** de type `Student` en mémoire.
2. Python **appelle `__init__`** et lui passe : `self` = le nouvel objet, `name = "Dana"`, `age = 24`.
3. `__init__` exécute `self.name = name`, c'est-à-dire que le `name` du nouvel objet = `"Dana"`. Et de même pour `age`.
4. `__init__` se termine. Les paramètres locaux disparaissent, et les attributs restent sur l'objet.
5. Python **renvoie l'objet**, et le nom `dana` pointe vers lui.

#### Les Instance Attributes

**Instance Attributes** = des données qui appartiennent **à un objet précis**. Chaque objet a ses propres valeurs. On les définit dans `__init__` avec `self.something = ...`.

### Syntaxe

```python
class ClassName:
    def __init__(self, param1, param2):
        self.attribute1 = param1
        self.attribute2 = param2
        self.attribute3 = "default value"   # attributes do not have to come from parameters

obj = ClassName(arg1, arg2)    # Python calls __init__(obj, arg1, arg2) for you
obj.attribute1                 # read an instance attribute
```

- `__init__` s'écrit **à l'intérieur** de la classe, indenté.
- `self` est **toujours en premier** dans la liste des paramètres.
- Lors de l'appel, on **ne passe pas** `self`.
- On peut donner des valeurs par défaut aux paramètres, comme dans n'importe quelle fonction : `def __init__(self, name, age=18):`.

### Premier exemple de code

`week-3/student.py` :

```python
class Student:
    def __init__(self, name, age):
        self.name = name
        self.age = age
        self.grades = []

dana = Student("Dana", 24)
avi = Student("Avi", 31)

print(dana.name, dana.age, dana.grades)
print(avi.name, avi.age, avi.grades)

dana.grades.append(95)
print("Dana's grades:", dana.grades)
print("Avi's grades:", avi.grades)
```

**Sortie :**

```text
Dana 24 []
Avi 31 []
Dana's grades: [95]
Avi's grades: []
```

### Analyse du code ligne par ligne

| Ligne | Explication |
|---|---|
| `class Student:` | La définition du modèle. |
| `def __init__(self, name, age):` | Le constructeur. `self` = l'objet en cours de création. `name`, `age` = ce que l'on passe à la création. |
| `self.name = name` | Un attribut `name` **sur l'objet**, avec la valeur du paramètre. |
| `self.age = age` | De même pour l'âge. |
| `self.grades = []` | Un attribut qui ne vient pas d'un paramètre : **chaque** étudiant commence avec sa **propre** liste de notes vide. |
| `dana = Student("Dana", 24)` | Python : crée un objet, appelle `__init__(new_obj, "Dana", 24)`, et le renvoie. |
| `avi = Student("Avi", 31)` | Un deuxième objet. `__init__` s'exécute **à nouveau**, avec `self` = le **deuxième** objet. |
| `dana.grades.append(95)` | Accès à la liste **de Dana** et ajout dans celle-ci. |
| `avi.grades` reste `[]` | Chaque `__init__` a créé une **nouvelle** liste, il n'y a donc pas de partage. (Comparez avec le bug de `def f(cart=[])` dans la leçon sur les fonctions !) |

**Maintenant la structure est garantie :** on ne peut pas créer un `Student` sans nom ni âge, et chaque étudiant a toujours des `grades`.

### Un autre exemple

**Un produit avec des valeurs par défaut, un attribut calculé et une première validation.** `week-3/product.py` :

```python
class Product:
    def __init__(self, name, price, quantity=0):
        if price < 0:
            raise ValueError("price cannot be negative, got " + str(price))
        self.name = name
        self.price = price
        self.quantity = quantity
        self.in_stock = quantity > 0

laptop = Product("Laptop", 3500, 4)
cable = Product("USB Cable", 25)

print(laptop.name, laptop.price, laptop.quantity, laptop.in_stock)
print(cable.name, cable.price, cable.quantity, cable.in_stock)

try:
    broken = Product("Ghost", -10)
except ValueError as error:
    print("Could not create product:", error)
```

**Sortie :**

```text
Laptop 3500 4 True
USB Cable 25 0 False
Could not create product: price cannot be negative, got -10
```

**Analyse :**

- `quantity=0` — une valeur par défaut. `Product("USB Cable", 25)` reçoit une quantité de 0.
- `self.in_stock = quantity > 0` — un attribut **calculé** à partir des données : `True` ou `False`.
- **La validation dans le constructeur :** `__init__` **refuse** de créer un produit avec un prix négatif, et lance une `ValueError` (souvenez-vous de `raise` !). Un produit invalide **ne peut pas exister** dans le système. C'est exactement « garder les règles à côté des données » dont nous avons parlé au premier chapitre.
- Quand `__init__` lance une erreur, l'objet **n'est pas créé**, et `broken` ne reçoit pas de valeur.

#### Person — un troisième exemple, pour consolider

```python
class Person:
    def __init__(self, first_name, last_name, city="Unknown"):
        self.first_name = first_name
        self.last_name = last_name
        self.city = city
        self.full_name = first_name + " " + last_name

noa = Person("Noa", "Levi", "Haifa")
tom = Person("Tom", "Cohen")
print(noa.full_name, "-", noa.city)
print(tom.full_name, "-", tom.city)
```

```text
Noa Levi - Haifa
Tom Cohen - Unknown
```

### Résumé

- **`__init__`** = le constructeur : appelé **automatiquement** à la création d'un objet, il initialise ses données.
- **`self`** = l'objet précis sur lequel la méthode travaille en ce moment. **Toujours le premier paramètre**, et Python le passe tout seul.
- **`self.x = x`** : `self.x` = un attribut enregistré sur l'objet. `x` = un paramètre local qui disparaît.
- `obj = Class(a, b)` → Python crée un objet, appelle `__init__(obj, a, b)`, et renvoie l'objet.
- Dans `__init__`, on peut avoir : des valeurs par défaut, des attributs calculés, et **une validation avec `raise`**, pour qu'un objet invalide ne soit jamais créé.

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

### Documentation officielle

- [Class Objects — __init__](https://docs.python.org/3/tutorial/classes.html#class-objects)
- [object.__init__](https://docs.python.org/3/reference/datamodel.html#object.__init__)

<!-- docs:end -->

## Chapitre 4 : Les méthodes — les actions de l'objet

### Qu'allons-nous apprendre ?

- Ce qu'est une **méthode** (Method).
- Comment la **définir** et comment l'**appeler**.
- Trois types de méthodes selon ce qu'elles font : elles **calculent**, elles **modifient l'état**, elles **affichent**.
- Une méthode qui appelle **une autre méthode**.
- **`__str__`** : comment un objet « se présente ».

### Pourquoi en avons-nous besoin ?

Jusqu'ici, nos objets n'avaient que des **données**. Mais l'idée de la POO, c'est **données + actions** ensemble. Au premier chapitre, nous avons vu que les règles (comme « on ne peut pas retirer plus que le solde ») doivent se trouver **à côté des données**. Les méthodes sont l'endroit où vivent ces règles.

Et il reste un problème ouvert du chapitre précédent : la `description` de la voiture était calculée une seule fois dans `__init__`, et devenait obsolète quand la couleur changeait. Une méthode recalcule **à chaque fois**.

### Explication à partir des bases

#### Qu'est-ce qu'une méthode ?

**Méthode** = **une fonction définie à l'intérieur d'une classe**, qui agit sur **un objet précis** de cette classe.

| | Function (une fonction ordinaire) | Method |
|---|---|---|
| Où elle est définie | Au niveau supérieur du fichier | **Dans** une classe |
| Comment on l'appelle | `function_name(args)` | `obj.method_name(args)` |
| Sur quoi elle travaille | Sur ce qu'on lui passe | Sur **son objet** (`self`), et sur ce qu'on lui passe |
| Le premier paramètre | Ce que vous voulez | **`self`** |

Vous avez déjà utilisé des méthodes : `"hi".upper()`, `numbers.append(5)`. Aujourd'hui, nous écrivons **les nôtres**.

#### `self` dans les méthodes

Exactement comme dans `__init__` : quand vous appelez `dana.add_grade(90)`, Python passe automatiquement `dana` en tant que `self`. Dans la méthode, `self.grades` = **les notes de Dana**. Si vous appelez `avi.add_grade(80)`, exactement la même méthode s'exécute, mais `self` est Avi.

```text
dana.add_grade(90)   →   Student.add_grade(dana, 90)
                                            ↑     ↑
                                          self  grade
```

La ligne de gauche est ce que nous écrivons. La ligne de droite est ce que Python fait réellement.

#### Trois types de méthodes (selon ce qu'elles font)

| Type | Ce qu'elle fait | Exemple |
|---|---|---|
| **Getter** | **Calcule et renvoie** une valeur à partir des données, sans rien modifier | `average()`, `is_passing()` |
| **Setter** | **Modifie l'état** de l'objet | `add_grade(90)`, `deposit(100)` |
| **Affichage** | Affiche / renvoie du texte à afficher | `print_report()`, `__str__()` |

Une distinction utile du Clean Code : une bonne méthode est généralement **soit** un Getter **soit** un Setter, pas les deux à la fois (SRP !).

#### L'état (State)

**L'état** = les valeurs de tous les attributs de l'objet **à un instant donné**. Un compte avec un solde de 1 000 est dans un certain état. Après `deposit(200)`, il est dans un **nouvel** état. Les méthodes Setter sont la façon **ordonnée** de modifier l'état.

#### `__str__` — à quoi ressemble l'objet à l'affichage

Vous vous souvenez que `print(dana)` affichait `<__main__.Student object at 0x...>` ? Si vous définissez dans la classe une méthode spéciale nommée **`__str__`** (encore un Dunder !) qui renvoie une chaîne, **`print` et `str()` l'utiliseront** pour afficher l'objet.

### Syntaxe

```python
class ClassName:
    def __init__(self, ...):
        self.data = ...

    def getter_method(self):             # returns something, changes nothing
        return ... self.data ...

    def setter_method(self, value):      # changes the object's state
        self.data = ...

    def uses_other_method(self):
        return self.getter_method() * 2  # call another method on the same object

    def __str__(self):                   # used by print() and str()
        return "readable text about " + str(self.data)

obj = ClassName(...)
obj.setter_method(5)           # no self in the call
result = obj.getter_method()
print(obj)                     # calls __str__ automatically
```

- Chaque méthode est **indentée dans** la classe, et `self` est **en premier**.
- Entre les méthodes, mettez une ligne vide (PEP 8).
- Dans une méthode, on accède aux données **et aux autres méthodes** avec `self.`.

### Premier exemple de code

`week-3/student_methods.py` :

```python
PASSING_GRADE = 60

class Student:
    def __init__(self, name):
        self.name = name
        self.grades = []

    def add_grade(self, grade):
        if grade < 0 or grade > 100:
            raise ValueError("grade must be between 0 and 100, got " + str(grade))
        self.grades.append(grade)

    def average(self):
        if not self.grades:
            return 0
        return sum(self.grades) / len(self.grades)

    def is_passing(self):
        return self.average() >= PASSING_GRADE

    def __str__(self):
        return self.name + " (average " + str(round(self.average(), 1)) + ")"

dana = Student("Dana")
dana.add_grade(90)
dana.add_grade(75)

avi = Student("Avi")
avi.add_grade(50)

print(dana)
print(avi)
print("Dana passing?", dana.is_passing())
print("Avi passing?", avi.is_passing())
```

**Sortie :**

```text
Dana (average 82.5)
Avi (average 50.0)
Dana passing? True
Avi passing? False
```

### Analyse du code ligne par ligne

| Code | Type | Explication |
|---|---|---|
| `def add_grade(self, grade):` | **Setter** | Modifie l'état : ajoute à `self.grades`. Et **vérifie** d'abord la règle (0–100). |
| `def average(self):` | **Getter** | Calcule à partir des données **de l'objet** et renvoie. Ne modifie rien. Gère une liste vide (le bug du premier chapitre !). |
| `def is_passing(self):` | **Getter** | Appelle **une autre méthode** du même objet : `self.average()`. |
| `def __str__(self):` | **Affichage** | Renvoie une chaîne. `print(dana)` l'appelle automatiquement. |
| `dana.add_grade(90)` | | Python : `Student.add_grade(dana, 90)`. `self` = dana. |
| `print(avi)` | | Python : `print(avi.__str__())`, c'est-à-dire `"Avi (average 50.0)"`. |

**Le point important :** le code **qui utilise** la classe (`dana.add_grade(...)`) n'a pas besoin de savoir **comment** les notes sont stockées (une liste), ni de vérifier la plage lui-même. C'est la classe qui en est responsable.

### Un autre exemple

**Un compte bancaire — le problème du premier chapitre, résolu.** `week-3/bank_account.py` :

```python
class BankAccount:
    def __init__(self, owner, balance=0):
        if balance < 0:
            raise ValueError("initial balance cannot be negative")
        self.owner = owner
        self.balance = balance
        self.transactions = []

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("deposit must be positive, got " + str(amount))
        self.balance = self.balance + amount
        self.transactions.append("deposit " + str(amount))

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("withdrawal must be positive, got " + str(amount))
        if amount > self.balance:
            raise ValueError("insufficient funds: balance " + str(self.balance) + ", requested " + str(amount))
        self.balance = self.balance - amount
        self.transactions.append("withdraw " + str(amount))

    def transfer_to(self, other_account, amount):
        self.withdraw(amount)
        other_account.deposit(amount)

    def statement(self):
        print("--- Statement for", self.owner, "---")
        for transaction in self.transactions:
            print(" ", transaction)
        print("  Balance:", self.balance)

    def __str__(self):
        return "Account of " + self.owner + ": " + str(self.balance) + " NIS"

dana = BankAccount("Dana", 1000)
avi = BankAccount("Avi")

dana.deposit(200)
dana.transfer_to(avi, 300)

try:
    avi.withdraw(5000)
except ValueError as error:
    print("Rejected:", error)

print(dana)
print(avi)
dana.statement()
```

**Sortie :**

```text
Rejected: insufficient funds: balance 300, requested 5000
Account of Dana: 900 NIS
Account of Avi: 300 NIS
--- Statement for Dana ---
  deposit 200
  withdraw 300
  Balance: 900
```

**Analyse :**

- `transfer_to(self, other_account, amount)` — une méthode qui reçoit **un autre objet** en paramètre. Elle ne modifie **pas** directement les soldes. Elle utilise `self.withdraw` et `other_account.deposit`, et ainsi toutes les vérifications s'exécutent automatiquement. **DRY !**
- Si `withdraw` échoue (pas assez d'argent), elle lance une erreur **avant** le `deposit`, donc aucun « argent n'est créé à partir de rien ».
- `self.transactions` — un historique interne que chaque action met à jour. Celui qui utilise le compte n'a pas besoin de penser à enregistrer une transaction.
- `statement()` est une méthode d'**affichage**. `__str__` donne une courte description pour `print`.
- **Comparez avec la version procédurale du premier chapitre :** les mêmes actions, mais il y a maintenant **un seul** endroit qui définit ce qu'est un compte et ce qu'on a le droit d'en faire.

### Résumé

- **Méthode** = une fonction dans une classe. On la définit avec `self` en premier, et on appelle `obj.method(args)` **sans** `self`.
- `obj.method(x)` équivaut à `Class.method(obj, x)`. C'est ainsi que `self` « sait » sur quel objet travailler.
- Dans une méthode, `self.attribute` et `self.other_method()`.
- Un **Getter** renvoie et ne modifie rien. Un **Setter** modifie l'état (et vérifie les règles).
- **`__str__`** renvoie une chaîne, et `print(obj)` l'utilise.
- Une information dérivée se calcule mieux dans une méthode, plutôt que d'être stockée comme un attribut qui risque de devenir obsolète.

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

### Documentation officielle

- [Method Objects](https://docs.python.org/3/tutorial/classes.html#method-objects)

<!-- docs:end -->

## Chapitre 5 : Les données partagées — Instance Attribute face à Class Attribute

### Qu'allons-nous apprendre ?

- **Attribut** face à **méthode** : comment décider de ce qu'est chaque chose.
- **Instance Attribute** : les données **de chaque objet** séparément (ce que nous avons fait jusqu'ici).
- **Class Attribute** : les données **de la classe**, partagées par tous les objets.
- Quand utiliser chacun, et quels pièges existent.

### Pourquoi en avons-nous besoin ?

Supposons qu'il y ait une classe `Product`, et que tous les produits soient soumis à la même TVA : 17 %. Où garder la TVA ?

- Comme Instance Attribute (`self.vat = 0.17`) ? Alors il y a **mille copies** du même nombre, et si la TVA change, il faut mettre à jour mille objets.
- Comme constante globale en haut du fichier ? Possible, mais elle est « loin » de la classe à laquelle elle appartient.

Et si l'on veut savoir **combien de produits ont été créés** au total ? Aucun produit ne le « sait » à lui seul. C'est une information sur **tout le type**. Les Class Attributes résolvent les deux cas.

### Explication à partir des bases

#### Attribut ou méthode ?

| | Attribute | Method |
|---|---|---|
| Ce que c'est | **Une donnée**, ce que l'objet **sait / contient** | **Une action**, ce que l'objet **fait / calcule** |
| Nature grammaticale | Un nom / un adjectif | **Un verbe** |
| Exemples | `name`, `price`, `balance`, `is_active` | `deposit()`, `add_grade()`, `total()` |
| Accès | `obj.price` | `obj.total()` **avec des parenthèses** |

**Des questions qui aident à décider :**

1. **Est-ce une donnée stockée, ou quelque chose de calculé ?** Une donnée stockée → un attribut. Calculé à partir d'autres données → généralement une méthode (nous l'avons vu : la `description` qui devenait obsolète).
2. **Cela modifie-t-il l'objet ?** Toute modification ordonnée → une méthode (une commande).
3. **Faut-il des règles lors de la modification ?** Si oui, la modification passe par une méthode (et nous l'approfondirons dans la leçon sur l'encapsulation).

#### Instance Attribute

Défini avec **`self.x = ...`**, généralement dans `__init__`. **Chaque objet a sa propre copie**, avec sa propre valeur.

#### Class Attribute

Défini **directement dans le corps de la classe**, en dehors de toute méthode, **sans `self`** :

```python
class Product:
    VAT_RATE = 0.17       # class attribute - one shared value
    count = 0             # class attribute

    def __init__(self, name):
        self.name = name  # instance attribute - per object
```

- Il n'y a **qu'une seule copie**, qui appartient à la classe elle-même.
- **Tous les objets** la voient.
- On y accède par la classe (`Product.VAT_RATE`, la façon recommandée), ou par un objet (`laptop.VAT_RATE`, fonctionne en lecture).

{{< box "analogy" "Analogie : une classe à l'école" >}}
**Les Instance Attributes** = ce que chaque élève garde pour lui : un nom, des notes, un cahier. **Les Class Attributes** = ce qui est affiché au tableau de la classe et partagé par tous : le nom du professeur principal, le numéro de la salle, et le nombre d'élèves dans la classe. Quand le professeur change, on met à jour le tableau **une seule fois**, et pas dans le cahier de chaque élève.
{{< /box >}}

#### Comment Python trouve un attribut

Quand vous écrivez `obj.x`, Python cherche :

1. **D'abord sur l'objet** (les Instance Attributes).
2. **S'il ne le trouve pas, sur la classe** (les Class Attributes et les méthodes).
3. Pas trouvé → `AttributeError`.

(Remarquez : c'est semblable à LEGB de la leçon sur la portée, une recherche par couches.) C'est aussi de là que vient le piège de l'erreur courante.

### Syntaxe

```python
class ClassName:
    SHARED_CONSTANT = 100        # class attribute (constant style: UPPER_CASE)
    counter = 0                  # class attribute (changes, shared)

    def __init__(self, value):
        self.value = value                     # instance attribute
        ClassName.counter = ClassName.counter + 1   # update the class attribute through the CLASS

    def uses_constant(self):
        return self.value * ClassName.SHARED_CONSTANT

ClassName.SHARED_CONSTANT        # read through the class
ClassName.counter
```

### Premier exemple de code

`week-3/products_shared.py` :

```python
class Product:
    VAT_RATE = 0.17
    created_count = 0

    def __init__(self, name, price):
        self.name = name
        self.price = price
        Product.created_count = Product.created_count + 1

    def price_with_vat(self):
        return round(self.price * (1 + Product.VAT_RATE), 2)

laptop = Product("Laptop", 1000)
mouse = Product("Mouse", 50)

print(laptop.name, laptop.price_with_vat())
print(mouse.name, mouse.price_with_vat())
print("Products created:", Product.created_count)

Product.VAT_RATE = 0.18
print("After VAT change:", laptop.price_with_vat(), mouse.price_with_vat())
```

**Sortie :**

```text
Laptop 1170.0
Mouse 58.5
Products created: 2
After VAT change: 1180.0 59.0
```

### Analyse du code ligne par ligne

| Code | Type | Explication |
|---|---|---|
| `VAT_RATE = 0.17` | **Class Attribute** | Une seule valeur pour tous les produits. Les majuscules = « une constante » (une convention). |
| `created_count = 0` | **Class Attribute** | Un compteur partagé. Il commence à 0 quand la classe est définie. |
| `self.name`, `self.price` | **Instance Attributes** | Différents pour chaque produit. |
| `Product.created_count = Product.created_count + 1` | | Chaque `__init__` met à jour le compteur **de la classe**, il compte donc tous les objets. |
| `Product.VAT_RATE` dans la méthode | | La lecture d'un Class Attribute. |
| `Product.VAT_RATE = 0.18` | | **Une seule** modification, et **tous** les produits sont affectés immédiatement, car ils lisent tous au même endroit. |

### Un autre exemple

**Une classe avec un nombre de places limité, et un identifiant unique automatique.** `week-3/students_ids.py` :

```python
class Student:
    SCHOOL_NAME = "Kodkod 6"
    MAX_STUDENTS = 3
    next_id = 1
    all_students = []

    def __init__(self, name):
        if len(Student.all_students) >= Student.MAX_STUDENTS:
            raise ValueError("class is full - cannot add " + name)
        self.name = name
        self.student_id = Student.next_id
        Student.next_id = Student.next_id + 1
        Student.all_students.append(self)

    def __str__(self):
        return "#" + str(self.student_id) + " " + self.name + " (" + Student.SCHOOL_NAME + ")"

for name in ["Dana", "Avi", "Noa", "Tom"]:
    try:
        Student(name)
    except ValueError as error:
        print("Error:", error)

for student in Student.all_students:
    print(student)
print("Next id will be:", Student.next_id)
```

**Sortie :**

```text
Error: class is full - cannot add Tom
#1 Dana (Kodkod 6)
#2 Avi (Kodkod 6)
#3 Noa (Kodkod 6)
Next id will be: 4
```

**Analyse :**

- `next_id` — un compteur qui **produit un identifiant unique** pour chaque étudiant. Chaque objet garde **son propre** numéro dans l'Instance Attribute `student_id`, et le compteur partagé avance.
- `all_students` — **une liste partagée** dans laquelle la classe « enregistre » chaque objet créé. `append(self)` = « ajoute **cet** objet à la liste ».
- `MAX_STUDENTS` — une règle qui s'applique à **toute** la classe. La vérification s'exécute **avant** l'attribution de l'identifiant, Tom n'a donc pas « brûlé » de numéro.
- `Student(name)` dans la boucle sans le stocker dans une variable : l'objet n'est pas perdu, car il est enregistré dans `all_students`.

{{< box "tip" "Quand oui et quand non" >}}
Les Class Attributes sont excellents pour les **constantes** (`VAT_RATE`, `MAX_STUDENTS`) et pour les compteurs. Une liste partagée de « tous les objets » est un bon outil de démonstration, mais dans les vrais projets il vaut généralement mieux gérer la collection dans une classe séparée (par exemple `School` qui contient une liste d'étudiants). Nous l'apprendrons dans les leçons sur la conception orientée objet (la composition).
{{< /box >}}

### Résumé

- **Attribute** = ce que l'objet sait (un nom). **Method** = ce qu'il fait (un verbe, avec des parenthèses). Une information calculée → une méthode.
- **Instance Attribute** (`self.x` dans `__init__`) = chaque objet a sa propre valeur.
- **Class Attribute** (dans le corps de la classe, sans `self`) = **une seule** copie partagée. Convient aux constantes et aux compteurs.
- Python cherche `obj.x` d'abord sur l'objet, puis sur la classe.
- Modifiez un Class Attribute **uniquement par le nom de la classe**. `obj.X = ...` crée un nouvel Instance Attribute.
- Une liste/un dict « par objet » → dans `__init__`, **pas** dans le corps de la classe.

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

### Documentation officielle

- [Class and Instance Variables](https://docs.python.org/3/tutorial/classes.html#class-and-instance-variables)

<!-- docs:end -->

## Chapitre 6 : Ce qui devient une classe — d'une exigence verbale à des classes

### Qu'allons-nous apprendre ?

Jusqu'ici, **nous vous avons dit** quelles classes écrire. Maintenant, nous passons de l'autre côté : **c'est vous qui décidez**.

- Comment trouver des classes dans une **description verbale** d'un système.
- **Quatre questions** qui décident : une classe, ou non ?
- Quand quelque chose est un **attribut**, et quand c'est **une classe à part entière**.
- À quelle classe **appartient** chaque action.

### Pourquoi en avons-nous besoin ?

Le programme du cours insiste : le but est de **comprendre** la POO, et pas seulement de savoir recopier une classe. Au travail, personne ne vous dira « écris une classe nommée `Order` avec ces champs ». Vous recevrez une **exigence** : « il nous faut un système dans lequel des clients commandent des produits... », et vous devrez la transformer vous-mêmes en structure de classes.

Une bonne conception au départ fait gagner **des semaines** de corrections. Une mauvaise conception mène à une seule classe énorme, ou à des dizaines de classes vides qui ne font rien.

### Explication à partir des bases

#### Outil 1 : l'analyse des noms et des verbes

Une technique simple et efficace : lisez l'exigence, et marquez :

| Dans le texte | Un candidat pour |
|---|---|
| Les **noms** importants (« client », « commande », « produit ») | Des **classes** ou des **attributs** |
| Les **adjectifs** et propriétés (« prix », « date », « actif ») | Des **attributs** |
| Les **verbes** (« commande », « paie », « annule ») | Des **méthodes** |
| Les **règles** (« il n'est pas possible de... », « au maximum... ») | La **validation / les invariants** |

**C'est un point de départ, pas une réponse.** Tous les noms ne deviennent pas des classes.

#### Outil 2 : quatre questions — est-ce une classe ?

| # | Question | Si la réponse est « oui » |
|---|---|---|
| 1 | **A-t-il une identité et des données propres ?** (chacun a des valeurs différentes) | Penche vers une classe |
| 2 | **A-t-il un comportement ou des règles ?** (actions, validation) | Penche fortement vers une classe |
| 3 | **Y en a-t-il plusieurs instances ?** (beaucoup de clients, beaucoup de commandes) | Penche vers une classe |
| 4 | **Est-ce seulement une valeur unique, sans aucun comportement ?** (« couleur », « nom ») | Un **attribut**, pas une classe |

{{< box "analogy" "Analogie" >}}
« **Voiture** » — elle a sa propre plaque d'immatriculation (identité), elle roule et freine (comportement), et il y a beaucoup de voitures. ✓ Une classe. « **La couleur de la voiture** » — ce n'est qu'une valeur (« rouge »), sans actions propres. Un attribut de la voiture. « **Moteur** » — ça dépend ! Dans un jeu simple, un attribut (`engine_size = 1.6`). Dans un garage qui répare des moteurs, une classe (il a un numéro de série, un historique d'entretien et un état). **Le même concept, une décision différente selon ce dont le système a besoin.**
{{< /box >}}

#### Outil 3 : un simple attribut ou une classe à part entière ?

Une question décisive : **faut-il conserver plus d'une information sur cette chose, ou lui appliquer des règles ?**

| Concept | Un simple attribut quand... | Une classe à part entière quand... |
|---|---|---|
| Adresse | Un seul texte suffit : `"Herzl 10, Haifa"` | Il faut la ville, la rue et le code postal séparément, filtrer par ville, et valider le code postal |
| Prix | Un seul nombre | Il faut une devise, des conversions et un arrondi selon la devise |
| Note | Un nombre | Il faut aussi une date, le nom de l'examen, un coefficient et un recours |

**Règle empirique :** commencez **simple** (un attribut). Quand la valeur « grandit » et acquiert plus de champs et de règles, transformez-la en classe.

#### Outil 4 : à quelle classe appartient la méthode ?

**La règle d'or : la méthode va dans la classe qui détient les données dont elle a besoin.** (On l'appelle « Information Expert ».)

- **« Calculer le total de la commande »** → a besoin des articles de la commande → **`Order.total()`**.
- **« Le produit est-il en stock ? »** → a besoin de la quantité du produit → **`Product.is_in_stock()`**.
- **« Combien le client a-t-il dépensé cette année ? »** → a besoin des commandes du client → **`Customer`** (s'il détient ses propres commandes), ou une classe qui gère toutes les commandes.

**Un signe d'alerte :** une méthode de la classe A qui est surtout `other.x`, `other.y`, `other.z` → elle appartient probablement à la classe de `other`.

### Syntaxe

Il n'y a pas de nouvelle syntaxe. Le produit de cette étape est **une conception**, que l'on peut écrire sous forme de « squelette » de code, avec `pass` et des docstrings :

```python
class Order:
    """A customer's order: items, status and total."""

    def __init__(self, customer):
        self.customer = customer      # has-a: the order holds its customer
        self.items = []
        self.status = "new"

    def add_item(self, product, quantity):
        pass

    def total(self):
        pass
```

Un squelette comme celui-ci permet de réfléchir à **la structure** avant d'écrire la logique.

### Premier exemple de code

**L'exigence :** « Un système pour un café. Des **clients** commandent des **boissons** dans le **menu**. Chaque boisson a un **nom**, une **taille** (petite/grande) et un **prix**. Une grande boisson coûte 3 NIS de plus. Une **commande** peut contenir plusieurs boissons, et elle a un **total**. Un client peut **annuler** une commande, tant qu'elle n'a pas été **préparée**. »

**Étape A — le marquage :**

- **Noms :** client, boisson, menu, commande, nom, taille, prix, total.
- **Verbes :** commander, contenir, annuler, préparer.
- **Règles :** grande = +3. Annulation seulement avant la préparation.

**Étape B — les quatre questions :**

| Candidat | Identité/données ? | Comportement ? | Plusieurs instances ? | Décision |
|---|---|---|---|---|
| Boisson | ✓ nom, taille, prix | ✓ calcul du prix selon la taille | ✓ | **Classe `Drink`** |
| Commande | ✓ articles, statut | ✓ total, annulation, préparation | ✓ | **Classe `Order`** |
| Client | ✓ nom | Un peu | ✓ | Ici : un **attribut** (le nom) de Order. L'exigence ne lui donne aucun comportement. |
| Menu | Une liste de boissons | Peut-être une recherche | **Un seul** | Ici : un simple **dict**. Si des règles s'ajoutent, il deviendra une classe. |
| Taille | Une valeur (« small »/« large ») | — | — | Un **attribut** de Drink |
| Prix | Un nombre | — | — | Un **attribut** + une méthode calculée |
| Total | Dérivé | — | — | Une **méthode** `Order.total()` |

**Étape C — les méthodes (« qui détient les données ? ») :**

- `price()` → Drink, car Drink connaît le prix de base et la taille.
- `total()` → Order, car Order détient les boissons.
- `cancel()`, `mark_prepared()` → Order, car Order détient le statut.

**Étape D — le code.** `week-3/coffee_shop.py` :

```python
LARGE_EXTRA = 3

class Drink:
    def __init__(self, name, base_price, size="small"):
        if size not in ("small", "large"):
            raise ValueError("size must be small or large, got " + size)
        self.name = name
        self.base_price = base_price
        self.size = size

    def price(self):
        if self.size == "large":
            return self.base_price + LARGE_EXTRA
        return self.base_price

class Order:
    def __init__(self, customer_name):
        self.customer_name = customer_name
        self.drinks = []
        self.status = "open"

    def add(self, drink):
        if self.status != "open":
            raise ValueError("cannot add drinks to a " + self.status + " order")
        self.drinks.append(drink)

    def total(self):
        return sum(drink.price() for drink in self.drinks)

    def mark_prepared(self):
        self.status = "prepared"

    def cancel(self):
        if self.status == "prepared":
            raise ValueError("cannot cancel - the order was already prepared")
        self.status = "cancelled"

MENU = {"espresso": 9, "latte": 14, "tea": 8}

order = Order("Dana")
order.add(Drink("latte", MENU["latte"], "large"))
order.add(Drink("espresso", MENU["espresso"]))
print(order.customer_name, "pays", order.total())

order.mark_prepared()
try:
    order.cancel()
except ValueError as error:
    print("Rejected:", error)
```

**Sortie :**

```text
Dana pays 26
Rejected: cannot cancel - the order was already prepared
```

### Analyse du code ligne par ligne

| Décision de conception | À quoi elle ressemble dans le code |
|---|---|
| Boisson = une classe | `class Drink` avec des données (nom, prix, taille) et un comportement (`price`). |
| Taille = un attribut avec une règle | `self.size`, avec une validation dans `__init__`. |
| « Grande = +3 » appartient à la boisson | `Drink.price()`, car la boisson connaît sa propre taille. |
| Commande = une classe | `class Order` avec des articles et un statut. |
| Client = un attribut (pour l'instant) | `self.customer_name`, une chaîne. |
| « Annulation seulement avant la préparation » | Une validation dans `Order.cancel()`. |
| Menu = un dict | `MENU`. Il n'a pas de comportement, il n'y a donc pas besoin d'une classe. |
| Total = une méthode | `total()` calcule à partir de `drinks`, et n'est pas stocké comme une donnée qui risque de devenir obsolète. |

### Un autre exemple

**Quand l'exigence change — le client devient une classe.** Une exigence a été ajoutée : « Les clients ont une **carte de fidélité** : chaque achat rapporte un point par tranche de 10 NIS, et à 50 points on obtient une boisson gratuite. »

Maintenant, le client a des **données** (des points), un **comportement** (gagner, échanger) et des **règles** (50 points). Selon les quatre questions, c'est déjà une **classe** :

```python
POINTS_PER_SHEKEL = 0.1
FREE_DRINK_POINTS = 50

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

    def earn_points(self, amount_paid):
        self.points = self.points + int(amount_paid * POINTS_PER_SHEKEL)

    def has_free_drink(self):
        return self.points >= FREE_DRINK_POINTS

    def redeem_free_drink(self):
        if not self.has_free_drink():
            raise ValueError(self.name + " has only " + str(self.points) + " points")
        self.points = self.points - FREE_DRINK_POINTS

dana = Customer("Dana")
for payment in [120, 260, 140]:
    dana.earn_points(payment)
print(dana.name, "has", dana.points, "points. Free drink?", dana.has_free_drink())
dana.redeem_free_drink()
print("After redeeming:", dana.points)
```

**Sortie :**

```text
Dana has 52 points. Free drink? True
After redeeming: 2
```

Et dans la structure : `Order` contient maintenant `self.customer` (un objet Customer) au lieu de `customer_name`, et quand une commande est payée, elle appelle `self.customer.earn_points(self.total())`. **Le même concept, « client », a commencé comme un attribut et est devenu une classe quand les exigences ont grandi.** C'est naturel, et c'est le bon processus de travail.

### Résumé

- **Les noms** → candidats pour des classes/attributs. **Les verbes** → des méthodes. **Les règles** → la validation.
- Une **classe** quand il y a identité + données, **comportement ou règles**, et plusieurs instances. Une valeur unique sans comportement → un **attribut**.
- Le même concept peut être un attribut dans un système et une classe dans un autre. Commencez simple, et faites évoluer quand les exigences grandissent.
- **Une méthode appartient à la classe qui détient les données dont elle a besoin** (Information Expert).
- Parfois, une « action » (un prêt, une commande) est en réalité une **classe**, quand elle a ses propres données et sa propre logique.
- Pas une classe pour chaque nom, et pas une God Class qui fait tout.

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

### Documentation officielle

- [Random Remarks](https://docs.python.org/3/tutorial/classes.html#random-remarks)

<!-- docs:end -->

## Chapitre 7 : Retour à la banque — tout ensemble

### Qu'allons-nous apprendre ?

Nous revenons au système bancaire du début de la leçon, et nous le reconstruisons — cette fois avec tout ce que nous avons appris dans les chapitres précédents.

### Pourquoi en avons-nous besoin ?

Chaque chapitre a ajouté un outil et résolu un problème. Ici, vous voyez comment ils fonctionnent tous **ensemble** dans une seule classe — et c'est exactement ce que vous ferez seuls dans le mini-projet.

### La banque — avec tout ce que nous avons appris

```python
class BankAccount:
    accounts_opened = 0                  # shared by all accounts

    def __init__(self, owner, balance=0):
        if balance < 0:
            raise ValueError("balance cannot be negative")
        self.owner = owner               # per account
        self.balance = balance
        BankAccount.accounts_opened = BankAccount.accounts_opened + 1

    def deposit(self, amount):           # setter - changes the state
        if amount <= 0:
            raise ValueError("amount must be positive")
        self.balance = self.balance + amount

    def __str__(self):
        return self.owner + ": " + str(self.balance)

dana = BankAccount("Dana", 1000)
dana.deposit(200)
print(dana)                              # Dana: 1200
```

| Partie du code | De quel chapitre |
|---|---|
| `class BankAccount` — un seul modèle pour tous les comptes | Chapitre 2 |
| `__init__` avec une vérification — chaque compte naît complet et valide | Chapitre 3 |
| `deposit` — un Setter qui protège les règles | Chapitre 4 |
| `__str__` — à quoi ressemble le compte à l'affichage | Chapitre 4 |
| `accounts_opened` — un compteur partagé par tous les comptes | Chapitre 5 |

Les quatre problèmes du premier chapitre ont été résolus : on ne peut pas créer un compte avec un solde négatif, les données et les actions sont ensemble, la structure a une seule définition, et la classe elle-même impose les règles.

### Résumé

- Une **classe** est un modèle, et un **objet** est une instance construite à partir de lui — avec ses propres données.
- **`__init__`** s'exécute à la création et garantit que l'objet naît complet ; **`self`** est l'objet courant.
- **Les méthodes** sont les actions : un **Getter** renvoie une information, un **Setter** modifie l'état et vérifie les règles, et **`__str__`** décide de ce qu'affiche `print`.
- Un **Class Attribute** est une donnée unique partagée par tous les objets — comme un compteur.

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

### Documentation officielle

- [Python Tutorial — Classes](https://docs.python.org/3/tutorial/classes.html)

<!-- docs:end -->

<!-- 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 :** pourquoi la POO est nécessaire, créer une classe avec `__init__` et la vérification des valeurs, les méthodes (Getter et Setter) et `__str__`, les données partagées par toute la classe, et la conception — ce qui est une classe, un attribut et une méthode.

**À la fin de la page, vous saurez :**
- Reconnaître quand il vaut mieux réunir des données et des actions dans un seul objet
- Écrire une classe complète : `__init__`, des méthodes et `__str__`
- Utiliser un Class Attribute comme compteur partagé par tous les objets
- Transformer une description verbale en liste de classes, d'attributs et de méthodes

### Exercice 1: Repérer les problèmes

**Ce que vous faites :** lire un code existant et y trouver des problèmes — sans écrire de code.

**Le code :**

```text
student1 = {"name": "Dana", "grades": [90, 85]}
student2 = {"name": "Avi", "grades": [70]}
student3 = {"nmae": "Noa", "grades": []}

def average(student):
    return sum(student["grades"]) / len(student["grades"])
```

**La tâche :** trouvez trois problèmes qui viennent du fait que les données (les dictionnaires) et les actions (la fonction) sont séparées. Pour chaque problème, écrivez une phrase : quel est le problème, et à quelle ligne il apparaît.

**Ce qu'il faut rendre :** trois problèmes, une phrase pour chacun.

{{< solution >}}
1. **Une faute de frappe crée une nouvelle clé** — `student3` a `"nmae"` au lieu de `"name"`. Aucune structure définie ne le détecterait, et le problème ne sera découvert que lorsque quelqu'un essaiera `student3["name"]`.
2. **Il n'y a pas de règles** — n'importe quel code peut mettre `-50` ou `"abc"` dans `grades`, et personne ne vérifie.
3. **Les données et les actions sont dispersées** — `average` est une fonction séparée qui reçoit un dictionnaire, et ne sait même pas si c'est un étudiant. Et avec une liste vide (`student3`), elle échouera avec une `ZeroDivisionError`.

En POO, `Student` définit une fois pour toutes **quelles données** il possède et **quelles actions** il sait faire — tout ensemble.
{{< /solution >}}

### Exercice 2: Une vérification à la création

**Ce que vous construisez :** un produit dans une boutique — qu'on ne peut pas créer avec un prix négatif.

**Ce qu'il faut écrire :**
1. Une classe nommée `Product`.
2. Un `__init__` qui reçoit un nom, un prix et une quantité. Si la quantité n'a pas été passée — elle vaut 0.
3. Si le prix est négatif : lancez une `ValueError` avec le message qui apparaît dans la sortie, et n'enregistrez rien.
4. Sinon : enregistrez les trois données sur l'objet, avec `self`.

**Code de test :**

```python
pen = Product("Pen", 5)
print(pen.name, pen.price, pen.quantity)
try:
    Product("Bad", -10)
except ValueError as error:
    print("error:", error)
```

**La sortie souhaitée :**

```text
Pen 5 0
error: price cannot be negative
```

{{< solution >}}
```python
class Product:
    def __init__(self, name, price, quantity=0):
        if price < 0:
            raise ValueError("price cannot be negative")
        self.name = name
        self.price = price
        self.quantity = quantity


pen = Product("Pen", 5)
print(pen.name, pen.price, pen.quantity)
try:
    Product("Bad", -10)
except ValueError as error:
    print("error:", error)
```

```text
Pen 5 0
error: price cannot be negative
```

La vérification dans `__init__` garantit qu'il n'existe jamais d'objet `Product` avec un prix invalide.
{{< /solution >}}

### Exercice 3: Student avec des méthodes et __str__

**Ce que vous construisez :** un étudiant qui gère ses propres notes.

**Ce qu'il faut écrire :**
1. Une classe nommée `Student`. Le `__init__` reçoit un nom et crée une liste de notes vide.
2. `add_grade` — reçoit une note et l'ajoute à la liste. C'est un Setter : il modifie l'état.
3. `average` — renvoie la moyenne, ou 0 s'il n'y a pas encore de notes. C'est un Getter : il ne fait que renvoyer.
4. `__str__` — renvoie le nom et la moyenne, exactement comme dans la dernière ligne de la sortie.

**Code de test :**

```python
dana = Student("Dana")
print(dana.average())
dana.add_grade(90)
dana.add_grade(75)
print(dana.average())
print(dana)
```

**La sortie souhaitée :**

```text
0
82.5
Dana (average 82.5)
```

{{< solution >}}
```python
class Student:
    def __init__(self, name):
        self.name = name
        self.grades = []

    def add_grade(self, grade):
        self.grades.append(grade)

    def average(self):
        if len(self.grades) == 0:
            return 0
        return sum(self.grades) / len(self.grades)

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


dana = Student("Dana")
print(dana.average())
dana.add_grade(90)
dana.add_grade(75)
print(dana.average())
print(dana)
```

```text
0
82.5
Dana (average 82.5)
```

`add_grade` est un **Setter** — il modifie l'état. `average` est un **Getter** — il renvoie une information sans rien modifier. `print` appelle `__str__` automatiquement.
{{< /solution >}}

### Exercice 4: Un compteur partagé

**Ce que vous construisez :** un produit qui connaît son prix TVA comprise — et une classe qui compte combien de produits ont été créés.

**Ce qu'il faut écrire :**
1. Une classe nommée `Product`, avec un `__init__` qui reçoit un nom et un prix.
2. Deux Class Attributes — des données qui appartiennent à toute la classe, pas à un seul objet : `VAT_RATE` avec la valeur 0.17, et `created_count` qui commence à 0.
3. Chaque fois qu'un produit est créé, augmentez `created_count` de 1.
4. `price_with_vat` — renvoie le prix TVA comprise.

**Code de test :**

```python
laptop = Product("Laptop", 1000)
mouse = Product("Mouse", 50)
print(Product.created_count)
print(laptop.price_with_vat())
```

**La sortie souhaitée :**

```text
2
1170.0
```

{{< solution >}}
```python
class Product:
    VAT_RATE = 0.17
    created_count = 0

    def __init__(self, name, price):
        self.name = name
        self.price = price
        Product.created_count = Product.created_count + 1

    def price_with_vat(self):
        return round(self.price * (1 + Product.VAT_RATE), 2)


laptop = Product("Laptop", 1000)
mouse = Product("Mouse", 50)
print(Product.created_count)
print(laptop.price_with_vat())
```

```text
2
1170.0
```

- `name` et `price` appartiennent à chaque produit séparément — des **Instance Attributes**.
- `VAT_RATE` et `created_count` sont partagés par tous les produits — des **Class Attributes**.
- Le compteur est mis à jour via `Product.created_count` — et non via `self`.
{{< /solution >}}

### Exercice 5: Noms et verbes

**Ce que vous faites :** de la conception uniquement, sans code — transformer une description verbale en liste de classes, d'attributs et de méthodes.

**L'exigence :**

> « Dans un café, des **clients** commandent des **boissons** de différentes tailles. Chaque boisson a un **prix de base**, et une grande boisson coûte 3 NIS de plus. Le client **gagne des points** à chaque commande, et peut les **échanger**. »

**La tâche :** construisez un tableau à trois colonnes : le candidat, ce que c'est (une classe, un attribut ou une méthode), et pourquoi.

**Indice :** les noms sont des candidats pour des classes ou des attributs. Les verbes sont des candidats pour des méthodes.

**Ce qu'il faut rendre :** un tableau avec tous les candidats de l'exigence.

{{< solution >}}
| Candidat | Ce que c'est | Pourquoi |
|---|---|---|
| Client (`Customer`) | **Classe** | Un nom avec des données (nom, points) et un comportement (gagne, échange) |
| Boisson (`Drink`) | **Classe** | Un nom avec des données (prix de base, taille) et une règle (la grande coûte plus cher) |
| Prix de base, taille | **Attributs** de `Drink` | Des valeurs simples qui décrivent une boisson |
| Points | **Attribut** de `Customer` | Un simple nombre |
| Gagne des points, échange | **Méthodes** de `Customer` | Des verbes — des actions sur les données du client |
| Prix final | **Méthode** de `Drink` | Calculé à partir du prix et de la taille |
{{< /solution >}}

## Mini-projet: Une petite bibliothèque

**Quel système vous construisez :** un petit système pour une bibliothèque. La bibliothèque a des livres, et chaque livre a plusieurs exemplaires. Les lecteurs empruntent des exemplaires et les rendent, et à chaque instant le système sait combien d'exemplaires de chaque livre sont libres — et combien de livres il y a au total dans la bibliothèque.

**Ce que vous apprendrez du projet :**
- Construire une classe complète à partir de zéro : `__init__`, des méthodes et `__str__`
- Vérifier les valeurs dès la création, et lancer une erreur quand elles sont invalides
- Distinguer un Setter qui modifie l'état d'un Getter qui ne fait que renvoyer une information
- Utiliser un Class Attribute comme compteur partagé par tous les objets

**Ce qu'il faut construire — étape par étape :**

**Étape 1 — la classe et le compteur partagé.** Écrivez une classe nommée `Book`, avec un Class Attribute nommé `total_books` qui commence à 0. Il comptera combien de livres ont été créés.

**Étape 2 — créer un livre.** Le `__init__` reçoit un titre, un auteur et un nombre d'exemplaires, et les enregistre. Au départ, tous les exemplaires sont libres — enregistrez-les aussi dans `available`. Chaque nouveau livre augmente `total_books` de 1. Si le nombre d'exemplaires est inférieur à 1 — lancez une `ValueError`, et ne créez pas de livre.

**Étape 3 — emprunter et rendre (Setters).**
- `borrow` — retire un exemplaire libre. S'il n'y a pas d'exemplaire libre : `ValueError`.
- `return_book` — rend un exemplaire. Si tous les exemplaires sont déjà à la bibliothèque : `ValueError`.

**Étape 4 — vérifier (Getter).** `is_available` renvoie `True` s'il y a au moins un exemplaire libre, sinon `False`.

**Étape 5 — l'affichage.** `__str__` renvoie le titre, l'auteur et combien d'exemplaires sont libres sur combien, comme dans la sortie.

**Les messages d'erreur — exactement ainsi :**

```text
copies must be at least 1
no copies available
all copies are already in
```

**Code de test :**

```python
books = [
    Book("Clean Code", "Martin", 3),
    Book("Python Basics", "Cohen", 1),
]

books[0].borrow()
books[1].borrow()

try:
    books[1].borrow()
except ValueError as error:
    print("Python Basics ->", error)

try:
    Book("Empty", "Nobody", 0)
except ValueError as error:
    print("Empty ->", error)

for book in books:
    print(book, "| available:", book.is_available())

print("Books in the library:", Book.total_books)
```

**La sortie souhaitée :**

```text
Python Basics -> no copies available
Empty -> copies must be at least 1
Clean Code by Martin (2/3 available) | available: True
Python Basics by Cohen (0/1 available) | available: False
Books in the library: 2
```

**À la fin du projet, vous saurez :**
- Traduire la description d'une « chose » du monde — un livre — en une classe avec des données et des actions
- Garder les règles à l'intérieur de l'objet, pour qu'il ne puisse pas atteindre un état invalide
- Comprendre pourquoi le livre Empty n'est pas compté par le compteur : l'erreur a été lancée avant l'incrémentation

**Bonus :** ajoutez une classe nommée `Library` qui contient une liste de livres, avec `add_book` qui ajoute un livre, et `available_books` qui ne renvoie que les livres qu'on peut emprunter en ce moment.

{{< solution >}}
```python
class Book:
    total_books = 0

    def __init__(self, title, author, copies):
        if copies < 1:
            raise ValueError("copies must be at least 1")
        self.title = title
        self.author = author
        self.copies = copies
        self.available = copies
        Book.total_books = Book.total_books + 1

    def borrow(self):
        if self.available == 0:
            raise ValueError("no copies available")
        self.available = self.available - 1

    def return_book(self):
        if self.available == self.copies:
            raise ValueError("all copies are already in")
        self.available = self.available + 1

    def is_available(self):
        return self.available > 0

    def __str__(self):
        return (self.title + " by " + self.author + " ("
                + str(self.available) + "/" + str(self.copies) + " available)")


books = [
    Book("Clean Code", "Martin", 3),
    Book("Python Basics", "Cohen", 1),
]

books[0].borrow()
books[1].borrow()

try:
    books[1].borrow()
except ValueError as error:
    print("Python Basics ->", error)

try:
    Book("Empty", "Nobody", 0)
except ValueError as error:
    print("Empty ->", error)

for book in books:
    print(book, "| available:", book.is_available())

print("Books in the library:", Book.total_books)
```

```text
Python Basics -> no copies available
Empty -> copies must be at least 1
Clean Code by Martin (2/3 available) | available: True
Python Basics by Cohen (0/1 available) | available: False
Books in the library: 2
```

- `total_books` est un **Class Attribute** — un seul compteur pour tous les livres. Le livre `Empty` n'a pas été créé (l'erreur a été lancée avant l'incrémentation), le compteur vaut donc 2.
- `borrow` et `return_book` sont des **Setters** — ils modifient l'état, et vérifient les règles **avant** la modification.
- `is_available` est un **Getter** — il renvoie une information sans rien modifier.

**Bonus :**

```python
class Library:
    def __init__(self):
        self.books = []

    def add_book(self, book):
        self.books.append(book)

    def available_books(self):
        result = []
        for book in self.books:
            if book.is_available():
                result.append(book)
        return result


library = Library()
for book in books:
    library.add_book(book)
for book in library.available_books():
    print(book)
```

```text
Clean Code by Martin (2/3 available)
```
{{< /solution >}}

<!-- exercises:end -->
