Retour au cours

backend / python

POO : méthodes spéciales (dunder) et propriétés

Leçon 131 exercice

Explication

Ce que vous allez apprendre

  • Comprendre le lien entre len(x), x + y, x == y et les méthodes dunder qu'ils appellent
  • Implémenter __eq__ et __hash__ ensemble, sans jamais oublier l'un des deux
  • Rendre une classe itérable, indexable ou "appelable" via __iter__, __getitem__, __call__
  • Utiliser @property pour exposer une méthode comme un attribut, avec validation à l'écriture
  • Distinguer le rôle de __str__ (affichage humain) de celui de __repr__ (débogage)

Dans quel contexte ?

Un développeur modélise des coordonnées géographiques avec une classe Vecteur2D et veut pouvoir écrire distance_totale = v1 + v2 + v3 comme il le ferait avec des nombres, plutôt que v1.additionner(v2).additionner(v3). Implémenter __add__ permet exactement cela : Python appelle automatiquement cette méthode dès qu'il rencontre + entre deux instances de la classe, rendant le code aussi lisible qu'avec des types natifs.

Faire parler le langage de vos propres objets

Quand vous écrivez len(ma_liste) ou a + b, Python ne fait pas de magie : il appelle en coulisses une méthode spéciale de l'objet, respectivement __len__ et __add__. Ces méthodes, encadrées de doubles underscores (d'où leur surnom dunder, pour double underscore), forment un protocole que n'importe quelle classe personnalisée peut implémenter pour se comporter comme un type natif de Python.

Pourquoi ça vaut la peine de les définir

Sans ces méthodes, additionner deux objets Vecteur2D obligerait à écrire une méthode .additionner() explicite, moins naturelle à lire qu'un simple v1 + v2. En implémentant __add__, __eq__ ou __iter__, on rend ses propres classes aussi agréables à utiliser que les types intégrés du langage, tout en gardant un contrôle total sur ce que signifie réellement additionner deux objets de son domaine métier.

Piège fréquent

Définir __eq__ sans définir __hash__ rend l'objet non hashable : impossible de le mettre dans un set ou de l'utiliser comme clé de dict, avec une erreur TypeError: unhashable type qui peut surprendre bien après l'écriture de la classe.

Un couple à ne jamais séparer

Si une classe définit __eq__ (pour comparer deux instances avec ==), elle doit aussi définir __hash__, sinon Python considère l'objet comme non hashable et refuse de l'utiliser dans un set ou comme clé de dictionnaire. C'est une règle de cohérence : deux objets égaux doivent produire le même hash.

Un attribut qui exécute du code

Une propriété permet d'exposer une méthode comme si c'était un simple attribut, tout en gardant la possibilité de valider une valeur à l'écriture (@x.setter) ou de la calculer à la volée à la lecture, comme la conversion Celsius/Fahrenheit de cette leçon. C'est l'outil idéal pour ajouter de la validation sans casser le code existant qui accédait déjà à l'attribut directement.

DunderDéclenché parNécessite aussi
__eq__a == b__hash__ si l'objet doit rester hashable
__add__a + b__radd__ pour gérer nombre + a
__iter__for x in a, unpacking__next__ si l'objet est son propre itérateur
__getitem__a[i]Rien d'obligatoire

Astuce

Avant d'écrire un setter avec if valeur < 0: raise ValueError(...), vérifiez si @property est réellement nécessaire : si l'attribut n'a besoin d'aucune validation ni calcul, un simple attribut public suffit et évite du code superflu.

Commandes & code

Méthodes spéciales (dunder) et propriétés

Intégrer ses objets aux opérateurs et syntaxes natives de Python.

