Retour au cours

backend / python

POO : classes et objets

Leçon 111 exercice

Explication

Ce que vous allez apprendre

  • Définir une classe avec un état (attributs) et un comportement (méthodes)
  • Distinguer un attribut de classe, partagé par toutes les instances, d'un attribut d'instance
  • Choisir entre méthode normale, @classmethod et @staticmethod selon le besoin
  • Implémenter __str__ et __repr__ pour un affichage lisible et un débogage efficace
  • Comprendre les limites de l'encapsulation en Python (_protege, __prive)

Dans quel contexte ?

Un développeur ajoute une fonctionnalité de bonus de fidélité à une classe CompteBancaire existante et écrit, par réflexe venu d'un autre langage, taux_bonus = 0.01 directement au niveau de la classe plutôt que dans __init__. Il découvre en test que modifier ce taux pour un seul client via compte.taux_bonus = 0.05 fonctionne, mais que CompteBancaire.taux_bonus reste inchangé pour tous les autres comptes — un comportement correct une fois qu'on comprend la différence entre attribut de classe et attribut d'instance, mais déroutant sans cette leçon.

Regrouper données et comportement

Jusqu'à présent, les données (listes, dictionnaires) et les fonctions qui les manipulaient étaient séparées. La programmation orientée objet propose de les regrouper : une classe définit un modèle qui combine un état (les attributs) et des actions possibles sur cet état (les méthodes). Une instance de CompteBancaire n'est pas juste un solde stocké quelque part, c'est un objet qui sait lui-même comment déposer ou retirer de l'argent en respectant ses propres règles.

Attribut de classe contre attribut d'instance

C'est une distinction souvent mal comprise au début : un attribut défini au niveau de la classe (comme taux_interet) est partagé par toutes les instances, alors qu'un attribut défini dans __init__ via self appartient uniquement à l'instance qui l'a créé. Réaffecter un attribut de classe depuis une instance ne modifie pas la classe : cela crée un nouvel attribut d'instance qui masque simplement l'attribut de classe pour cet objet précis.

Type de méthodePremier paramètreUsage typique
Méthode normaleself (l'instance)Agir sur l'état d'un objet précis
@classmethodcls (la classe)Constructeur alternatif (depuis_dict)
@staticmethodAucunFonction utilitaire liée logiquement à la classe

Piège fréquent

compte.taux_interet = 0.05 ne modifie jamais CompteBancaire.taux_interet : cela crée un nouvel attribut d'instance qui masque l'attribut de classe pour ce seul objet. Pour changer la valeur partagée par toutes les instances, il faut écrire CompteBancaire.taux_interet = 0.05.

Deux façons alternatives d'appeler une méthode

Une méthode normale reçoit toujours self, l'instance sur laquelle elle est appelée. Une méthode de classe (@classmethod) reçoit cls, la classe elle-même, ce qui en fait un excellent outil pour créer des constructeurs alternatifs. Une méthode statique (@staticmethod) ne reçoit ni l'un ni l'autre : c'est simplement une fonction utilitaire rangée dans la classe par cohérence logique.

L'encapsulation à la Python

Contrairement à des langages comme Java, Python n'impose pas de vraie protection des attributs privés : les underscores sont des conventions de communication entre développeurs, pas des verrous. C'est un choix philosophique du langage : faire confiance aux développeurs plutôt que de tout verrouiller.

Commandes & code

POO : classes et objets

Modéliser des entités avec état et comportement.

python
class CompteBancaire:
    # Attribut de classe : partage par toutes les instances
    taux_interet = 0.02

    def __init__(self, titulaire: str, solde_initial: float = 0):
        # Attributs d'instance : propres a chaque objet
        self.titulaire = titulaire
        self.solde = solde_initial
        self._historique = []          # convention "_" = usage interne

    def deposer(self, montant: float) -> None:
        if montant <= 0:
            raise ValueError("Le montant doit etre positif")
        self.solde += montant
        self._historique.append(("depot", montant))

    def retirer(self, montant: float) -> None:
        if montant > self.solde:
            raise ValueError("Solde insuffisant")
        self.solde -= montant
        self._historique.append(("retrait", montant))

    def appliquer_interets(self) -> None:
        self.solde *= (1 + self.taux_interet)

    def __str__(self):
        # Representation lisible pour l'utilisateur (print, str())
        return f"Compte de {self.titulaire} : {self.solde:.2f} EUR"

    def __repr__(self):
        # Representation non-ambigue pour le debogage (console, logs)
        return f"CompteBancaire(titulaire={self.titulaire!r}, solde={self.solde!r})"

compte = CompteBancaire("Alice", 1000)
compte.deposer(500)
compte.retirer(200)
print(compte)                      # utilise __str__
print(repr(compte))                # utilise __repr__

# Attribut de classe vs instance
print(CompteBancaire.taux_interet)     # 0.02, accessible sans instance
compte.taux_interet = 0.05             # cree un attribut D'INSTANCE qui masque celui de classe
print(compte.taux_interet, CompteBancaire.taux_interet)   # 0.05, 0.02 (inchange)

# Methodes de classe et methodes statiques
class Produit:
    devise_defaut = "EUR"

    def __init__(self, nom, prix):
        self.nom = nom
        self.prix = prix

    @classmethod
    def depuis_dict(cls, data: dict) -> "Produit":
        # constructeur alternatif : pattern tres courant
        return cls(data["nom"], data["prix"])

    @staticmethod
    def convertir_devise(montant: float, taux: float) -> float:
        # ne depend ni de l'instance ni de la classe : juste une fonction utilitaire logee ici
        return round(montant * taux, 2)

produit = Produit.depuis_dict({"nom": "Clavier", "prix": 49.99})
prix_usd = Produit.convertir_devise(produit.prix, 1.08)

# Encapsulation : conventions Python (pas d'imposition stricte comme Java)
class Exemple:
    def __init__(self):
        self.public = "visible partout"
        self._protege = "convention : usage interne, mais accessible"
        self.__prive = "name mangling : devient _Exemple__prive"

e = Exemple()
print(e._Exemple__prive)      # toujours accessible, mais decourage explicitement

# Attributs de classe mutables : piege similaire aux defauts de fonction
class Panier:
    def __init__(self):
        self.articles = []          # OK ici car defini dans __init__, pas au niveau classe

    # class Panier:
    #     articles = []             # BUG : partage entre toutes les instances !

Résumé

  • Les attributs de classe sont partagés ; les attributs d'instance (définis dans __init__) ne le sont pas.
  • @classmethod reçoit cls (constructeurs alternatifs), @staticmethod ne reçoit ni self ni cls.
  • __str__ est pour l'affichage humain, __repr__ pour le débogage (doit idéalement être non-ambigu).
  • Le "privé" en Python (__attr) est une convention (name mangling), pas une vraie protection.

Exercices pratiques

1 disponible
1

Mission : comprendre pourquoi un seul client a hérité d'un taux à tous

Objectif : Diagnostiquer une confusion entre attribut de classe et attribut d'instance sur un système bancaire, et concevoir un constructeur alternatif.

Contexte

La classe CompteBancaire définit taux_interet = 0.02 directement au niveau de la classe. Un développeur exécute compte_vip.taux_interet = 0.05 pour offrir un meilleur taux à un client VIP, puis s'inquiète en testant CompteBancaire.taux_interet : il vaut toujours 0.02, alors qu'il pensait avoir changé le taux pour tout le monde par erreur.

Tu dois expliquer ce comportement, écrire un constructeur alternatif depuis_dict, puis raisonner sur la différence entre __str__ et __repr__.

Résoudre l’exercice →