De la fonction à la classe en Python : le grand saut, pas à pas

Ton script tourne. Il est fait de fonctions, chacune fait son travail, et pourtant chaque nouvelle fonctionnalité oblige à traîner une variable de plus d’un appel à l’autre. Un jour, tu ouvres le code d’un collègue et tu y trouves self, __init__, des classes qui héritent d’autres classes.

Bonne nouvelle : ce saut n’est pas un changement de langage, c’est un changement de rangement. Voici comment le franchir sans te perdre.

L’essentiel en 30 secondes

  • Une classe regroupe au même endroit des données (ses attributs) et les fonctions qui travaillent dessus (ses méthodes).
  • Le constructeur __init__() prépare chaque objet ; les variables d’instance sont propres à chaque objet, les variables de classe sont partagées.
  • Les méthodes magiques (__str__(), __lt__(), __iter__()…) apprennent à tes objets à réagir aux opérateurs et aux boucles de Python.
  • L’héritage permet de réutiliser une classe existante ; le polymorphisme permet à une même fonction de traiter des objets de classes différentes.
  • En Python, rien n’est vraiment privé : c’est une affaire de convention.

L’intuition : ranger ce qui va ensemble

Imagine un petit jeu à deux joueurs. Il te faut deux scores, une opération pour ajouter des points, une pour remettre les compteurs à zéro, une pour afficher le score et une pour désigner le vainqueur. Avec de simples fonctions, il faut passer les scores de l’une à l’autre à chaque appel, et c’est à toi de ne jamais les mélanger.

L’approche orientée objet propose autre chose : fabriquer un seul objet qui possède les deux scores et sait faire ces quatre opérations. Tu ne manipules plus des variables éparpillées, mais un « suivi de score » auquel tu donnes des ordres. C’est exactement ce que fait la classe ScoreTracker un peu plus bas.

💡 Intuition Une classe, c’est un plan de fabrication ; chaque objet créé à partir de ce plan emporte ses propres données avec lui, et connaît les opérations qui s’y appliquent.

Sous le capot : les briques d’une classe

Une classe, un constructeur, un attribut

Voici la classe la plus simple qui soit : elle a un constructeur __init__() qui pose un attribut x, et une méthode qui l’affiche.

#!/usr/bin/env python

class MyClass:
    def __init__(self):
          self.x = 5

    def main(self):
          print('inside main')
          print('x:',self.x)

if __name__ == '__main__':
    myClass = MyClass()
    myClass.main()

Le bloc if __name__ == '__main__': est un idiome que tu croiseras partout : il n’exécute son contenu que si le fichier est lancé directement, jamais lorsqu’il est simplement importé par un autre programme. Trois façons d’écrire l’en-tête existent (class MyClass:, class MyClass(): ou class MyClass(object):), mais elles sont équivalentes : en Python 3, toute classe hérite implicitement de object.

Ce qui appartient à l’objet, ce qui appartient à la classe

Une distinction change tout : une variable d’instance est propre à chaque objet, une variable de classe est partagée par toutes les instances.

class Company:
    # a shared class variable:
    location = "USA"
    def __init__(self, name):
        # an instance variable:
        self.name = name

Ici, location est identique pour toutes les entreprises créées ; name, lui, est défini à la création et diffère d’un objet à l’autre.

Public, protégé, privé : une question de politesse

En Python 3, tout est public par défaut. Un tiret bas en préfixe (_variable) signale une variable protégée, réservée à la classe et à ses sous-classes ; un double tiret bas (__variable) signale une variable privée, réservée à la classe elle-même. Mais rien n’empêche techniquement d’y accéder de l’extérieur, contrairement à Java où le compilateur l’interdit.

⚠️ Piège Un attribut « privé » en Python n’est privé que par convention : c’est ton engagement d’équipe qui le protège, pas le langage.

Le voir tourner : ton premier objet utile

Reprenons notre jeu à deux joueurs. Observe comment les deux scores et les quatre opérations vivent dans la même classe, et comment le code appelant se contente de donner des ordres à un objet.

class ScoreTracker:
    def __init__(self, player1_initial=0,
player2_initial=0):
        self.player1 = player1_initial
        self.player2 = player2_initial

    def update_score(self, player_number,
new_points=None):
        if player_number == 1:
            self.player1 += (new_points or 1)
        elif player_number == 2:
            self.player2 += (new_points or 1)
        else:
            raise ValueError("Player number must be 1 or 2")

    def reset_scores(self):
        self.player1 = 0
        self.player2 = 0

    def display_scores(self, format_style="{} - {}"):
        return format_style.format(self.player1, self.player2)

    def get_winner(self):
        if self.player1 > self.player2:
            return "Player 1"
        elif self.player2 > self.player1:
            return "Player 2"
        else:
            return "Tie"

