33  POO (1/2) : classes et instances

🎯 Chapitre charnière du cursus

La Programmation Orientée Objet (POO) est le tournant intellectuel du cursus Python. Elle change votre façon de concevoir un programme : on ne manipule plus seulement des données, on modélise des objets qui combinent données et comportements. Prenez votre temps — ce chapitre est dense mais fondamental.

33.1 Pourquoi la POO ?

Imaginez que vous développez un jeu vidéo avec des personnages. Chaque personnage a :

  • Des données : nom, points de vie, force, équipement…
  • Des comportements : attaquer, se défendre, soigner, mourir…

Sans POO, il faudrait gérer tout ça avec des dictionnaires et des fonctions séparées :

# Sans POO : dict + fonctions éparpillées
personnage = {"nom": "Aragorn", "pv": 100, "force": 20}

def attaquer(attaquant, cible):
    degats = attaquant["force"]
    cible["pv"] -= degats

legolas = {"nom": "Legolas", "pv": 80, "force": 15}
attaquer(personnage, legolas)
print(legolas)
{'nom': 'Legolas', 'pv': 60, 'force': 15}

Problèmes :

  • Les données et les fonctions sont éparpillées.
  • N’importe qui peut modifier directement les PV (personnage["pv"] = 9999).
  • Pas de réutilisation : si on veut un nouveau type de personnage, on recopie tout.
  • Pas de contrat : rien ne garantit qu’un « personnage » a bien les clés pv et force.

Avec la POO, on regroupe données + comportements dans une classe :

class Personnage:
    def __init__(self, nom, pv, force):
        self.nom = nom
        self.pv = pv
        self.force = force

    def attaquer(self, cible):
        cible.pv -= self.force

# Création de personnages
aragorn = Personnage("Aragorn", 100, 20)
legolas = Personnage("Legolas", 80, 15)

# Utilisation
aragorn.attaquer(legolas)
print(f"{legolas.nom} a maintenant {legolas.pv} PV")
Legolas a maintenant 60 PV

Plus propre, plus sûr, plus extensible.

33.2 Vocabulaire essentiel

Terme Définition
Classe Le moule — définit le type d’objet (ses attributs, ses méthodes).
Instance Un objet concret créé à partir de la classe.
Attribut Une donnée stockée dans une instance ou une classe.
Méthode Une fonction définie dans une classe, appelée sur une instance.
self Référence à l’instance courante dans les méthodes.
Constructeur La méthode spéciale __init__ qui initialise un nouvel objet.

Analogie

  • Une classe = le plan d’une maison.
  • Une instance = une maison construite selon ce plan.
  • Les attributs = couleur de la façade, nombre de pièces (propres à chaque maison).
  • Les méthodes = actions possibles (ouvrir la porte, allumer la lumière).

33.3 Définir une classe

class NomDeClasse:
    # corps de la classe
    ...

Convention PEP 8 : nom de classe en PascalCase (première lettre de chaque mot en majuscule, sans underscore).

class Chien:
    pass                 # classe vide pour l'instant

# Créer des instances
rex = Chien()
medor = Chien()

print(rex)
print(type(rex))
print(rex is medor)      # False : deux instances distinctes
<__main__.Chien object at 0x000001ED78DF7A40>
<class '__main__.Chien'>
False

Les parenthèses () à l’appel Chien() instancient la classe (créent un nouvel objet).

33.4 Les attributs d’instance

Un attribut d’instance est une donnée propre à chaque objet. On les ajoute avec instance.attribut = valeur.

class Chien:
    pass

rex = Chien()
rex.nom = "Rex"
rex.age = 3
rex.race = "Berger allemand"

medor = Chien()
medor.nom = "Médor"
medor.age = 7

print(f"{rex.nom}, {rex.age} ans, {rex.race}")
print(f"{medor.nom}, {medor.age} ans")
Rex, 3 ans, Berger allemand
Médor, 7 ans

Chaque instance a ses propres attributs — rex.nom et medor.nom sont indépendants.

