Initialisation des systèmes...

Baptiste.Dev
Retour aux notes
LangagesIntermédiaireSérie : Python

Python : Les objets (POO)

Classes, self et __init__, héritage, encapsulation par convention, méthodes spéciales et @dataclass : la POO à la sauce Python.

7 min de lecture
Mis à jour aujourd'hui
pythonpooclassesheritagedataclassdunder

Python : Les objets (POO)

En Python, tout est objet : un int, une chaîne, une fonction, une classe elle-même. La POO y est donc naturelle, mais plus souple et plus « déclarative » qu'en C# : pas de new, pas de private, pas d'interfaces obligatoires. Si tu viens de C#, garde C# : Les objets en tête, les comparaisons ci-dessous s'y réfèrent.


# Définir une classe

class Compte:
    def __init__(self, titulaire, solde=0):
        self.titulaire = titulaire
        self.solde = solde
 
    def deposer(self, montant):
        self.solde += montant
 
compte = Compte("Alice", 100)   # pas de new
compte.deposer(50)
compte.solde                    # 150
  • __init__ joue le rôle du constructeur : il initialise l'objet déjà créé.
  • self désigne l'instance courante. C'est l'équivalent du this de C#, à une différence près : il est explicite, on l'écrit en premier paramètre de chaque méthode.
  • Les attributs ne se déclarent pas à l'avance : ils naissent à la première affectation self.x = valeur dans __init__.

compte.deposer(50) n'est qu'un raccourci pour Compte.deposer(compte, 50). C'est pour ça que self est explicite : c'est littéralement l'objet passé en premier argument.

Et puisque c'est un paramètre comme un autre, self n'est pas un mot-clé du langage, juste une convention. Le nom est libre :

class Compte:
    def deposer(moi, montant):      # fonctionne exactement pareil
        moi.solde += montant

Possible, mais à éviter : la convention est universelle (PEP 8 la recommande) et tout lecteur, linter ou IDE s'attend à self. Même chose pour cls, le nom d'usage du premier paramètre d'une @classmethod, qui reçoit la classe au lieu de l'instance.


# Attributs d'instance et attributs de classe

Un attribut défini dans le corps de la classe (hors méthode) est partagé par toutes les instances, comme un static en C#.

class Compte:
    taux_interet = 0.02             # attribut de classe : partagé
 
    def __init__(self, titulaire):
        self.titulaire = titulaire  # attribut d'instance : propre à chaque objet
 
Compte.taux_interet                 # 0.02, accessible sans instance

Même piège que la valeur par défaut modifiable : un attribut de classe modifiable (articles = []) est partagé entre toutes les instances. Ajouter un article à un panier le fait apparaître dans tous. Les données propres à chaque objet s'initialisent dans __init__.


# Pas de private : l'encapsulation par convention

Python n'a ni private ni protected. La philosophie : « nous sommes entre adultes consentants ». On signale l'intention par le nommage :

NommageSignification
soldePublic.
_solde« Interne, n'y touche pas ». Simple convention, rien ne l'empêche.
__soldeRenommé en _Compte__solde (name mangling) pour éviter les collisions en héritage. Pas une vraie protection non plus.

C'est une des facettes de la permissivité du langage : l'encapsulation repose sur la discipline de l'équipe, pas sur le compilateur.


# Les propriétés (@property)

Pour contrôler l'accès à un attribut sans changer la syntaxe d'appel, on utilise @property, l'équivalent des propriétés get / set de C#.

class Compte:
    def __init__(self, solde):
        self._solde = solde
 
    @property
    def solde(self):                # lecture : compte.solde
        return self._solde
 
    @solde.setter
    def solde(self, valeur):        # écriture : compte.solde = 10
        if valeur < 0:
            raise ValueError("Le solde ne peut pas être négatif")
        self._solde = valeur