# Example usage
score_game = ScoreTracker()
score_game.update_score(1)
score_game.update_score(2, new_points=5) # Player 2 scores additional points

print(score_game.display_scores())
print("Current score:", score_game.player1, "-",
score_game.player2)

if score_game.get_winner():
    print(f"Winner is {score_game.get_winner()}!")

score_game.reset_scores()
print("Reset scores:", score_game.display_scores())

Trois choses à retenir de cet exemple. Les scores sont créés une fois, dans le constructeur, avec 0 par défaut. Chaque méthode travaille sur ces mêmes scores sans qu’on ait à les lui repasser. Enfin, l’appel update_score(2, new_points=5) montre qu’une méthode se règle avec des arguments, exactement comme une fonction : ta culture des fonctions reste donc entièrement valable.

Cacher le « comment » derrière un « quoi »

Une classe peut aussi masquer sa cuisine interne. Ici, le code appelant ne connaît que search_list() ; le choix entre une recherche linéaire et une recherche binaire se fait à l’intérieur, dans des méthodes préfixées par __.

class MyEncapsulation:
   def __init__(self,my_list):
     self.my_list = my_list
     self.sorted = False

   # invokes a private method:
   def search_list(self,item):
     self.__search_my_list(item)

   def __search_my_list(self,item):
     if(self.sorted == False):
        # perform a linear search:
        self.__linear_search(item)
     else:
        # perform a binary search:
        self.__binary_search(item)

   def __linear_search(self,item):
     # perform a linear search
     pass

   def __binary_search(self,item):
     # perform a binary search
     pass

if __name__ == '__main__':
    enc1 = MyEncapsulation([4,1,5,3,8])
    enc1.search_list(5)

Le bénéfice est concret : demain, on peut remplacer la recherche linéaire par une binaire, ou l’inverse, sans changer la signature publique search_list() ni le code qui l’appelle. On appelle cela l’encapsulation : faire évoluer l’intérieur d’une classe sans casser ce qui en dépend.

Faire parler tes objets avec les méthodes magiques

Les méthodes dont le nom commence et finit par deux tirets bas (__add__(), __len__(), __str__()…) s’appellent des méthodes magiques, ou dunders. Elles permettent à une classe personnalisée de répondre aux opérateurs standards de Python.

Deux d’entre elles décident de l’affichage : __str__() vise un texte lisible pour l’utilisateur final, __repr__() un texte utile au développeur. Si l’une manque, Python retombe sur l’autre, et print() appelle __str__() par défaut.

Autre exemple : pour dire ce que signifie « inférieur à » entre deux points en 3D, on écrit __lt__(). On peut ensuite comparer deux objets avec l’opérateur <, comme n’importe quel type natif.

def __lt__(self, other):
    if self.x < other.x and self.y < other.y and self.z < other.z:
        return True
    else:
        return False

if(p1 < p2): print("p1 < p2")
elif (p2 < p1): print("p2 < p1")
else: print("cannot be compared")

Ce même __lt__() est ce qu’utilise queue.PriorityQueue pour savoir quelle tâche traiter en premier : elle compare les tâches entre elles, et les traite par priorité croissante, quel que soit leur ordre d’insertion. Tes classes deviennent ainsi compatibles avec les outils de la bibliothèque standard.

💡 Intuition Une méthode magique, c’est un contrat : « si tu me fournis cette méthode, Python saura s’en servir dans ses opérateurs et ses boucles ».

Une classe qui se laisse parcourir

Le même principe s’applique aux boucles for. Un objet est itérable dès qu’il définit __iter__(), qui doit renvoyer un itérateur, c’est-à-dire un objet doté de __next__(), chargé de produire l’élément suivant ou de lever StopIteration quand tout est épuisé.

Réutiliser sans copier : héritage et polymorphisme

L’héritage : « fais comme ma classe parente »

Quand une classe B hérite d’une classe A, tout ce que A définit devient accessible depuis B. Dans l’exemple ci-dessous, la méthode func() de MyClassB est mise en commentaire : c’est donc celle de MyClassA qui s’exécute quand on appelle instanceB.func().

class MyClassA:
   def __init__(self):
      print('Hello from A')

   def func(self):
      print('Hello again from A')

class MyClassB(MyClassA):
   def __init__(self):
      print('Hello from B')

  #def func(self):
  # print('Hello again from B')