Limite de cette approche

Écrire rex.nom = ...; rex.age = ... à chaque création est fastidieux et risqué : on peut oublier un attribut, ou faire une faute de frappe.

C’est le rôle du constructeur __init__.

33.5 Le constructeur __init__

__init__ (prononcé « dunder init », pour double underscore init) est une méthode spéciale appelée automatiquement à la création d’une instance. Elle sert à initialiser les attributs.

class Chien:
    def __init__(self, nom, age, race="inconnue"):
        self.nom = nom
        self.age = age
        self.race = race

# Création — les arguments passent au __init__
rex = Chien("Rex", 3, "Berger allemand")
medor = Chien("Médor", 7)        # race prend sa valeur par défaut

print(f"{rex.nom}, {rex.age} ans, {rex.race}")
print(f"{medor.nom}, {medor.age} ans, {medor.race}")
Rex, 3 ans, Berger allemand
Médor, 7 ans, inconnue

Anatomie

def __init__(self, nom, age, race="inconnue"):
    │         │    │                │
    │         │    │                valeur par défaut (classique)
    │         │    paramètres passés à l'appel Chien(...)
    │         référence à l'instance courante
    méthode spéciale Python

Points à noter :

  • __init__ ne se return pas une valeur : Python renvoie l’instance automatiquement.
  • Les paramètres après self sont ceux qu’on passe à l’appel : Chien("Rex", 3).
  • self.nom = nom stocke le paramètre dans l’attribut.

33.6 Le paramètre self — clé de la POO

self est le paramètre le plus déroutant de la POO Python. Voici l’essentiel à comprendre :

Qu’est-ce que self ?

self est une convention : c’est la première variable que Python passe automatiquement aux méthodes, et elle désigne l’instance courante sur laquelle la méthode est appelée.

class Chien:
    def __init__(self, nom):
        self.nom = nom

    def aboyer(self):
        print(f"{self.nom} dit : Wouf !")

rex = Chien("Rex")
rex.aboyer()        # équivalent à Chien.aboyer(rex)
Rex dit : Wouf !

Quand on écrit rex.aboyer(), Python transforme ça en interne en Chien.aboyer(rex) — c’est pourquoi self est rempli automatiquement par rex.

Pourquoi écrire self ?

Sans self, une méthode ne saurait pas quelle instance utiliser. Imaginez deux chiens :

class Chien:
    def __init__(self, nom):
        self.nom = nom

    def aboyer(self):
        print(f"{self.nom} dit : Wouf !")

rex = Chien("Rex")
medor = Chien("Médor")

rex.aboyer()        # self = rex → affiche "Rex"
medor.aboyer()      # self = medor → affiche "Médor"
Rex dit : Wouf !
Médor dit : Wouf !

Le même code de aboyer, mais self change selon l’instance.

self est une convention, pas un mot-clé

Techniquement, vous pourriez écrire def aboyer(this): — ça marcherait. Mais TOUS les développeurs Python utilisent self. L’utiliser rend votre code immédiatement lisible. Respectez la convention.

33.7 Les méthodes

Une méthode est une fonction définie dans une classe. Sa première paramètre est toujours self.

class CompteBancaire:
    def __init__(self, titulaire, solde_initial=0):
        self.titulaire = titulaire
        self.solde = solde_initial

    def deposer(self, montant):
        self.solde += montant
        print(f"+{montant} €. Nouveau solde : {self.solde} €")

    def retirer(self, montant):
        if montant > self.solde:
            print("Fonds insuffisants")
            return
        self.solde -= montant
        print(f"-{montant} €. Nouveau solde : {self.solde} €")

    def afficher(self):
        print(f"Compte de {self.titulaire} : {self.solde} €")


# Utilisation
compte = CompteBancaire("Alice", 100)
compte.afficher()
compte.deposer(50)
compte.retirer(30)
compte.retirer(1000)        # refusé
compte.afficher()
Compte de Alice : 100 €
+50 €. Nouveau solde : 150 €
-30 €. Nouveau solde : 120 €
Fonds insuffisants
Compte de Alice : 120 €

