alpinux-wiki/docs/presentations/Python/10-poo-classes-objets.md
2026-10-15 00:28:56 +02:00

1807 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Chapitre 10 — Programmation orientée objet : classes et objets
> **Durée estimée** : 5 à 6 heures (cours + exercices + mini-projet)
>
> **Prérequis** : [Chapitre 09 — Modules et bibliothèque standard](09-modules-bibliotheque-standard.md) (et surtout les fonctions du [chapitre 05](05-fonctions.md) et les dictionnaires du [chapitre 04](04-structures-de-donnees.md)).
>
> **Objectifs** — À la fin de ce chapitre, tu sauras :
> - expliquer ce qu'est une classe, un objet (une instance), un attribut et une méthode ;
> - écrire une classe avec un constructeur `__init__` et comprendre enfin ce qu'est `self` ;
> - ajouter des méthodes qui lisent ou modifient l'état d'un objet ;
> - rendre tes objets lisibles avec `__str__` et `__repr__` ;
> - distinguer attributs d'instance et attributs de classe, et éviter le piège de l'attribut mutable partagé ;
> - concevoir une petite classe propre : validation des données, exceptions, conventions de nommage.
---
## Cours
### 1. Pourquoi des objets ?
Imagine que tu programmes un jeu avec des personnages. Avec ce que tu sais déjà, tu utiliserais sans doute des **dictionnaires** et des **fonctions** :
```python
def creer_personnage(nom, points_de_vie):
return {"nom": nom, "pv": points_de_vie}
def subir_degats(personnage, degats):
personnage["pv"] = max(0, personnage["pv"] - degats)
heros = creer_personnage("Aria", 30)
subir_degats(heros, 12)
print(heros)
```
```
{'nom': 'Aria', 'pv': 18}
```
Ça marche. Mais quand le programme grandit, des problèmes apparaissent :
- les **données** (le dictionnaire) et les **actions** (les fonctions) sont séparées : rien n'indique que `subir_degats` est faite pour ces dictionnaires-là ;
- une faute de frappe (`heros["vp"]`) crée silencieusement une nouvelle clé ;
- rien n'empêche de passer un dictionnaire de *voiture* à `subir_degats`.
La **programmation orientée objet** (POO) propose de **regrouper les données et les actions** qui vont ensemble dans une même entité : un **objet**.
```python
class Personnage:
def __init__(self, nom, points_de_vie):
self.nom = nom
self.pv = points_de_vie
def subir_degats(self, degats):
self.pv = max(0, self.pv - degats)
heros = Personnage("Aria", 30)
heros.subir_degats(12)
print(heros.nom, heros.pv)
```
```
Aria 18
```
Ne t'inquiète pas si la syntaxe te paraît étrange : tout ce chapitre sert à l'expliquer, ligne par ligne.
> 💡 Bonne nouvelle : tu utilises des objets depuis le début ! Une chaîne est un objet de la classe `str`, avec ses méthodes `.upper()`, `.split()`… Une liste est un objet de la classe `list`, avec `.append()`, `.sort()`… Dans ce chapitre, tu apprends à **créer tes propres types**.
```python
print(type("bonjour"))
print(type([1, 2, 3]))
print("bonjour".upper())
```
```
<class 'str'>
<class 'list'>
BONJOUR
```
### 2. Classe et objet : le moule et les gâteaux
Deux mots à bien distinguer :
- Une **classe** est un **plan de fabrication**, un modèle. Elle décrit ce qu'auront tous les objets de ce type.
- Un **objet** (on dit aussi une **instance**) est un exemplaire **concret**, fabriqué à partir de ce plan.
> 💡 **Analogie** : la classe, c'est le **moule à gâteaux** ; les objets, ce sont les **gâteaux**. Un seul moule permet de fabriquer autant de gâteaux que tu veux. Ils ont tous la même forme, mais chacun peut avoir son propre parfum (ses propres valeurs). Manger un gâteau ne fait pas disparaître les autres.
Un objet possède :
- des **attributs** : ses données, ses caractéristiques (le nom, les points de vie…). Ce sont des variables attachées à l'objet.
- des **méthodes** : ses actions, ses comportements (subir des dégâts, se soigner…). Ce sont des fonctions attachées à l'objet.
Une astuce de conception : dans la description d'un problème, les **noms communs** deviennent souvent des classes ou des attributs, et les **verbes** des méthodes. « Un *compte bancaire* a un *titulaire* et un *solde* ; on peut *déposer* et *retirer* de l'argent. »
### 3. Ta première classe
La classe la plus simple possible :
```python
class Chien:
pass
rex = Chien() # on crée un objet en « appelant » la classe
medor = Chien()
print(rex)
print(type(rex))
print(rex is medor)
```
```
<__main__.Chien object at 0x7f3a2c1d5e50>
<class '__main__.Chien'>
False
```
(L'adresse `0x7f...` change à chaque exécution : c'est l'emplacement de l'objet dans la mémoire.)
- `class Chien:` définit la classe. **Convention** : un nom de classe s'écrit en **CamelCase** (`Chien`, `CompteBancaire`, `PersonnageJoueur`), alors que les variables et fonctions sont en `snake_case`.
- `Chien()` **crée** un nouvel objet. On dit qu'on **instancie** la classe.
- `rex` et `medor` sont **deux objets distincts** (`is` renvoie `False`).
On peut techniquement ajouter des attributs à la main (`rex.nom = "Rex"`), mais ce n'est pas une bonne pratique : rien ne garantit que tous les chiens auront un nom. C'est le rôle du **constructeur**.
### 4. Le constructeur `__init__` et les attributs
La méthode spéciale `__init__` (deux tirets bas de chaque côté, on dit « dunder init », pour *double underscore*) est appelée **automatiquement** à la création de chaque objet. Elle sert à lui donner ses attributs de départ.
```python
class Chien:
def __init__(self, nom, race, age):
self.nom = nom
self.race = race
self.age = age
rex = Chien("Rex", "berger allemand", 4)
medor = Chien("Médor", "labrador", 2)
print(rex.nom, rex.age)
print(medor.nom, medor.race)
```
```
Rex 4
Médor labrador
```
Que se passe-t-il quand on écrit `Chien("Rex", "berger allemand", 4)` ?
1. Python crée un objet `Chien` **vide**.
2. Il appelle `__init__` en lui passant **cet objet tout neuf** comme premier argument (`self`), suivi de tes arguments : `nom="Rex"`, `race="berger allemand"`, `age=4`.
3. `self.nom = nom` crée un attribut `nom` **sur l'objet**, et y range la valeur du paramètre `nom`.
4. L'objet, maintenant rempli, est renvoyé et rangé dans `rex`.
> ⚠️ `self.nom` et `nom` sont **deux choses différentes** : `nom` est le paramètre (une variable locale qui disparaît à la fin de `__init__`), `self.nom` est l'attribut **stocké dans l'objet** (qui dure aussi longtemps que l'objet). On leur donne souvent le même nom par habitude, mais ce n'est pas obligatoire.
On accède aux attributs avec la **notation pointée** `objet.attribut`, et on peut les modifier :
```python
class Chien:
def __init__(self, nom, race, age):
self.nom = nom
self.race = race
self.age = age
rex = Chien("Rex", "berger allemand", 4)
rex.age = 5 # modification
rex.age += 1 # encore une
print(rex.age)
print(vars(rex)) # tous les attributs, sous forme de dictionnaire
```
```
6
{'nom': 'Rex', 'race': 'berger allemand', 'age': 6}
```
`vars(objet)` est très utile pour déboguer : il montre le contenu complet d'un objet.
#### Valeurs par défaut et attributs calculés
`__init__` est une fonction comme les autres : ses paramètres peuvent avoir des valeurs par défaut ([chapitre 05](05-fonctions.md)). Et tous les attributs ne viennent pas forcément d'un paramètre :
```python
class Personnage:
def __init__(self, nom, classe="guerrier", niveau=1):
self.nom = nom
self.classe = classe
self.niveau = niveau
self.pv_max = 20 + niveau * 10 # calculé
self.pv = self.pv_max # au départ, en pleine forme
self.inventaire = [] # une liste vide, propre à CHAQUE personnage
aria = Personnage("Aria", "mage", niveau=3)
brutus = Personnage("Brutus")
print(aria.classe, aria.pv)
print(brutus.classe, brutus.pv)
aria.inventaire.append("bâton")
print(aria.inventaire, brutus.inventaire)
```
```
mage 50
guerrier 30
['bâton'] []
```
Chaque objet a **sa propre liste** `inventaire`, parce qu'elle est créée **à l'intérieur** de `__init__`, donc à nouveau pour chaque personnage. Retiens ce point : il est au cœur d'un piège classique (voir la section 8).
### 5. `self`, enfin expliqué
`self` est le mot qui intrigue le plus les débutants. C'est pourtant simple : **`self`, c'est l'objet sur lequel on est en train de travailler**.
Une classe est écrite **une seule fois**, mais sert pour **des milliers d'objets**. Quand le code de la classe dit « mon nom », il faut bien savoir *de quel objet* on parle. `self` est ce « moi ».
> 💡 **Analogie** : imagine une fiche de consignes distribuée à tous les élèves d'une classe : « Écris **ton** prénom en haut de **ta** feuille ». La fiche est la même pour tous, mais chaque élève l'applique à **lui-même**. `self`, c'est le « ton / ta » de la fiche.
Voici le point clé. Quand tu écris :
```python
rex.aboyer()
```
Python le traduit **automatiquement** en :
```python
Chien.aboyer(rex)
```
L'objet placé **avant le point** devient le **premier argument** de la méthode. C'est pour ça que toute méthode doit déclarer `self` comme premier paramètre, même si on ne le passe jamais soi-même entre les parenthèses.
```python
class Chien:
def __init__(self, nom):
self.nom = nom
def aboyer(self):
print(f"{self.nom} fait : Wouf !")
rex = Chien("Rex")
medor = Chien("Médor")
rex.aboyer() # self = rex
medor.aboyer() # self = medor
Chien.aboyer(rex) # exactement équivalent à rex.aboyer() (mais on ne l'écrit jamais ainsi)
```
```
Rex fait : Wouf !
Médor fait : Wouf !
Rex fait : Wouf !
```
`self` n'est pas un mot réservé : c'est une **convention** universelle. Techniquement, tu pourrais l'appeler `moi`, mais ne le fais pas, tout le monde s'attend à lire `self`.
### 6. Les méthodes
Une **méthode** est une fonction définie **dans** une classe. Elle a accès à l'objet via `self`, donc à tous ses attributs et à toutes ses autres méthodes.
On distingue en gros deux sortes de méthodes :
- celles qui **modifient l'état** de l'objet (ses attributs) ;
- celles qui **calculent et renvoient** une information, sans rien modifier.
```python
class Personnage:
def __init__(self, nom, pv_max):
self.nom = nom
self.pv_max = pv_max
self.pv = pv_max
# --- méthodes qui modifient l'état ---
def subir_degats(self, degats):
self.pv = max(0, self.pv - degats)
def se_soigner(self, soin):
self.pv = min(self.pv_max, self.pv + soin)
# --- méthodes qui renvoient une information ---
def est_vivant(self):
return self.pv > 0
def barre_de_vie(self, largeur=10):
pleins = round(self.pv / self.pv_max * largeur)
return "[" + "█" * pleins + "·" * (largeur - pleins) + "]"
# --- une méthode qui en utilise d'autres ---
def afficher(self):
etat = "debout" if self.est_vivant() else "K.O."
print(f"{self.nom:<8} {self.barre_de_vie()} {self.pv:>3}/{self.pv_max} ({etat})")
aria = Personnage("Aria", 40)
aria.afficher()
aria.subir_degats(25)
aria.afficher()
aria.se_soigner(100)
aria.afficher()
aria.subir_degats(999)
aria.afficher()
```
```
Aria [██████████] 40/40 (debout)
Aria [████······] 15/40 (debout)
Aria [██████████] 40/40 (debout)
Aria [··········] 0/40 (K.O.)
```
Observe :
- `se_soigner` ne peut pas dépasser `pv_max` grâce à `min`, `subir_degats` ne descend pas sous 0 grâce à `max`. **Les règles du jeu sont dans la classe**, à un seul endroit : impossible de les oublier ailleurs dans le programme.
- `afficher` appelle d'autres méthodes **du même objet** avec `self.est_vivant()` et `self.barre_de_vie()`. Sans `self.`, Python chercherait une fonction globale `est_vivant` et provoquerait une `NameError`.
#### Des objets qui interagissent
Une méthode peut recevoir **un autre objet** en paramètre. C'est là que la POO devient vraiment expressive :
```python
class Personnage:
def __init__(self, nom, pv, force):
self.nom = nom
self.pv = pv
self.force = force
def est_vivant(self):
return self.pv > 0
def attaquer(self, cible):
cible.pv = max(0, cible.pv - self.force)
print(f"{self.nom} frappe {cible.nom} : -{self.force} PV (reste {cible.pv})")
heros = Personnage("Aria", 30, 7)
gobelin = Personnage("Gobelin", 18, 4)
while heros.est_vivant() and gobelin.est_vivant():
heros.attaquer(gobelin)
if gobelin.est_vivant():
gobelin.attaquer(heros)
gagnant = heros if heros.est_vivant() else gobelin
print(f"Vainqueur : {gagnant.nom}")
```
```
Aria frappe Gobelin : -7 PV (reste 11)
Gobelin frappe Aria : -4 PV (reste 26)
Aria frappe Gobelin : -7 PV (reste 4)
Gobelin frappe Aria : -4 PV (reste 22)
Aria frappe Gobelin : -7 PV (reste 0)
Vainqueur : Aria
```
Dans `attaquer`, `self` est l'attaquant et `cible` la victime : deux objets de la même classe, chacun avec ses propres attributs. Le code se lit presque comme une phrase : `heros.attaquer(gobelin)`.
### 7. Rendre un objet lisible : `__str__` et `__repr__`
Par défaut, afficher un objet donne un résultat peu parlant :
```python
class Livre:
def __init__(self, titre, auteur, pages):
self.titre = titre
self.auteur = auteur
self.pages = pages
livre = Livre("Le Petit Prince", "Saint-Exupéry", 96)
print(livre)
```
```
<__main__.Livre object at 0x7f8e0ed74cd0>
```
On peut définir deux méthodes spéciales pour y remédier :
| Méthode | Appelée par | Destinée à | Style |
|---|---|---|---|
| `__str__` | `print(objet)`, `str(objet)`, f-strings | l'**utilisateur** | joli, lisible |
| `__repr__` | `repr(objet)`, la console interactive, l'affichage dans une liste | le **développeur** | précis, sans ambiguïté, idéalement du code Python valide |
```python
class Livre:
def __init__(self, titre, auteur, pages):
self.titre = titre
self.auteur = auteur
self.pages = pages
def __str__(self):
return f"« {self.titre} » de {self.auteur} ({self.pages} pages)"
def __repr__(self):
return f"Livre({self.titre!r}, {self.auteur!r}, {self.pages!r})"
livre = Livre("Le Petit Prince", "Saint-Exupéry", 96)
print(livre) # utilise __str__
print(f"Je lis {livre}") # __str__ aussi
print(repr(livre)) # __repr__
bibliotheque = [livre, Livre("Dune", "Herbert", 896)]
print(bibliotheque) # une liste affiche ses éléments avec __repr__ !
```
```
« Le Petit Prince » de Saint-Exupéry (96 pages)
Je lis « Le Petit Prince » de Saint-Exupéry (96 pages)
Livre('Le Petit Prince', 'Saint-Exupéry', 96)
[Livre('Le Petit Prince', 'Saint-Exupéry', 96), Livre('Dune', 'Herbert', 896)]
```
Règles pratiques :
- `__str__` et `__repr__` doivent **renvoyer** une chaîne (`return`), pas l'afficher (`print`).
- Si tu ne dois en écrire qu'une, écris `__repr__` : quand `__str__` n'existe pas, Python utilise `__repr__` à sa place.
- Le `!r` dans la f-string applique `repr()` à la valeur : il ajoute les guillemets autour des chaînes, ce qui permet de recopier la sortie comme du code Python.
Ces méthodes à double tiret bas (`__init__`, `__str__`, `__repr__`…) s'appellent des **méthodes spéciales** (ou « méthodes magiques ») : on ne les appelle presque jamais directement, c'est Python qui les appelle pour nous au bon moment. Il en existe beaucoup d'autres, par exemple `__eq__` pour `==` ou `__len__` pour `len()`, que tu découvriras au fil du temps.
```python
class Playlist:
def __init__(self, nom):
self.nom = nom
self.titres = []
def ajouter(self, titre):
self.titres.append(titre)
def __len__(self):
return len(self.titres)
ma_liste = Playlist("Running")
ma_liste.ajouter("Titre A")
ma_liste.ajouter("Titre B")
print(len(ma_liste))
```
```
2
```
### 8. Attributs de classe et attributs d'instance
Jusqu'ici, tous nos attributs étaient des **attributs d'instance** : créés avec `self.xxx = ...`, propres à chaque objet.
Un **attribut de classe** est défini directement dans le corps de la classe, en dehors de toute méthode. Il est **partagé** par tous les objets :
```python
class Voiture:
nombre_de_roues = 4 # attribut de classe : commun à toutes les voitures
voitures_fabriquees = 0 # un compteur partagé
def __init__(self, marque, couleur):
self.marque = marque # attributs d'instance : propres à chaque voiture
self.couleur = couleur
Voiture.voitures_fabriquees += 1
v1 = Voiture("Renault", "rouge")
v2 = Voiture("Peugeot", "bleue")
print(v1.nombre_de_roues, v2.nombre_de_roues, Voiture.nombre_de_roues)
print(Voiture.voitures_fabriquees)
print(v1.couleur, v2.couleur)
```
```
4 4 4
2
rouge bleue
```
Quand tu écris `v1.nombre_de_roues`, Python cherche d'abord un attribut d'instance `nombre_de_roues` sur `v1` ; il n'en trouve pas, alors il regarde **dans la classe**. C'est pour ça que l'attribut de classe est visible depuis chaque objet.
> ⚠️ Pour **modifier** un attribut de classe, passe par le nom de la classe (`Voiture.voitures_fabriquees += 1`). Si tu écris `self.voitures_fabriquees += 1`, Python crée un **nouvel attribut d'instance** sur cet objet, qui **masque** celui de la classe, et le compteur partagé ne bouge pas.
**Quand utiliser un attribut de classe ?** Pour des **constantes** communes à tous les objets (`TAUX_TVA = 0.2`, `PV_MAX = 100`) ou des compteurs globaux. Pour tout le reste, utilise des attributs d'instance créés dans `__init__`.
#### Le piège de l'attribut de classe mutable
Ce qui suit est **l'un des bugs les plus fréquents** en POO Python :
```python
class Panier:
articles = [] # ❌ une SEULE liste, partagée par tous les paniers !
def __init__(self, client):
self.client = client
def ajouter(self, article):
self.articles.append(article)
panier_alice = Panier("Alice")
panier_bob = Panier("Bob")
panier_alice.ajouter("pain")
print(panier_bob.articles) # Bob n'a rien acheté…
```
```
['pain']
```
Le pain d'Alice apparaît dans le panier de Bob ! `self.articles.append(...)` ne **crée** pas d'attribut d'instance, il **modifie** la liste trouvée dans la classe, qui est commune à tous. ✅ Correction : crée la liste **dans `__init__`**, pour que chaque panier ait la sienne :
```python
class Panier:
def __init__(self, client):
self.client = client
self.articles = [] # ✅ une nouvelle liste pour chaque panier
def ajouter(self, article):
self.articles.append(article)
panier_alice = Panier("Alice")
panier_bob = Panier("Bob")
panier_alice.ajouter("pain")
print(panier_alice.articles, panier_bob.articles)
```
```
['pain'] []
```
### 9. Les objets sont des références
Comme les listes ([chapitre 04](04-structures-de-donnees.md)), les objets sont manipulés par **référence** : une variable ne *contient* pas l'objet, elle *pointe* vers lui.
```python
class Compteur:
def __init__(self):
self.valeur = 0
def incrementer(self):
self.valeur += 1
a = Compteur()
b = a # b pointe vers LE MÊME objet que a (pas une copie)
b.incrementer()
print(a.valeur)
print(a is b)
c = Compteur() # un nouvel objet
print(a is c)
```
```
1
True
False
```
> 💡 **Analogie** : une variable, c'est une **étiquette** collée sur un objet. `b = a` colle une deuxième étiquette sur le même objet ; il n'y a toujours qu'un seul compteur.
Conséquence importante : quand tu passes un objet à une fonction, la fonction peut le **modifier** (c'est d'ailleurs ce que fait `attaquer` à la section 6 avec `cible`).
Et pour comparer deux objets ? Par défaut, `==` se comporte comme `is` : deux objets distincts ne sont jamais égaux, même avec les mêmes attributs. Tu peux changer cela en définissant `__eq__` :
```python
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, autre):
return self.x == autre.x and self.y == autre.y
p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1 == p2, p1 is p2)
```
```
True False
```
(Les *dataclasses* du [chapitre 11](11-poo-avancee-heritage-typage.md) écriront `__init__`, `__repr__` et `__eq__` automatiquement à ta place.)
### 10. Protéger ses données : validation et encapsulation
Une classe est responsable de la **cohérence** de ses objets. Un compte bancaire ne devrait jamais avoir un dépôt négatif ; un personnage ne devrait jamais avoir plus de PV que son maximum. Le bon réflexe : **valider dans les méthodes** et **lever une exception** ([chapitre 08](08-erreurs-et-debogage.md)) si la demande est invalide.
```python
class Thermostat:
TEMPERATURE_MIN = 5
TEMPERATURE_MAX = 30
def __init__(self, temperature=19):
self._temperature = 0
self.regler(temperature) # on réutilise la validation dès la création
def regler(self, temperature):
if not self.TEMPERATURE_MIN <= temperature <= self.TEMPERATURE_MAX:
raise ValueError(
f"température hors limites : {temperature} "
f"(entre {self.TEMPERATURE_MIN} et {self.TEMPERATURE_MAX})"
)
self._temperature = temperature
def temperature(self):
return self._temperature
salon = Thermostat()
salon.regler(21)
print(salon.temperature())
try:
salon.regler(45)
except ValueError as erreur:
print("Refusé :", erreur)
print(salon.temperature())
```
```
21
Refusé : température hors limites : 45 (entre 5 et 30)
21
```
#### La convention du tiret bas
Remarque le nom `_temperature`, avec un **tiret bas** au début. En Python, rien n'est vraiment « privé » : n'importe qui peut écrire `salon._temperature = 99`. Mais le tiret bas est une **convention** forte qui signifie :
> « Attribut **interne** : ne le modifie pas directement depuis l'extérieur, passe par les méthodes prévues. »
C'est ce qu'on appelle l'**encapsulation** : l'objet expose une interface (ses méthodes publiques, comme `regler` et `temperature`) et cache ses détails internes. On en reparle au [chapitre 11](11-poo-avancee-heritage-typage.md), où `@property` permettra d'écrire `salon.temperature` sans parenthèses tout en gardant la validation.
> 💡 Les exceptions personnalisées que tu as écrites au [chapitre 08](08-erreurs-et-debogage.md), `class SoldeInsuffisantError(Exception): pass`, sont… des classes ! Le `(Exception)` entre parenthèses signifie que ta classe **hérite** de `Exception` : c'est le sujet du prochain chapitre.
### 11. Des objets qui contiennent des objets
Un attribut peut contenir n'importe quelle valeur : un nombre, une liste… ou d'autres objets. On construit ainsi des programmes entiers en assemblant des classes simples :
```python
class Article:
def __init__(self, nom, prix, quantite=1):
self.nom = nom
self.prix = prix
self.quantite = quantite
def sous_total(self):
return self.prix * self.quantite
def __str__(self):
return f"{self.quantite} x {self.nom:<12} {self.sous_total():>7.2f} €"
class Commande:
def __init__(self, client):
self.client = client
self.articles = [] # une liste d'objets Article
def ajouter(self, article):
self.articles.append(article)
def total(self):
return sum(article.sous_total() for article in self.articles)
def afficher(self):
print(f"Commande de {self.client}")
for article in self.articles:
print(" ", article)
print(f" {'TOTAL':<16} {self.total():>7.2f} €")
commande = Commande("Léa")
commande.ajouter(Article("Cahier", 2.50, 3))
commande.ajouter(Article("Stylo", 1.20, 5))
commande.ajouter(Article("Cartable", 34.90))
commande.afficher()
```
```
Commande de Léa
3 x Cahier 7.50 €
5 x Stylo 6.00 €
1 x Cartable 34.90 €
TOTAL 48.40 €
```
Chaque classe a **une responsabilité claire** : `Article` sait calculer son sous-total et s'afficher ; `Commande` sait gérer une liste d'articles et calculer le total. Cette façon d'assembler des objets s'appelle la **composition**, approfondie au [chapitre 11](11-poo-avancee-heritage-typage.md).
### 12. Ranger ses classes dans des modules
Une classe s'importe comme une fonction ([chapitre 09](09-modules-bibliotheque-standard.md)). Une organisation courante : un module par classe importante, ou par groupe de classes liées.
```
boutique/
├── commande.py ← contient les classes Article et Commande
└── main.py
```
```python
# main.py
from commande import Article, Commande
panier = Commande("Hugo")
panier.ajouter(Article("Gomme", 0.80, 2))
panier.afficher()
```
### 13. Récapitulatif du vocabulaire
| Terme | Signification | Exemple |
|---|---|---|
| Classe | Le modèle, le plan | `class Chien:` |
| Objet / instance | Un exemplaire concret | `rex = Chien("Rex")` |
| Instancier | Créer un objet à partir d'une classe | `Chien("Rex")` |
| Attribut | Une donnée attachée à l'objet | `rex.nom` |
| Méthode | Une fonction attachée à l'objet | `rex.aboyer()` |
| Constructeur | La méthode qui initialise l'objet | `__init__` |
| `self` | L'objet courant, à l'intérieur de la classe | `self.nom = nom` |
| Méthode spéciale | Méthode appelée par Python lui-même | `__str__`, `__repr__`, `__len__` |
| Attribut de classe | Donnée partagée par toutes les instances | `Voiture.nombre_de_roues` |
---
## Pièges fréquents
### Piège 1 — Oublier `self` dans la définition d'une méthode
```python
class Chien:
def aboyer():
print("Wouf")
Chien().aboyer()
```
```
TypeError: Chien.aboyer() takes 0 positional arguments but 1 was given
```
Le message semble absurde : « 0 argument attendu, mais 1 donné », alors qu'on n'a rien mis entre les parenthèses ! Souviens-toi : `objet.methode()` passe **automatiquement** l'objet en premier argument. ✅ Toute méthode commence par `self` : `def aboyer(self):`.
### Piège 2 — Oublier `self.` devant un attribut
```python
class Chien:
def __init__(self, nom):
self.nom = nom
def presenter(self):
print(f"Je suis {nom}")
Chien("Rex").presenter()
```
```
NameError: name 'nom' is not defined. Did you mean: 'self.nom'?
```
Dans `presenter`, il n'existe aucune variable locale `nom` : l'information est rangée dans l'objet. Python te souffle même la solution. ✅ `print(f"Je suis {self.nom}")`.
Variante plus sournoise, **sans** message d'erreur immédiat :
```python
class Chien:
def __init__(self, nom):
nom = nom # ❌ ne fait rien d'utile : affecte le paramètre à lui-même
print(Chien("Rex").nom)
```
```
AttributeError: 'Chien' object has no attribute 'nom'
```
✅ `self.nom = nom`.
### Piège 3 — Mal écrire `__init__`
```python
class Chien:
def _init_(self, nom): # un seul tiret bas de chaque côté !
self.nom = nom
Chien("Rex")
```
```
TypeError: Chien() takes no arguments
```
Python ne reconnaît pas `_init_` comme constructeur : pour lui, la classe n'a pas de `__init__`, donc elle n'accepte aucun argument. Même symptôme avec `__int__` ou `__inti__`. ✅ **Deux** tirets bas de chaque côté : `__init__`.
### Piège 4 — L'attribut de classe mutable partagé
Déjà vu à la section 8, mais il mérite sa place ici tant il est fréquent :
```python
class Panier:
articles = [] # ❌ partagé par tous les paniers
def ajouter(self, article):
self.articles.append(article)
```
✅ Toute liste, dictionnaire ou ensemble propre à chaque objet doit être créé **dans `__init__`** : `self.articles = []`.
Même piège avec une **valeur par défaut mutable** dans `__init__` (vu au [chapitre 05](05-fonctions.md)) :
```python
class Panier:
def __init__(self, client, articles=[]): # ❌ la liste par défaut est créée UNE fois
self.client = client
self.articles = articles
panier_alice = Panier("Alice")
panier_bob = Panier("Bob")
panier_alice.articles.append("lait")
print(panier_bob.articles)
```
```
['lait']
```
✅ Utilise `None` comme valeur par défaut :
```python
class Panier:
def __init__(self, client, articles=None):
self.client = client
self.articles = articles if articles is not None else []
```
### Piège 5 — `__str__` qui affiche au lieu de renvoyer
```python
class Chien:
def __init__(self, nom):
self.nom = nom
def __str__(self):
print(f"Chien {self.nom}") # ❌ print au lieu de return
print(Chien("Rex"))
```
```
Chien Rex
Traceback (most recent call last):
...
TypeError: __str__ returned non-string (type NoneType)
```
✅ `return f"Chien {self.nom}"`.
### Piège 6 — Oublier les parenthèses en appelant une méthode
```python
class Chien:
def __init__(self, nom):
self.nom = nom
def aboyer(self):
return "Wouf"
rex = Chien("Rex")
print(rex.aboyer)
```
```
<bound method Chien.aboyer of <__main__.Chien object at 0x7fc4904dee40>>
```
Pas d'erreur, mais on affiche **la méthode elle-même** au lieu de l'appeler. Piège redoutable dans un `if` : `if rex.est_vivant:` est **toujours vrai** (une méthode n'est jamais « vide »). ✅ `rex.aboyer()`.
### Piège 7 — Appeler une méthode sur la classe au lieu d'un objet
```python
class Chien:
def __init__(self, nom):
self.nom = nom
def aboyer(self):
return "Wouf"
Chien.aboyer()
```
```
TypeError: Chien.aboyer() missing 1 required positional argument: 'self'
```
Une méthode ordinaire a besoin d'un **objet** pour travailler. ✅ Crée d'abord un objet : `Chien("Rex").aboyer()`. (Le [chapitre 11](11-poo-avancee-heritage-typage.md) présente les méthodes de classe et statiques, qui s'appellent sur la classe.)
### Piège 8 — Oublier un argument du constructeur
```python
class Chien:
def __init__(self, nom):
self.nom = nom
Chien()
```
```
TypeError: Chien.__init__() missing 1 required positional argument: 'nom'
```
Les arguments de `Chien(...)` sont transmis à `__init__` (sauf `self`, fourni automatiquement). ✅ `Chien("Rex")`, ou une valeur par défaut dans `__init__` si c'est pertinent.
---
## Exercices
### Exercice 1 ⭐ — Rectangle
Écris une classe `Rectangle` avec deux attributs `largeur` et `hauteur`, et les méthodes :
- `aire()` et `perimetre()` ;
- `est_carre()` qui renvoie `True` si les deux côtés sont égaux ;
- `__str__` qui renvoie par exemple `Rectangle 4 x 3`.
```python
r = Rectangle(4, 3)
print(r) # Rectangle 4 x 3
print(r.aire(), r.perimetre()) # 12 14
print(r.est_carre()) # False
print(Rectangle(5, 5).est_carre()) # True
```
### Exercice 2 ⭐ — Compteur de clics
Écris une classe `Compteur` avec :
- un attribut `valeur` qui vaut 0 à la création (ou une valeur de départ optionnelle) ;
- `incrementer(pas=1)`, `decrementer(pas=1)` (la valeur ne descend jamais sous 0) et `reinitialiser()` ;
- `__repr__` qui renvoie `Compteur(valeur=...)`.
```python
c = Compteur()
c.incrementer()
c.incrementer(5)
c.decrementer(2)
print(c) # Compteur(valeur=4)
c.decrementer(10)
print(c.valeur) # 0
c2 = Compteur(10)
print(c2) # Compteur(valeur=10)
```
### Exercice 3 ⭐ — `__str__` et `__repr__`
Écris une classe `Film` (titre, réalisateur, année, durée en minutes) avec :
- `__str__` → `Inception (2010), de Christopher Nolan — 2h28` ;
- `__repr__` → `Film('Inception', 'Christopher Nolan', 2010, 148)`.
Crée une liste de trois films, affiche la liste entière avec `print(films)`, puis chaque film avec une boucle. Explique la différence entre les deux affichages.
### Exercice 4 ⭐⭐ — Le point qui se déplace
Écris une classe `Point` (coordonnées `x` et `y`) avec :
- `deplacer(dx, dy)` qui modifie le point ;
- `distance(autre)` qui renvoie la distance avec un autre `Point` (module `math`) ;
- `milieu(autre)` qui **renvoie un nouveau** `Point` situé au milieu des deux ;
- `__repr__` → `Point(3, 4)` ;
- `__eq__` pour que deux points de mêmes coordonnées soient égaux.
```python
a = Point(0, 0)
b = Point(3, 4)
print(a.distance(b)) # 5.0
print(a.milieu(b)) # Point(1.5, 2.0)
a.deplacer(3, 4)
print(a == b) # True
print(a is b) # False
```
### Exercice 5 ⭐⭐ — Pile de tâches et attribut de classe
Écris une classe `Tache` avec un titre, une priorité (1 à 3) et un état `terminee` (False au départ). Chaque tâche reçoit automatiquement un **numéro unique** (1, 2, 3…), grâce à un **attribut de classe** qui compte les tâches créées.
- `terminer()` passe la tâche à l'état terminé ;
- `__str__` → `#1 [ ] Faire les courses (priorité 2)` (ou `[x]` si terminée) ;
- la priorité doit être entre 1 et 3, sinon `ValueError`.
```python
t1 = Tache("Faire les courses", 2)
t2 = Tache("Réviser Python", 1)
t2.terminer()
print(t1)
print(t2)
print(Tache.compteur)
```
```
#1 [ ] Faire les courses (priorité 2)
#2 [x] Réviser Python (priorité 1)
2
```
### Exercice 6 ⭐⭐ — Le tamagotchi
Écris une classe `Tamagotchi` avec un nom, une `faim` et un `ennui`, tous deux entre 0 (parfait) et 10 (critique), qui démarrent à 3. Méthodes :
- `nourrir()` : faim − 4 ; `jouer()` : ennui − 4 (sans descendre sous 0) ;
- `passer_le_temps()` : faim + 2 et ennui + 1 (sans dépasser 10) ;
- `humeur()` qui renvoie `"heureux"` si la somme faim + ennui est ≤ 5, `"grognon"` si elle est ≤ 12, `"malheureux"` sinon ;
- `__str__` → `Pixel : faim 3/10, ennui 3/10, humeur : grognon`.
Écris un petit programme qui fait vivre l'animal sur une liste d'actions :
```python
actions = ["temps", "temps", "nourrir", "jouer", "temps", "temps", "temps", "temps"]
```
```
Pixel : faim 3/10, ennui 3/10, humeur : grognon
temps -> Pixel : faim 5/10, ennui 4/10, humeur : grognon
temps -> Pixel : faim 7/10, ennui 5/10, humeur : grognon
nourrir -> Pixel : faim 3/10, ennui 5/10, humeur : grognon
jouer -> Pixel : faim 3/10, ennui 1/10, humeur : heureux
temps -> Pixel : faim 5/10, ennui 2/10, humeur : grognon
temps -> Pixel : faim 7/10, ennui 3/10, humeur : grognon
temps -> Pixel : faim 9/10, ennui 4/10, humeur : malheureux
temps -> Pixel : faim 10/10, ennui 5/10, humeur : malheureux
```
### Exercice 7 ⭐⭐⭐ — Carnet de contacts
Écris deux classes :
- `Contact` : nom, téléphone, email (optionnel). `__str__` → `Alice Martin — 06 12 34 56 78 — alice@mail.fr` (sans la partie email si elle est absente).
- `Carnet` : contient une **liste** de `Contact`. Méthodes :
- `ajouter(contact)` : refuse un doublon de nom (insensible à la casse) en levant une `ValueError` ;
- `chercher(texte)` : renvoie la liste des contacts dont le nom contient `texte` (insensible à la casse) ;
- `supprimer(nom)` : supprime le contact, ou lève une `KeyError` s'il n'existe pas ;
- `__len__` ;
- `afficher()` : affiche les contacts triés par nom.
```python
carnet = Carnet()
carnet.ajouter(Contact("Alice Martin", "06 12 34 56 78", "alice@mail.fr"))
carnet.ajouter(Contact("Bob Durand", "07 98 76 54 32"))
carnet.ajouter(Contact("Alicia Keys", "06 00 00 00 00"))
print(len(carnet))
for contact in carnet.chercher("ali"):
print(contact)
carnet.supprimer("bob durand")
carnet.afficher()
```
```
3
Alice Martin — 06 12 34 56 78 — alice@mail.fr
Alicia Keys — 06 00 00 00 00
Alice Martin — 06 12 34 56 78 — alice@mail.fr
Alicia Keys — 06 00 00 00 00
```
### Exercice 8 ⭐⭐⭐ — Sauvegarder des objets en JSON
Reprends la classe `Tache` de l'exercice 5 (sans le compteur automatique : le numéro est passé au constructeur) et ajoute :
- une méthode `vers_dict()` qui renvoie un dictionnaire des attributs ;
- une fonction (hors de la classe) `depuis_dict(donnees)` qui crée une `Tache` à partir d'un tel dictionnaire.
Puis écris `sauvegarder(taches, chemin)` et `charger(chemin)` qui enregistrent / relisent une liste de tâches dans un fichier JSON ([chapitre 07](07-fichiers-csv-json.md)). Vérifie qu'après un aller-retour, les tâches sont identiques.
```python
taches = [Tache(1, "Courses", 2), Tache(2, "Sport", 3, terminee=True)]
sauvegarder(taches, "taches.json")
for tache in charger("taches.json"):
print(tache)
```
```
#1 [ ] Courses (priorité 2)
#2 [x] Sport (priorité 3)
```
---
## Mini-projet — Compte bancaire avec historique
### Cahier des charges
Tu vas modéliser un petit système bancaire, dans un fichier `banque.py`.
**Classe `Operation`** : représente une opération passée.
- Attributs : `type_operation` (`"dépôt"`, `"retrait"`, `"virement émis"`, `"virement reçu"`), `montant`, `date` (un `datetime`), `solde_apres`, `libelle` (optionnel).
- `__str__` → `14/03/2026 10:32 retrait -50.00 € solde : 150.00 € (Courses)`.
**Classe `CompteBancaire`** :
1. Création avec un titulaire et un solde initial optionnel (0 par défaut). Chaque compte reçoit un **numéro unique** automatique, du type `FR-0001`, `FR-0002`… (attribut de classe).
2. `deposer(montant, libelle="")` et `retirer(montant, libelle="")`.
3. `virer(montant, destinataire, libelle="")` : transfère de l'argent vers un **autre objet** `CompteBancaire`. Le virement apparaît dans l'historique **des deux comptes**.
4. Un **découvert autorisé** (attribut, 0 par défaut) : un retrait ne peut pas faire descendre le solde en dessous de `-decouvert_autorise`.
5. Validation :
- montant ≤ 0 → `MontantInvalideError` ;
- solde insuffisant → `SoldeInsuffisantError` (avec le solde disponible dans le message) ;
- virement vers soi-même → `ValueError`.
Les deux exceptions personnalisées héritent d'une exception `BanqueError` (syntaxe vue au [chapitre 08](08-erreurs-et-debogage.md)).
6. Le solde est stocké dans un attribut « interne » `_solde`, consultable via une méthode `solde()`.
7. L'historique est une liste d'`Operation`. Méthodes `afficher_releve()` (avec le solde actuel) et `total_par_type()` qui renvoie un dictionnaire `{type: total}`.
8. `__str__` → `Compte FR-0001 (Alice) : 150.00 €`, `__repr__` → `CompteBancaire('Alice', solde=150.0)`.
Un programme de démonstration (sous `if __name__ == "__main__":`) crée deux comptes, effectue des opérations valides **et invalides** (interceptées proprement), puis affiche les relevés.
### Étapes suggérées
1. Écris les trois exceptions.
2. Écris `Operation` avec son `__str__` (teste-la seule, avec une date fixe).
3. Écris `CompteBancaire` avec `__init__`, `solde()`, `__str__`, `__repr__` et le compteur de numéros.
4. Ajoute une méthode **interne** `_enregistrer(type_operation, montant, libelle)` qui crée une `Operation` et l'ajoute à l'historique : toutes les autres méthodes l'utiliseront.
5. Ajoute `deposer`, puis `retirer` avec le découvert, puis `virer` en **réutilisant** `retirer` et `deposer`… attention, les types d'opération doivent être « virement émis / reçu » : prévois un paramètre pour cela.
6. Ajoute `afficher_releve` et `total_par_type` (un `defaultdict` est idéal).
7. Écris la démonstration et teste toutes les erreurs.
### Pistes d'amélioration
- Une classe `Banque` qui contient tous les comptes, avec recherche par numéro et un menu interactif.
- Sauvegarde et chargement des comptes en JSON (dates au format ISO).
- Filtrer le relevé par période ou par type d'opération.
- Calculer des intérêts mensuels sur un compte épargne (ce sera un bon exercice d'**héritage** au chapitre suivant !).
---
## Corrigés
<details><summary>Corrigé de l'exercice 1</summary>
```python
class Rectangle:
def __init__(self, largeur, hauteur):
self.largeur = largeur
self.hauteur = hauteur
def aire(self):
return self.largeur * self.hauteur
def perimetre(self):
return 2 * (self.largeur + self.hauteur)
def est_carre(self):
return self.largeur == self.hauteur
def __str__(self):
return f"Rectangle {self.largeur} x {self.hauteur}"
r = Rectangle(4, 3)
print(r)
print(r.aire(), r.perimetre())
print(r.est_carre())
print(Rectangle(5, 5).est_carre())
```
```
Rectangle 4 x 3
12 14
False
True
```
`est_carre` renvoie directement le résultat de la comparaison, qui est déjà un booléen : inutile d'écrire `if ...: return True else: return False`.
</details>
<details><summary>Corrigé de l'exercice 2</summary>
```python
class Compteur:
def __init__(self, valeur=0):
self.valeur = valeur
def incrementer(self, pas=1):
self.valeur += pas
def decrementer(self, pas=1):
self.valeur = max(0, self.valeur - pas)
def reinitialiser(self):
self.valeur = 0
def __repr__(self):
return f"Compteur(valeur={self.valeur})"
c = Compteur()
c.incrementer()
c.incrementer(5)
c.decrementer(2)
print(c)
c.decrementer(10)
print(c.valeur)
c2 = Compteur(10)
print(c2)
```
```
Compteur(valeur=4)
0
Compteur(valeur=10)
```
On n'a défini que `__repr__` : `print` l'utilise faute de `__str__`. `max(0, ...)` empêche de descendre sous zéro.
</details>
<details><summary>Corrigé de l'exercice 3</summary>
```python
class Film:
def __init__(self, titre, realisateur, annee, duree):
self.titre = titre
self.realisateur = realisateur
self.annee = annee
self.duree = duree
def __str__(self):
heures, minutes = divmod(self.duree, 60)
return f"{self.titre} ({self.annee}), de {self.realisateur} — {heures}h{minutes:02d}"
def __repr__(self):
return f"Film({self.titre!r}, {self.realisateur!r}, {self.annee}, {self.duree})"
films = [
Film("Inception", "Christopher Nolan", 2010, 148),
Film("Amélie", "Jean-Pierre Jeunet", 2001, 122),
Film("Le Voyage de Chihiro", "Hayao Miyazaki", 2001, 125),
]
print(films)
for film in films:
print(film)
```
```
[Film('Inception', 'Christopher Nolan', 2010, 148), Film('Amélie', 'Jean-Pierre Jeunet', 2001, 122), Film('Le Voyage de Chihiro', 'Hayao Miyazaki', 2001, 125)]
Inception (2010), de Christopher Nolan — 2h28
Amélie (2001), de Jean-Pierre Jeunet — 2h02
Le Voyage de Chihiro (2001), de Hayao Miyazaki — 2h05
```
Afficher une **liste** utilise le `__repr__` de chaque élément (vue « développeur », précise) ; afficher **un** objet avec `print` utilise son `__str__` (vue « utilisateur », jolie). `{minutes:02d}` affiche `02` plutôt que `2`.
</details>
<details><summary>Corrigé de l'exercice 4</summary>
```python
import math
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def deplacer(self, dx, dy):
self.x += dx
self.y += dy
def distance(self, autre):
return math.dist((self.x, self.y), (autre.x, autre.y))
def milieu(self, autre):
return Point((self.x + autre.x) / 2, (self.y + autre.y) / 2)
def __eq__(self, autre):
return self.x == autre.x and self.y == autre.y
def __repr__(self):
return f"Point({self.x}, {self.y})"
a = Point(0, 0)
b = Point(3, 4)
print(a.distance(b))
print(a.milieu(b))
a.deplacer(3, 4)
print(a == b)
print(a is b)
```
```
5.0
Point(1.5, 2.0)
True
False
```
Deux styles de méthodes cohabitent : `deplacer` **modifie** l'objet (et ne renvoie rien), `milieu` **ne modifie rien** et renvoie un **nouvel** objet. Une méthode peut tout à fait créer un objet de sa propre classe. Après `deplacer`, `a` et `b` sont égaux (`==` utilise `__eq__`) mais restent deux objets distincts (`is`).
</details>
<details><summary>Corrigé de l'exercice 5</summary>
```python
class Tache:
compteur = 0 # attribut de classe : nombre de tâches créées
def __init__(self, titre, priorite):
if priorite not in (1, 2, 3):
raise ValueError(f"priorité invalide : {priorite} (1, 2 ou 3)")
Tache.compteur += 1
self.numero = Tache.compteur
self.titre = titre
self.priorite = priorite
self.terminee = False
def terminer(self):
self.terminee = True
def __str__(self):
case = "x" if self.terminee else " "
return f"#{self.numero} [{case}] {self.titre} (priorité {self.priorite})"
t1 = Tache("Faire les courses", 2)
t2 = Tache("Réviser Python", 1)
t2.terminer()
print(t1)
print(t2)
print(Tache.compteur)
try:
Tache("Tâche impossible", 7)
except ValueError as erreur:
print("Erreur :", erreur)
print(Tache.compteur)
```
```
#1 [ ] Faire les courses (priorité 2)
#2 [x] Réviser Python (priorité 1)
2
Erreur : priorité invalide : 7 (1, 2 ou 3)
2
```
On écrit bien `Tache.compteur += 1` (et non `self.compteur += 1`, qui créerait un attribut d'instance). La validation est faite **avant** d'incrémenter le compteur : une tâche refusée ne « consomme » pas de numéro.
</details>
<details><summary>Corrigé de l'exercice 6</summary>
```python
class Tamagotchi:
MINIMUM = 0
MAXIMUM = 10
def __init__(self, nom):
self.nom = nom
self.faim = 3
self.ennui = 3
def _borner(self, valeur):
"""Ramène une valeur entre MINIMUM et MAXIMUM (méthode interne)."""
return max(self.MINIMUM, min(self.MAXIMUM, valeur))
def nourrir(self):
self.faim = self._borner(self.faim - 4)
def jouer(self):
self.ennui = self._borner(self.ennui - 4)
def passer_le_temps(self):
self.faim = self._borner(self.faim + 2)
self.ennui = self._borner(self.ennui + 1)
def humeur(self):
total = self.faim + self.ennui
if total <= 5:
return "heureux"
if total <= 12:
return "grognon"
return "malheureux"
def __str__(self):
return (f"{self.nom} : faim {self.faim}/10, ennui {self.ennui}/10, "
f"humeur : {self.humeur()}")
pixel = Tamagotchi("Pixel")
print(pixel)
actions = ["temps", "temps", "nourrir", "jouer", "temps", "temps", "temps", "temps"]
for action in actions:
match action:
case "nourrir":
pixel.nourrir()
case "jouer":
pixel.jouer()
case "temps":
pixel.passer_le_temps()
print(f"{action:<7} -> {pixel}")
```
```
Pixel : faim 3/10, ennui 3/10, humeur : grognon
temps -> Pixel : faim 5/10, ennui 4/10, humeur : grognon
temps -> Pixel : faim 7/10, ennui 5/10, humeur : grognon
nourrir -> Pixel : faim 3/10, ennui 5/10, humeur : grognon
jouer -> Pixel : faim 3/10, ennui 1/10, humeur : heureux
temps -> Pixel : faim 5/10, ennui 2/10, humeur : grognon
temps -> Pixel : faim 7/10, ennui 3/10, humeur : grognon
temps -> Pixel : faim 9/10, ennui 4/10, humeur : malheureux
temps -> Pixel : faim 10/10, ennui 5/10, humeur : malheureux
```
La méthode interne `_borner` (tiret bas = usage interne) évite de répéter `max(0, min(10, ...))` partout. `__str__` appelle `self.humeur()` : l'humeur n'est pas stockée, elle est **calculée** à la demande, donc toujours à jour.
</details>
<details><summary>Corrigé de l'exercice 7</summary>
```python
class Contact:
def __init__(self, nom, telephone, email=""):
self.nom = nom
self.telephone = telephone
self.email = email
def __str__(self):
texte = f"{self.nom} — {self.telephone}"
if self.email:
texte += f" — {self.email}"
return texte
class Carnet:
def __init__(self):
self.contacts = []
def _trouver(self, nom):
"""Renvoie le contact portant ce nom (casse ignorée), ou None."""
for contact in self.contacts:
if contact.nom.lower() == nom.lower():
return contact
return None
def ajouter(self, contact):
if self._trouver(contact.nom) is not None:
raise ValueError(f"le contact {contact.nom!r} existe déjà")
self.contacts.append(contact)
def chercher(self, texte):
texte = texte.lower()
return [contact for contact in self.contacts if texte in contact.nom.lower()]
def supprimer(self, nom):
contact = self._trouver(nom)
if contact is None:
raise KeyError(nom)
self.contacts.remove(contact)
def __len__(self):
return len(self.contacts)
def afficher(self):
for contact in sorted(self.contacts, key=lambda c: c.nom.lower()):
print(contact)
carnet = Carnet()
carnet.ajouter(Contact("Alice Martin", "06 12 34 56 78", "alice@mail.fr"))
carnet.ajouter(Contact("Bob Durand", "07 98 76 54 32"))
carnet.ajouter(Contact("Alicia Keys", "06 00 00 00 00"))
print(len(carnet))
for contact in carnet.chercher("ali"):
print(contact)
carnet.supprimer("bob durand")
carnet.afficher()
try:
carnet.ajouter(Contact("ALICE MARTIN", "01 01 01 01 01"))
except ValueError as erreur:
print("Refusé :", erreur)
try:
carnet.supprimer("Zoé")
except KeyError as erreur:
print("Introuvable :", erreur)
```
```
3
Alice Martin — 06 12 34 56 78 — alice@mail.fr
Alicia Keys — 06 00 00 00 00
Alice Martin — 06 12 34 56 78 — alice@mail.fr
Alicia Keys — 06 00 00 00 00
Refusé : le contact 'ALICE MARTIN' existe déjà
Introuvable : 'Zoé'
```
La méthode interne `_trouver` est utilisée à la fois par `ajouter` et `supprimer` : on ne répète pas la logique de recherche. `sorted(..., key=lambda c: c.nom.lower())` trie des **objets** selon un de leurs attributs ([chapitre 06](06-chaines-et-comprehensions.md)).
</details>
<details><summary>Corrigé de l'exercice 8</summary>
```python
import json
from pathlib import Path
class Tache:
def __init__(self, numero, titre, priorite, terminee=False):
self.numero = numero
self.titre = titre
self.priorite = priorite
self.terminee = terminee
def vers_dict(self):
return {
"numero": self.numero,
"titre": self.titre,
"priorite": self.priorite,
"terminee": self.terminee,
}
def __str__(self):
case = "x" if self.terminee else " "
return f"#{self.numero} [{case}] {self.titre} (priorité {self.priorite})"
def depuis_dict(donnees):
return Tache(donnees["numero"], donnees["titre"], donnees["priorite"], donnees["terminee"])
def sauvegarder(taches, chemin):
donnees = [tache.vers_dict() for tache in taches]
Path(chemin).write_text(json.dumps(donnees, indent=2, ensure_ascii=False), encoding="utf-8")
def charger(chemin):
donnees = json.loads(Path(chemin).read_text(encoding="utf-8"))
return [depuis_dict(element) for element in donnees]
taches = [Tache(1, "Courses", 2), Tache(2, "Sport", 3, terminee=True)]
sauvegarder(taches, "taches.json")
relues = charger("taches.json")
for tache in relues:
print(tache)
assert [t.vers_dict() for t in relues] == [t.vers_dict() for t in taches]
print(Path("taches.json").read_text(encoding="utf-8"))
```
```
#1 [ ] Courses (priorité 2)
#2 [x] Sport (priorité 3)
[
{
"numero": 1,
"titre": "Courses",
"priorite": 2,
"terminee": false
},
{
"numero": 2,
"titre": "Sport",
"priorite": 3,
"terminee": true
}
]
```
JSON ne sait pas stocker des objets Python : on les **convertit** en dictionnaires (qui, eux, sont compatibles), puis on refait le chemin inverse au chargement. Ce schéma « objet ↔ dictionnaire » est omniprésent (applications web, bases de données…). Pour comparer, on compare les dictionnaires, puisque nos `Tache` n'ont pas de `__eq__`. Au [chapitre 11](11-poo-avancee-heritage-typage.md), `depuis_dict` deviendra une **méthode de classe** (`Tache.depuis_dict(...)`).
</details>
<details><summary>Corrigé du mini-projet</summary>
```python
"""Comptes bancaires avec historique des opérations."""
from collections import defaultdict
from datetime import datetime
class BanqueError(Exception):
pass
class MontantInvalideError(BanqueError):
pass
class SoldeInsuffisantError(BanqueError):
pass
class Operation:
def __init__(self, type_operation, montant, solde_apres, libelle="", date=None):
self.type_operation = type_operation
self.montant = montant # positif pour un crédit, négatif pour un débit
self.solde_apres = solde_apres
self.libelle = libelle
self.date = date if date is not None else datetime.now()
def __str__(self):
texte = (f"{self.date:%d/%m/%Y %H:%M} {self.type_operation:<15} "
f"{self.montant:>+9.2f} € solde : {self.solde_apres:>8.2f} €")
if self.libelle:
texte += f" ({self.libelle})"
return texte
class CompteBancaire:
_dernier_numero = 0 # attribut de classe : compteur partagé
def __init__(self, titulaire, solde_initial=0.0, decouvert_autorise=0.0):
CompteBancaire._dernier_numero += 1
self.numero = f"FR-{CompteBancaire._dernier_numero:04d}"
self.titulaire = titulaire
self.decouvert_autorise = decouvert_autorise
self._solde = 0.0
self.historique = []
if solde_initial > 0:
self.deposer(solde_initial, "Dépôt initial")
# ---------- consultation ----------
def solde(self):
return self._solde
def __str__(self):
return f"Compte {self.numero} ({self.titulaire}) : {self._solde:.2f} €"
def __repr__(self):
return f"CompteBancaire({self.titulaire!r}, solde={self._solde!r})"
# ---------- méthodes internes ----------
@staticmethod
def _verifier_montant(montant):
if montant <= 0:
raise MontantInvalideError(f"le montant doit être positif (reçu : {montant})")
def _enregistrer(self, type_operation, montant, libelle):
self._solde += montant
self.historique.append(Operation(type_operation, montant, self._solde, libelle))
# ---------- opérations ----------
def deposer(self, montant, libelle="", type_operation="dépôt"):
self._verifier_montant(montant)
self._enregistrer(type_operation, montant, libelle)
def retirer(self, montant, libelle="", type_operation="retrait"):
self._verifier_montant(montant)
disponible = self._solde + self.decouvert_autorise
if montant > disponible:
raise SoldeInsuffisantError(
f"retrait de {montant:.2f} € refusé, disponible : {disponible:.2f} €"
)
self._enregistrer(type_operation, -montant, libelle)
def virer(self, montant, destinataire, libelle=""):
if destinataire is self:
raise ValueError("impossible de faire un virement vers le même compte")
# retirer d'abord : s'il échoue, rien n'est déposé chez le destinataire
self.retirer(montant, f"vers {destinataire.titulaire} {libelle}".strip(),
type_operation="virement émis")
destinataire.deposer(montant, f"de {self.titulaire} {libelle}".strip(),
type_operation="virement reçu")
# ---------- rapports ----------
def total_par_type(self):
totaux = defaultdict(float)
for operation in self.historique:
totaux[operation.type_operation] += operation.montant
return dict(totaux)
def afficher_releve(self):
print(f"=== Relevé du compte {self.numero} — {self.titulaire} ===")
if not self.historique:
print("(aucune opération)")
for operation in self.historique:
print(operation)
print(f"Solde actuel : {self._solde:.2f} €")
print()
if __name__ == "__main__":
alice = CompteBancaire("Alice", 200)
bob = CompteBancaire("Bob", decouvert_autorise=100)
print(alice)
print(bob)
print(repr(alice))
print()
operations = [
("Alice retire 50 €", lambda: alice.retirer(50, "Courses")),
("Bob retire 80 € (découvert)", lambda: bob.retirer(80, "Loyer")),
("Bob retire 50 € de plus", lambda: bob.retirer(50)),
("Alice dépose -10 €", lambda: alice.deposer(-10)),
("Alice vire 120 € à Bob", lambda: alice.virer(120, bob, "Remboursement")),
("Alice vire 500 € à Bob", lambda: alice.virer(500, bob)),
("Alice se vire 10 €", lambda: alice.virer(10, alice)),
]
for description, action in operations:
try:
action()
except BanqueError as erreur:
print(f"✗ {description} : {type(erreur).__name__} — {erreur}")
except ValueError as erreur:
print(f"✗ {description} : {erreur}")
else:
print(f"✓ {description}")
print()
alice.afficher_releve()
bob.afficher_releve()
print("Totaux d'Alice :", alice.total_par_type())
```
Sortie (les dates et heures affichées seront celles du moment de l'exécution) :
```
Compte FR-0001 (Alice) : 200.00 €
Compte FR-0002 (Bob) : 0.00 €
CompteBancaire('Alice', solde=200.0)
✓ Alice retire 50 €
✓ Bob retire 80 € (découvert)
✗ Bob retire 50 € de plus : SoldeInsuffisantError — retrait de 50.00 € refusé, disponible : 20.00 €
✗ Alice dépose -10 € : MontantInvalideError — le montant doit être positif (reçu : -10)
✓ Alice vire 120 € à Bob
✗ Alice vire 500 € à Bob : SoldeInsuffisantError — retrait de 500.00 € refusé, disponible : 30.00 €
✗ Alice se vire 10 € : impossible de faire un virement vers le même compte
=== Relevé du compte FR-0001 — Alice ===
14/03/2026 10:32 dépôt +200.00 € solde : 200.00 € (Dépôt initial)
14/03/2026 10:32 retrait -50.00 € solde : 150.00 € (Courses)
14/03/2026 10:32 virement émis -120.00 € solde : 30.00 € (vers Bob Remboursement)
Solde actuel : 30.00 €
=== Relevé du compte FR-0002 — Bob ===
14/03/2026 10:32 retrait -80.00 € solde : -80.00 € (Loyer)
14/03/2026 10:32 virement reçu +120.00 € solde : 40.00 € (de Alice Remboursement)
Solde actuel : 40.00 €
Totaux d'Alice : {'dépôt': 200.0, 'retrait': -50.0, 'virement émis': -120.0}
```
Points importants :
- **Une seule méthode modifie `_solde`** : `_enregistrer`. Impossible d'oublier d'ajouter une ligne à l'historique, puisque les deux sont faits au même endroit.
- `virer` **réutilise** `retirer` et `deposer` (avec un paramètre `type_operation`), donc profite gratuitement de toutes leurs vérifications. Le retrait est fait **en premier** : s'il échoue, l'exception interrompt `virer` avant tout dépôt, et aucun argent n'est créé.
- Le solde initial passe par `deposer` : il apparaît dans l'historique, et le relevé est toujours cohérent.
- `{self.date:%d/%m/%Y %H:%M}` : une f-string accepte directement un format de date ([chapitre 09](09-modules-bibliotheque-standard.md)). `{montant:>+9.2f}` affiche toujours le signe (`+` ou `-`).
- `@staticmethod` devant `_verifier_montant` indique une méthode qui n'a pas besoin de `self` ; elle sera expliquée au [chapitre 11](11-poo-avancee-heritage-typage.md). Sans ce décorateur, on aurait simplement écrit `def _verifier_montant(self, montant):`.
- La démonstration range des `lambda` dans une liste pour tester chaque opération dans le même `try` : pratique pour essayer plein de cas sans répéter le code.
</details>
---
## Récapitulatif
- Une **classe** est un modèle (le moule) ; un **objet** ou **instance** est un exemplaire concret (le gâteau). On crée un objet en appelant la classe : `Chien("Rex")`.
- Les **attributs** sont les données d'un objet (`self.nom`), les **méthodes** ses actions (`def aboyer(self):`).
- `__init__` est le **constructeur** : il est appelé automatiquement à la création et initialise les attributs.
- **`self` est l'objet courant**. `rex.aboyer()` équivaut à `Chien.aboyer(rex)` : toute méthode reçoit `self` en premier paramètre, et on accède aux attributs avec `self.`.
- `__str__` (affichage pour l'utilisateur) et `__repr__` (affichage pour le développeur) doivent **renvoyer** une chaîne.
- Un **attribut de classe** est partagé par toutes les instances : réservé aux constantes et compteurs. Les listes et dictionnaires propres à chaque objet se créent **dans `__init__`**.
- Les objets sont manipulés **par référence** : `b = a` ne copie pas l'objet. `is` teste l'identité, `==` l'égalité (personnalisable avec `__eq__`).
- Une classe protège sa cohérence en **validant** dans ses méthodes et en **levant des exceptions**. Le tiret bas (`_solde`) signale un attribut interne.
- Les classes se combinent : un objet peut contenir d'autres objets (composition).
- Conventions : `CamelCase` pour les classes, `snake_case` pour les attributs et méthodes.
### Checklist de validation
- [ ] Je sais expliquer la différence entre une classe et un objet avec mes propres mots.
- [ ] Je sais écrire une classe avec un `__init__` et plusieurs attributs.
- [ ] Je sais expliquer ce qu'est `self` et pourquoi `objet.methode()` passe l'objet automatiquement.
- [ ] Je sais écrire des méthodes qui modifient l'objet et d'autres qui renvoient une valeur.
- [ ] Mes classes ont un `__repr__` (et un `__str__` si besoin) qui renvoie une chaîne.
- [ ] Je sais pourquoi `articles = []` au niveau de la classe est un bug.
- [ ] Je sais faire interagir deux objets (une méthode qui reçoit un autre objet).
- [ ] Mon compte bancaire refuse les opérations invalides et garde un historique cohérent.
---
[← Chapitre précédent](09-modules-bibliotheque-standard.md) | [Sommaire](README.md) | [Chapitre suivant →](11-poo-avancee-heritage-typage.md)