python
class Vecteur2D:
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Vecteur2D({self.x}, {self.y})"

    def __eq__(self, autre):
        if not isinstance(autre, Vecteur2D):
            return NotImplemented
        return self.x == autre.x and self.y == autre.y

    def __hash__(self):
        # necessaire si on veut mettre l'objet dans un set/dict et qu'on a defini __eq__
        return hash((self.x, self.y))

    def __add__(self, autre):
        return Vecteur2D(self.x + autre.x, self.y + autre.y)

    def __sub__(self, autre):
        return Vecteur2D(self.x - autre.x, self.y - autre.y)

    def __mul__(self, scalaire):
        return Vecteur2D(self.x * scalaire, self.y * scalaire)

    def __rmul__(self, scalaire):
        # permet aussi "2 * vecteur" (l'entier est a gauche, ne sait pas multiplier un vecteur)
        return self.__mul__(scalaire)

    def __neg__(self):
        return Vecteur2D(-self.x, -self.y)

    def __abs__(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5

    def __len__(self):
        return 2

    def __getitem__(self, index):
        return (self.x, self.y)[index]

    def __iter__(self):
        yield self.x
        yield self.y

    def __bool__(self):
        return abs(self) > 0

v1 = Vecteur2D(1, 2)
v2 = Vecteur2D(3, 4)
print(v1 + v2)          # Vecteur2D(4, 6)
print(v1 - v2)          # Vecteur2D(-2, -2)
print(v1 * 3)            # Vecteur2D(3, 6)
print(2 * v1)             # Vecteur2D(2, 4) via __rmul__
print(abs(v2))            # 5.0
print(len(v1))             # 2
print(v1[0], v1[1])         # 1 2
x, y = v1                    # deballage grace a __iter__
print(list(v1))               # [1, 2]
print(bool(Vecteur2D(0, 0)))   # False via __bool__

# --- Properties : accesseurs Pythoniques ---
class Temperature:
    def __init__(self, celsius: float = 0):
        self._celsius = celsius

    @property
    def celsius(self) -> float:
        return self._celsius

    @celsius.setter
    def celsius(self, valeur: float) -> None:
        if valeur < -273.15:
            raise ValueError("Temperature en dessous du zero absolu")
        self._celsius = valeur

    @property
    def fahrenheit(self) -> float:
        # propriete en lecture seule, calculee a la volee
        return self._celsius * 9 / 5 + 32

    @fahrenheit.setter
    def fahrenheit(self, valeur: float) -> None:
        self.celsius = (valeur - 32) * 5 / 9

t = Temperature(25)
print(t.celsius, t.fahrenheit)    # 25 77.0
t.fahrenheit = 98.6
print(round(t.celsius, 1))         # 37.0

try:
    t.celsius = -300
except ValueError as e:
    print(f"Refuse : {e}")

# __call__ : rendre une instance "appelable" comme une fonction
class Multiplicateur:
    def __init__(self, facteur):
        self.facteur = facteur

    def __call__(self, x):
        return x * self.facteur

doubler = Multiplicateur(2)
print(doubler(21))    # 42 -- l'instance se comporte comme une fonction

# __enter__/__exit__ : voir la lecon sur les context managers pour le detail
DunderDéclenché par
__init__Classe(...)
__repr__ / __str__repr(x) / str(x), print(x)
__eq__ / __hash__==, utilisation dans set/dict
__add__ / __radd__x + y
__len__ / __getitem__len(x), x[i]
__iter__for ... in x, unpacking
__call__x(...)

Résumé

  • Les méthodes dunder permettent d'intégrer un objet aux opérateurs natifs (+, ==, len, []...).
  • Si __eq__ est défini, __hash__ doit l'être aussi pour rester utilisable dans un set/dict.
  • @property expose une méthode comme un attribut, avec validation possible au setter.

Exercices pratiques

1 disponible
1

Mission : rendre une classe Argent utilisable comme un nombre

Objectif : Implémenter le protocole dunder d'une classe de montants monétaires pour l'intégrer aux opérateurs natifs, tout en respectant le couple __eq__/__hash__.

Contexte

Une classe Argent représente un montant avec une devise (Argent(100, "EUR")). Un développeur veut pouvoir écrire total = argent1 + argent2 et mettre des instances d'Argent dans un set pour dédupliquer des transactions identiques. Il a défini __eq__ mais son code plante avec TypeError: unhashable type: 'Argent' dès qu'il tente d'utiliser un set.

Tu dois compléter le protocole dunder de cette classe, comprendre pourquoi l'erreur survient, et exposer un montant en centimes via une propriété calculée.

Résoudre l’exercice →