Points à noter :

  • Toutes les méthodes ont self comme premier paramètre.
  • On appelle avec instance.methode(arguments).
  • Dans la méthode, on lit et modifie les attributs via self.attribut.

33.8 Attributs de classe vs attributs d’instance

Il existe deux types d’attributs :

Attributs d’instance

Déclarés dans __init__ avec self.attribut = ...propres à chaque objet.

class Chien:
    def __init__(self, nom):
        self.nom = nom         # attribut d'instance

rex = Chien("Rex")
medor = Chien("Médor")
print(rex.nom, medor.nom)      # noms différents
Rex Médor

Attributs de classe

Déclarés directement dans le corps de la classe, en dehors des méthodes — partagés par toutes les instances.

class Chien:
    espece = "Canis lupus familiaris"   # attribut de CLASSE, partagé

    def __init__(self, nom):
        self.nom = nom                   # attribut d'instance

rex = Chien("Rex")
medor = Chien("Médor")

print(rex.nom, rex.espece)
print(medor.nom, medor.espece)

# Accès via la classe directement
print(Chien.espece)
Rex Canis lupus familiaris
Médor Canis lupus familiaris
Canis lupus familiaris

Quand utiliser quoi ?

Utiliser un attribut de classe quand… Utiliser un attribut d’instance quand…
La valeur est la même pour toutes les instances (ex: espèce, taux fiscal, configuration globale). La valeur est propre à chaque instance (ex: nom, âge, solde).
⚠️ Piège TOSA : attribut de classe mutable

Si un attribut de classe est mutable (liste, dict), toutes les instances le partagent — avec les pièges qu’on connaît de la Partie 2 !

class Caddy:
    articles = []         # ❌ attribut de classe mutable partagé !

    def ajouter(self, article):
        self.articles.append(article)

c1 = Caddy()
c2 = Caddy()
c1.ajouter("pain")
print(c2.articles)        # ["pain"] !? Surprise : partagé
['pain']

La bonne pratique : initialiser dans __init__ :

class Caddy:
    def __init__(self):
        self.articles = []    # ✅ chaque instance a SA liste

    def ajouter(self, article):
        self.articles.append(article)

c1 = Caddy()
c2 = Caddy()
c1.ajouter("pain")
print("c1 :", c1.articles)
print("c2 :", c2.articles)
c1 : ['pain']
c2 : []

C’est le même piège que les valeurs par défaut mutables des fonctions.

33.9 Méthode qui renvoie une valeur

Une méthode peut bien sûr renvoyer une valeur comme une fonction classique.

class Rectangle:
    def __init__(self, longueur, largeur):
        self.longueur = longueur
        self.largeur = largeur

    def aire(self):
        return self.longueur * self.largeur

    def perimetre(self):
        return 2 * (self.longueur + self.largeur)

    def est_carre(self):
        return self.longueur == self.largeur


r = Rectangle(4, 3)
print(f"Aire      : {r.aire()}")
print(f"Périmètre : {r.perimetre()}")
print(f"Carré ?   : {r.est_carre()}")
Aire      : 12
Périmètre : 14
Carré ?   : False

33.10 Méthode __str__ : représentation humaine

Par défaut, print(objet) affiche quelque chose de peu lisible :

class Chien:
    def __init__(self, nom):
        self.nom = nom

rex = Chien("Rex")
print(rex)
<__main__.Chien object at 0x000001ED7840EAB0>

La méthode spéciale __str__ permet de définir une représentation humaine :

class Chien:
    def __init__(self, nom):
        self.nom = nom

    def __str__(self):
        return f"Chien nommé {self.nom}"

rex = Chien("Rex")
print(rex)                # affiche "Chien nommé Rex"
print(str(rex))
Chien nommé Rex
Chien nommé Rex

On verra plus de méthodes spéciales (dites « dunders ») dans le chapitre suivant.

33.11 Tester le type : isinstance