if __name__ == '__main__':
    instanceA = MyClassA()
    instanceB = MyClassB()

    print()
    print('instanceA:')
    instanceA.func()
    print('instanceB:')
    instanceB.func()

Un contrat pour les sous-classes

Une classe de base peut aussi servir de simple contrat. Dans un exemple de préférences alimentaires, la classe parente MyFoodPrefs lève volontairement NotImplementedError dans chacune de ses méthodes : elle impose aux sous-classes MyVegetarian et MyCarnivore d’écrire leur propre version. Chacune appelle super().__init__() pour déclencher le constructeur de la classe parente. La version végétarienne refuse d’ajouter « Steak » ou « Chicken », la version carnivore n’applique jamais ce filtre.

Le polymorphisme : une fonction, plusieurs classes

C’est ici que tes fonctions retrouvent leur place. Le polymorphisme désigne la capacité d’une même fonction à opérer sur des objets de classes différentes, pourvu qu’ils exposent l’interface attendue. Regarde calcPerimeter() : elle ne teste jamais le type de son argument.

class MyRect:
   def perimeter(self):
     print('Perimeter of a rectangle')

class MySquare:
   def perimeter(self):
     print('Perimeter of a square')

def calcPerimeter(obj):
     obj.perimeter()

if __name__ == '__main__':
    instanceR = MyRect()
    instanceS = MySquare()

    print('instanceR:')
    calcPerimeter(instanceR)
    print('instanceS:')
    calcPerimeter(instanceS)

MyRect et MySquare n’ont aucun lien de parenté : seul compte le fait que les deux exposent une méthode perimeter(). C’est le duck typing : la fonction ne se soucie pas de la classe exacte, seulement de savoir si l’objet « se comporte comme un canard ». Java exigerait une interface commune ; en Python, cette exigence reste implicite. Pour formaliser malgré tout le contrat, le module abc (Abstract Base Class) permet de définir des classes abstraites qui imposent les méthodes à implémenter.

📌 À retenir Passer aux objets ne t’oblige pas à jeter tes fonctions : une fonction qui accepte « n’importe quel objet sachant faire ceci » est l’une des formes les plus élégantes du polymorphisme.

Pièges et limites

  • Constructeurs jamais appelés. Dans l’exemple d’héritage multiple, MyClassC(MyClassA, MyClassB) définit son propre __init__() sans appeler ceux de ses parents : ils ne s’exécutent donc pas. Pense à super().__init__() quand tu veux le comportement du parent.

  • Héritage multiple : l’ordre compte. Quand deux parents définissent la même méthode, Python la cherche de gauche à droite dans la liste des parents. Avec MyClassC(MyClassA, MyClassB), c’est celle de MyClassA qui gagne ; inverse l’ordre et le résultat bascule. Le module inspect te montre cet ordre de résolution (le MRO) avec inspect.getmro(MaClasse).

  • Toujours passer par iter(). Appeler directement __iter__() ne déclenche aucune vérification, alors que la fonction iter() lève une TypeError si __iter__() ne renvoie pas un vrai itérateur.

  • L’addition n’est pas symétrique. myobj1 + 7 fonctionne grâce à __add__(), mais 7 + myobj1 échoue tant que tu n’as pas écrit __radd__(). Même principe pour __sub__()/__rsub__() et __mul__()/__rmul__().

  • Ne compte pas sur __del__(). Son exécution n’est pas garantie : pour libérer un fichier ou une connexion, ferme-les explicitement.

Aller plus loin

  • __init__ et sa fratrie : trois méthodes encadrent la vie d’un objet — __new__() le crée, __init__() l’initialise, __del__() intervient à sa destruction.
  • Le protocole d’itération : c’est le mécanisme qui se cache derrière toute boucle for, et que ta classe peut implémenter à son tour.
  • Les collections d’objets : une classe qui encapsule une liste d’objets « valeur » (des employés, par exemple) est un motif que l’on retrouve dans la plupart des applications réelles.
  • Tes propres structures de données : liste chaînée, file de priorité… rien n’empêche de les construire avec des classes personnalisées.

À retenir

  • Une classe range au même endroit un état et les opérations qui le modifient : tu donnes des ordres à un objet au lieu de faire circuler des variables.
  • Ce que tu sais des fonctions (arguments, valeurs par défaut, retours) s’applique tel quel aux méthodes.
  • Les méthodes magiques rendent tes objets compatibles avec les opérateurs, les comparaisons et les boucles de Python.
  • L’héritage réutilise une classe ; le duck typing permet de ne pas en avoir besoin quand seule l’interface compte.
  • Python te fait confiance : privé et protégé sont des conventions, pas des barrières.