L'appelant écrit toujours compte.solde, comme pour un attribut simple. On peut donc commencer avec un attribut public et le transformer en propriété plus tard sans casser le code existant. C'est pour ça qu'en Python, on n'écrit pas de getters / setters « par précaution ».


# L'héritage

La classe parente se passe entre parenthèses. super() donne accès à ses méthodes.

class Animal:
    def __init__(self, nom):
        self.nom = nom
 
    def parler(self):
        return "Bruit générique"
 
class Chien(Animal):
    def __init__(self, nom, race):
        super().__init__(nom)       # appelle le __init__ d'Animal
        self.race = race
 
    def parler(self):               # redéfinition : pas de virtual / override
        return "Wouf"
 
rex = Chien("Rex", "berger")
rex.parler()                        # "Wouf"
isinstance(rex, Animal)             # True

Toutes les méthodes sont redéfinissables, sans mot-clé virtual ni override. Python autorise aussi l'héritage multiple (class C(A, B)), là où C# se limite à une classe parente plus des interfaces. Puissant, mais à manier avec parcimonie.


# Pas besoin d'interface : le duck typing

En C#, pour qu'une méthode accepte « tout ce qui sait parler », on déclare une interface. En Python, on n'en a pas besoin : si l'objet a une méthode parler(), on peut l'appeler. C'est le duck typing : « si ça cancane comme un canard, c'est un canard ».

class Robot:                        # n'hérite pas d'Animal
    def parler(self):
        return "Bip"
 
for chose in [Chien("Rex", "berger"), Robot()]:
    print(chose.parler())           # "Wouf", puis "Bip"

Quand on veut quand même imposer un contrat, le module abc fournit les classes abstraites, pendant des classes abstraites de C# :

from abc import ABC, abstractmethod
 
class Forme(ABC):
    @abstractmethod
    def aire(self):
        pass
 
Forme()     # TypeError : impossible d'instancier une classe abstraite

# Les méthodes spéciales (dunder)

Les méthodes entourées de doubles underscores (dunder, pour double underscore) branchent ta classe sur la syntaxe du langage. C'est ce qui rend les objets Python si « natifs ».

MéthodeDéclenchée parRôle
__init__Compte(...)Initialisation
__str__print(obj), str(obj)Affichage lisible pour l'utilisateur
__repr__repr(obj), la console, une liste d'objetsReprésentation technique, pour le debug
__eq__a == bÉgalité (par défaut : même objet en mémoire)
__len__len(obj)Taille
__add__a + bSurcharge d'opérateur
class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y
 
    def __repr__(self):
        return f"Point({self.x}, {self.y})"
 
    def __eq__(self, autre):
        return (self.x, self.y) == (autre.x, autre.y)
 
Point(1, 2) == Point(1, 2)          # True grâce à __eq__

Sans __eq__, deux objets aux mêmes valeurs sont différents : Python compare les références, exactement comme une classe C#. Même logique que dans HashSet et l'égalité.


# @dataclass : le record de Python

Écrire __init__, __repr__ et __eq__ à la main pour une simple classe de données est fastidieux. Le décorateur @dataclass les génère à partir des champs annotés, comme un record en C#.

from dataclasses import dataclass, field
 
@dataclass
class Point:
    x: int
    y: int
 
p = Point(1, 2)
p                                   # Point(x=1, y=2)   (__repr__ généré)
p == Point(1, 2)                    # True              (__eq__ généré)
 
@dataclass(frozen=True)             # immuable, comme un record
class Coordonnee:
    lat: float
    lon: float
 
@dataclass
class Panier:
    articles: list = field(default_factory=list)   # une liste neuve par instance
  • frozen=True interdit toute modification après création, et rend l'objet utilisable comme clé de dict ou élément de set.
  • field(default_factory=list) règle proprement le piège de la liste partagée vu plus haut.

Les annotations (x: int) ne sont pas vérifiées à l'exécution : Point("a", "b") passe sans erreur. Elles servent à @dataclass pour connaître les champs, et aux outils comme mypy pour la vérification statique.


# La suite