Pour tester si un objet est une instance d’une classe :

class Chien:
    pass

class Chat:
    pass

rex = Chien()

print(isinstance(rex, Chien))       # True
print(isinstance(rex, Chat))        # False
print(isinstance(rex, (Chien, Chat)))  # True si l'un OU l'autre
True
False
True

isinstance est préféré à type(rex) == Chien — il gère correctement les sous-classes (vu en chapitre 5).


🧩 Quiz 4.1 — POO partie 1

Question 1

Quel est le nom de la méthode spéciale appelée à la création d’une instance ?

  1. __new__
  2. __init__
  3. __create__
  4. constructor

b) __init__ — c’est la méthode d’initialisation, appelée juste après la création d’un objet. __new__ existe aussi mais est rarement utilisé directement.

Question 2

Que désigne le paramètre self dans une méthode ?

  1. La classe elle-même
  2. L’instance courante
  3. Le premier argument passé à la méthode
  4. Un mot-clé Python

b) L’instance couranteself est une convention qui désigne l’objet sur lequel la méthode est appelée. Python le remplit automatiquement.

Question 3

Que s’affiche-t-il ?

class C:
    def __init__(self, x):
        self.x = x

a = C(10)
b = C(20)
print(a.x + b.x)
  1. 10
  2. 20
  3. 30
  4. Une erreur

c) 30a.x = 10, b.x = 20, chaque instance a ses propres attributs.

class C:
    def __init__(self, x):
        self.x = x

a = C(10)
b = C(20)
print(a.x + b.x)
30

Question 4

Quelle convention PEP 8 pour un nom de classe ?

  1. snake_case : mon_objet
  2. camelCase : monObjet
  3. PascalCase : MonObjet
  4. SCREAMING_SNAKE : MON_OBJET

c) PascalCase — première lettre de chaque mot en majuscule, sans underscore. Ex: CompteBancaire, Utilisateur, Rectangle.

Question 5

Que s’affiche-t-il ?

class Caddy:
    articles = []

    def ajouter(self, x):
        self.articles.append(x)

c1 = Caddy()
c2 = Caddy()
c1.ajouter("pain")
print(c2.articles)
  1. []
  2. ["pain"]
  3. Une erreur
  4. None

b) ["pain"] — piège TOSA ! articles est un attribut de classe mutable, partagé par toutes les instances. Il faut l’initialiser dans __init__ pour éviter ce problème.

class Caddy:
    articles = []

    def ajouter(self, x):
        self.articles.append(x)

c1 = Caddy()
c2 = Caddy()
c1.ajouter("pain")
print(c2.articles)
['pain']

Question 6

Comment tester qu’obj est une instance de Chien ?

  1. type(obj) is Chien
  2. obj is Chien
  3. isinstance(obj, Chien)
  4. obj.__class__ == Chien

c) isinstance(obj, Chien) — méthode recommandée, qui gère aussi l’héritage (une sous-classe reste une instance de la classe parente). Les autres formes marchent mais sont moins idiomatiques.

Question 7

Que renvoie print(rex) avec cette classe ?

class Chien:
    def __init__(self, nom):
        self.nom = nom
    def __str__(self):
        return f"Chien : {self.nom}"

rex = Chien("Rex")
print(rex)
  1. <__main__.Chien object at 0x...>
  2. Chien : Rex
  3. {"nom": "Rex"}
  4. Une erreur

b) Chien : Rexprint utilise __str__ si elle est définie. Sinon, on aurait la forme a).

class Chien:
    def __init__(self, nom):
        self.nom = nom
    def __str__(self):
        return f"Chien : {self.nom}"

rex = Chien("Rex")
print(rex)
Chien : Rex

✏️ Exercice 4.1 — Classe Rectangle

Créez une classe Rectangle avec :

  1. Un constructeur prenant longueur et largeur.
  2. Des méthodes aire(), perimetre(), est_carre().
  3. Une méthode __str__ renvoyant "Rectangle 4x3 (aire = 12)".
class Rectangle:
    ...

