cours1 min de lecture

Objet, classe, interface

Introduire d'un seul tenant la programmation orientée objet et la distinction interface / implémentation — l'outillage qui structurera tout le chapitre.
programme

Pourquoi des objets ?

Les structures de données du chapitre — files, piles, arbres, graphes — ont une particularité commune : elles regroupent un état (les éléments stockés) et un comportement (les opérations qu'on peut faire dessus). Tant qu'on ne manipule qu'une seule pile ou une seule file dans un programme jouet, on peut s'en sortir avec une liste Python et quelques fonctions libres. Dès qu'on en a plusieurs, ou qu'on veut protéger la cohérence interne, cette approche craque : rien n'empêche un appelant distrait de modifier la liste interne et de casser l'invariant.

Considérez un compte bancaire dont on suit le solde. Avec un simple dictionnaire {"solde": 100}, n'importe qui peut écrire compte["solde"] = -1000 sans qu'aucune règle métier n'intervienne. Le compte n'a plus aucun garde-fou.

Le paradigme objet offre un cadre linguistique parfaitement adapté à cette situation. Une classe réunit en un même bloc le plan des données internes (les attributs) et le catalogue des opérations disponibles (les méthodes). Un objet est une instance concrète de ce plan — une analogie utile : la classe est le plan d'architecte, l'objet est la maison construite. Plusieurs maisons peuvent être bâties depuis un même plan, chacune avec ses meubles à elle.

Cet item ne demande ni l'héritage ni le polymorphisme. L'objectif est plus modeste : savoir écrire une définition de classe Python, l'instancier, accéder à ses attributs et appeler ses méthodes.

Classe et instance

Reprenons le compte bancaire. Une définition minimale en Python :

class CompteBancaire:
    """Compte bancaire avec un solde et des opérations de dépôt/retrait."""

    def __init__(self, solde_initial=0):
        self.solde = solde_initial          # attribut d'instance

Trois éléments à comprendre :

  • Le mot-clé class introduit la définition, suivi du nom de la classe en PascalCase (CompteBancaire).
  • __init__ est une méthode spéciale appelée à la création d'un objet — on l'appelle constructeur. Son rôle : initialiser les attributs.
  • self est la convention Python pour désigner l'objet courant au sein d'une méthode. C'est obligatoirement le premier paramètre.

L'instanciation consiste à appeler la classe comme une fonction :

c = CompteBancaire(100)   # appel implicite de __init__(self, solde_initial=100)
print(c.solde)            # 100

Chaque appel CompteBancaire(...) crée un objet indépendant : ses attributs lui sont propres.

c1 = CompteBancaire(100)
c2 = CompteBancaire(50)
c1.solde = 200
print(c1.solde, c2.solde)   # 200 50 — c2 n'a pas bougé

Dans c = CompteBancaire(100), à quel paramètre du init la valeur 100 est-elle passée ?

Méthodes — donner un comportement aux objets

Une méthode est une fonction définie dans la classe ; elle agit sur les attributs de l'objet via self. Ajoutons à CompteBancaire les opérations attendues d'un vrai compte :

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

    def deposer(self, montant):
        self.solde = self.solde + montant

    def retirer(self, montant):
        self.solde = self.solde - montant

    def consulter(self):
        return self.solde

Une méthode s'appelle sur un objet avec la notation pointée :

c = CompteBancaire(100)
c.deposer(50)
c.retirer(20)
print(c.consulter())   # 130

Quand vous écrivez c.deposer(50), Python traduit en CompteBancaire.deposer(c, 50) — l'objet c est passé en premier argument, qui porte le nom self par convention. C'est tout le mystère de self.

Dans c.deposer(50), à quoi correspond le paramètre self ?

Une fonction libre vit hors de toute classe ; on l'appelle par son nom (carre(5)). Une méthode est définie dans une classe ; on l'appelle sur un objet (c.deposer(50)) et elle a accès aux attributs de cet objet via self.

L'interface d'une classe

Une classe expose vers l'extérieur un ensemble d'opérations — son interface — et cache vers l'intérieur les détails de réalisation — son implémentation. Pour CompteBancaire, l'interface naturelle est :

MéthodeEffet attendu
deposer(montant)Augmente le solde de montant.
retirer(montant)Diminue le solde de montant, à condition qu'il reste positif.
consulter()Renvoie le solde courant.

Cette spécification — le contrat que la classe promet de respecter — est indépendante de la façon dont elle est codée. C'est elle que voit l'utilisateur de la classe ; lui n'a pas à savoir si le solde est stocké dans un entier, dans un flottant, ou dans une base de données distante. Tant que consulter() renvoie le bon nombre, le contrat est tenu.

Une analogie utile : l'interface est un contrat de sous-traitance. Le donneur d'ordre liste ce qu'il veut recevoir (les opérations et leur comportement) ; peu lui importe quelle usine fabrique, ni avec quelle machine. Tant que le contrat est respecté, on peut changer d'usine sans rien renégocier en amont.

Cette séparation est au cœur de . Elle vous permet de raisonner sur un programme sans dépendre du code interne d'une classe, et d'échanger une implémentation par une autre sans rien casser ailleurs.

Au cours suivant, on verra que la file FIFO admet plusieurs implémentations qui exposent toutes la même interface — c'est l'illustration canonique de cette règle. Pour l'instant, on reste sur le compte bancaire pour poser le vocabulaire.

Encapsulation — protéger les invariants

Un invariant est une propriété que les attributs d'un objet doivent toujours vérifier. Pour notre compte : « le solde reste positif ou nul ». La version actuelle de retirer ne protège rien :

c = CompteBancaire(50)
c.retirer(200)
print(c.consulter())   # -150  — invariant brisé

Le rôle de l'encapsulation est de placer le contrôle dans la méthode, à l'unique endroit où la modification se fait :

class CompteBancaire:
    def __init__(self, solde_initial=0):
        self._solde = solde_initial   # attribut "interne", préfixe _ par convention

    def deposer(self, montant):
        if montant <= 0:
            raise ValueError("le montant doit être strictement positif")
        self._solde = self._solde + montant

    def retirer(self, montant):
        if montant <= 0:
            raise ValueError("le montant doit être strictement positif")
        if montant > self._solde:
            raise ValueError("solde insuffisant")
        self._solde = self._solde - montant

    def consulter(self):
        return self._solde

Deux changements méritent un commentaire :

  • L'attribut s'appelle désormais _solde avec un préfixe _. Cette convention signale au lecteur que l'attribut est interne à la classe et qu'on ne doit pas le toucher de l'extérieur. Python n'impose aucune protection technique — vous pouvez écrire c._solde = -1000 et cela marchera. Le préfixe est un engagement social entre le rédacteur de la classe et son utilisateur.
  • L'accès à la valeur passe par la méthode consulter() plutôt que par l'attribut. Si demain on stocke le solde différemment (en centimes au lieu d'euros, par exemple), l'utilisateur ne s'en rendra pas compte — seule la méthode change.
Le préfixe _ n'est pas une protection technique. Python n'empêche pas l'accès. C'est une convention. Si vous lisez c._solde depuis l'extérieur, vous brisez l'abstraction même si Python vous laisse faire — et vous perdez la garantie que les invariants tiennent.

Que se passe-t-il à l'exécution de c.retirer(200) sur un compte au solde 50, dans la version encapsulée ?

Conventions et notations

  • Nom de classe : PascalCase (CompteBancaire, Pile, Noeud).
  • Attribut interne : préfixe _ indiquant « privé par convention » (self._solde).
  • Méthode magique : encadrée par __ (__init__, __len__, __repr__). Vous verrez __init__ partout dans le chapitre ; les autres sont optionnelles.
  • Une méthode = un verbe : deposer, retirer, consulter, enfiler, defiler. L'attribut est un nom (solde, valeur).

Pour aller plus loin

La méthode spéciale __repr__ permet de définir comment un objet s'affiche quand on le print :

class CompteBancaire:
    # ... reste de la classe inchangé ...

    def __repr__(self):
        return f"CompteBancaire(solde={self._solde})"

c = CompteBancaire(150)
print(c)   # CompteBancaire(solde=150)

Vous reverrez les classes dans tout le reste du chapitre : FileTableau et FileDeuxPiles au cours suivant pour illustrer la pluralité des implémentations ; Pile (LIFO) et File (FIFO) au cours 3 pour les structures linéaires canoniques ; Noeud au cours 4 pour les arbres ; GrapheMatrice et GrapheListe au cours 5 pour les graphes. Le vocabulaire posé ici sera utilisé partout.