# 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()) ``` ``` 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> 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) ``` ``` > ``` 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
Corrigé de l'exercice 1 ```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`.
Corrigé de l'exercice 2 ```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.
Corrigé de l'exercice 3 ```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`.
Corrigé de l'exercice 4 ```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`).
Corrigé de l'exercice 5 ```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.
Corrigé de l'exercice 6 ```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.
Corrigé de l'exercice 7 ```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)).
Corrigé de l'exercice 8 ```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(...)`).
Corrigé du mini-projet ```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.
--- ## 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)