r = Rectangle(4, 3)
print(r.aire())
print(r.perimetre())
print(r.est_carre())
print(r)
class Rectangle:
    def __init__(self, longueur, largeur):
        self.longueur = longueur
        self.largeur = largeur

    def aire(self):
        return self.longueur * self.largeur

    def perimetre(self):
        return 2 * (self.longueur + self.largeur)

    def est_carre(self):
        return self.longueur == self.largeur

    def __str__(self):
        return f"Rectangle {self.longueur}x{self.largeur} (aire = {self.aire()})"


r = Rectangle(4, 3)
print(r.aire())
print(r.perimetre())
print(r.est_carre())
print(r)

c = Rectangle(5, 5)
print(c.est_carre())
print(c)
12
14
False
Rectangle 4x3 (aire = 12)
True
Rectangle 5x5 (aire = 25)

✏️ Exercice 4.2 — Compte bancaire

Créez une classe CompteBancaire avec :

  • titulaire (str), solde (float, défaut 0).
  • Méthodes deposer(montant), retirer(montant) (avec refus si fonds insuffisants).
  • Méthode virer(montant, destinataire) qui retire chez self et dépose chez destinataire (un autre CompteBancaire).
  • __str__ affichant "Compte d'Alice : 150 €".
class CompteBancaire:
    ...

alice = CompteBancaire("Alice", 200)
bob = CompteBancaire("Bob")

alice.virer(50, bob)
print(alice)
print(bob)
class CompteBancaire:
    def __init__(self, titulaire, solde=0):
        self.titulaire = titulaire
        self.solde = solde

    def deposer(self, montant):
        if montant <= 0:
            print("Montant invalide")
            return
        self.solde += montant

    def retirer(self, montant):
        if montant <= 0:
            print("Montant invalide")
            return False
        if montant > self.solde:
            print(f"Fonds insuffisants pour {self.titulaire}")
            return False
        self.solde -= montant
        return True

    def virer(self, montant, destinataire):
        if self.retirer(montant):
            destinataire.deposer(montant)
            print(f"Virement {montant} € : {self.titulaire}{destinataire.titulaire}")

    def __str__(self):
        return f"Compte de {self.titulaire} : {self.solde} €"


# Tests
alice = CompteBancaire("Alice", 200)
bob = CompteBancaire("Bob")

print(alice)
print(bob)

alice.virer(50, bob)
print(alice)
print(bob)

alice.virer(500, bob)     # fonds insuffisants
Compte de Alice : 200 €
Compte de Bob : 0 €
Virement 50 € : Alice → Bob
Compte de Alice : 150 €
Compte de Bob : 50 €
Fonds insuffisants pour Alice
  • retirer renvoie un booléen pour indiquer si l’opération a réussi. Utilisé par virer pour décider d’enchaîner ou pas.
  • Une méthode peut appeler une autre méthode de la même classe via self.methode(...).
  • Les instances interagissent (alice.virer(50, bob) manipule bob).

✏️ Exercice 4.3 — Classe Livre avec compteur

Créez une classe Livre avec :

  • Constructeur titre, auteur, annee.
  • Un attribut de classe nb_livres qui compte le nombre total de livres créés.
  • __str__ affichant "'Le Petit Prince' par Saint-Exupéry (1943)".
class Livre:
    ...

l1 = Livre("Le Petit Prince", "Saint-Exupéry", 1943)
l2 = Livre("1984", "Orwell", 1949)
l3 = Livre("L'Étranger", "Camus", 1942)

print(l1)
print(l2)
print(Livre.nb_livres)         # 3
class Livre:
    nb_livres = 0      # attribut de CLASSE, partagé

    def __init__(self, titre, auteur, annee):
        self.titre = titre
        self.auteur = auteur
        self.annee = annee
        Livre.nb_livres += 1       # incrémente à chaque création

    def __str__(self):
        return f"'{self.titre}' par {self.auteur} ({self.annee})"


