Aller au contenu

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 (et surtout les fonctions du chapitre 05 et les dictionnaires du chapitre 04).

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 :

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.

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.

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 :

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.

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 :

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). Et tous les attributs ne viennent pas forcément d'un paramètre :

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 :

rex.aboyer()

Python le traduit automatiquement en :

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.

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

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 :

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

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 :

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 :

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 :

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), les objets sont manipulés par référence : une variable ne contient pas l'objet, elle pointe vers lui.

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

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 é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) si la demande est invalide.

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

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.

12. Ranger ses classes dans des modules

Une classe s'importe comme une fonction (chapitre 09). 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
# 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

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

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 :

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__

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 :

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

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 :

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

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

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

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 présente les méthodes de classe et statiques, qui s'appellent sur la classe.)

Piège 8 — Oublier un argument du constructeur

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.
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=...).
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.
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.
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 :

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.
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). Vérifie qu'après un aller-retour, les tâches sont identiques.

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 :
  6. montant ≤ 0 → MontantInvalideError ;
  7. solde insuffisant → SoldeInsuffisantError (avec le solde disponible dans le message) ;
  8. virement vers soi-même → ValueError. Les deux exceptions personnalisées héritent d'une exception BanqueError (syntaxe vue au chapitre 08).
  9. Le solde est stocké dans un attribut « interne » _solde, consultable via une méthode solde().
  10. 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}.
  11. __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
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
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
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
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
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
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
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
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
"""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 | Sommaire | Chapitre suivant →