l1 = Livre("Le Petit Prince", "Saint-Exupéry", 1943)
l2 = Livre("1984", "Orwell", 1949)
l3 = Livre("L'Étranger", "Camus", 1942)

print(l1)
print(l2)
print(l3)
print(f"Nombre total de livres : {Livre.nb_livres}")
'Le Petit Prince' par Saint-Exupéry (1943)
'1984' par Orwell (1949)
'L'Étranger' par Camus (1942)
Nombre total de livres : 3

Faire self.nb_livres += 1 créerait un attribut d’instance qui masquerait l’attribut de classe. Résultat : le compteur ne s’incrémenterait pas globalement.

class LivreBug:
    nb_livres = 0

    def __init__(self, titre):
        self.titre = titre
        self.nb_livres += 1       # ❌ crée un attribut d'instance !

l1 = LivreBug("A")
l2 = LivreBug("B")
print("Classe :", LivreBug.nb_livres)     # 0 — non modifié !
print("l1 :", l1.nb_livres)                # 1 — attribut local
Classe : 0
l1 : 1

✏️ Exercice 4.4 — Classe Etudiant avec bulletin

Créez une classe Etudiant :

  • Constructeur : nom, initialise une liste vide notes.
  • ajouter_note(note) : ajoute une note à la liste.
  • moyenne() : renvoie la moyenne (0 si aucune note).
  • mention() : renvoie "Très bien" si ≥16, "Bien" si ≥14, "Passable" si ≥10, "Insuffisant" sinon.
  • __str__ affichant "Alice — 12.5 (Passable)".
class Etudiant:
    ...

alice = Etudiant("Alice")
alice.ajouter_note(14)
alice.ajouter_note(15)
alice.ajouter_note(12)

print(alice.moyenne())
print(alice.mention())
print(alice)
class Etudiant:
    def __init__(self, nom):
        self.nom = nom
        self.notes = []              # CHAQUE instance a SA liste

    def ajouter_note(self, note):
        if 0 <= note <= 20:
            self.notes.append(note)
        else:
            print(f"Note invalide : {note}")

    def moyenne(self):
        if not self.notes:
            return 0
        return sum(self.notes) / len(self.notes)

    def mention(self):
        m = self.moyenne()
        if m >= 16:
            return "Très bien"
        elif m >= 14:
            return "Bien"
        elif m >= 10:
            return "Passable"
        else:
            return "Insuffisant"

    def __str__(self):
        return f"{self.nom}{self.moyenne():.1f} ({self.mention()})"


# Tests
alice = Etudiant("Alice")
for note in [14, 15, 12, 18]:
    alice.ajouter_note(note)

bob = Etudiant("Bob")
for note in [8, 7, 9]:
    bob.ajouter_note(note)

charlie = Etudiant("Charlie")     # aucune note

print(alice)
print(bob)
print(charlie)
Alice — 14.8 (Bien)
Bob — 8.0 (Insuffisant)
Charlie — 0.0 (Insuffisant)
  • self.notes = [] dans __init__ — chaque étudiant a sa liste de notes (pas de partage entre instances).
  • Une méthode peut appeler une autre méthode de la même classe : mention() utilise moyenne().
  • __str__ peut utiliser les autres méthodes et faire du formatage riche.

À retenir

Points clés du chapitre
  1. Une classe est un moule, une instance est un objet créé à partir de cette classe.
  2. __init__(self, ...) est le constructeur, appelé automatiquement à la création.
  3. self désigne l’instance courante — toujours premier paramètre des méthodes.
  4. Attributs d’instance (self.x = ... dans __init__) : propres à chaque objet.
  5. Attributs de classe (déclarés dans le corps) : partagés par toutes les instances.
  6. JAMAIS d’attribut de classe mutable : utilisez __init__ pour les listes/dicts.
  7. __str__ pour une représentation lisible par l’humain (utilisée par print).
  8. isinstance(obj, Classe) pour tester le type.
  9. PEP 8 : classes en PascalCase.

← Chapitre précédent : Les lambdasChapitre suivant : POO (2/2) →