Les méthodes spéciales (dunders)

Une classe peut déclarer des méthodes spéciales. Ces méthodes sont destinées à permettre à un objet d’avoir des comportements particuliers. Par exemple, comment faire pour que les instances de notre classe Vecteur puissent être utilisées avec des opérateurs arithmétiques ? Il suffit que la classe fournisse une implémentation pour une ou plusieurs méthodes spéciales. Ces méthodes sont facilement reconnaissables car leur nom est encadré par deux caractères soulignés (double underscores) comme par exemple __add__(). Cela a valu à ces méthodes leur surnom (non officiel) de dunder. La documentation Python y fait parfois référence sous le nom de magic methods.

Important

Si une méthode spéciale ne peut pas réaliser correctement son traitement à cause de la valeur ou du type d’un des paramètres qui lui sont transmis, elle doit retourner le mot-clé spécial NotImplemented ou produire une exception.

Méthodes spéciales élémentaires

Nous avons déjà traité d’une méthode spéciale : la méthode __init__() qui désigne le constructeur. Pour des usages plus avancés, on peut définir les méthodes __new__() et __del__() pour réaliser les traitements de création et de suppression des objets.

On peut également déclarer la méthode __repr__() qui doit retourner la chaîne de caractères correspondant à la représentation de l’objet. Cette méthode est appelée directement par la fonction repr(). C’est également cette méthode qui est utilisée par la console Python pour afficher un objet. Si nous reprenons comme exemple notre classe Vecteur du chapitre sur la programmation objet, nous pouvons proposer une implémentation pour la méthode __repr__().

import math

class Vecteur:

    __slots__ = ('x', 'y')

    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    @property
    def norme(self):
        return math.sqrt(self.x**2 + self.y**2)

    def calculer_produit_scalaire(self, v):
        return self.x * v.x + self.y * v.y

    def normaliser(self):
        norme = self.norme
        self.x /= norme
        self.y /= norme

    def __repr__(self):
        return f"Vecteur({self.x},{self.y})"
>>> v = Vecteur(5, 15)
>>> v
Vecteur(5,15)

La méthode __hash__() permet de produire un nombre de hachage. Ce nombre est nécessaire si votre objet doit être ajouté dans un ensemble ou comme clé d’un dictionnaire. Deux objets égaux doivent avoir le même nombre de hachage. Par contre l’inverse n’est pas nécessairement vrai. Le calcul d’un nombre de hachage n’est pas trivial car ce nombre est utilisé en interne par les ensembles et les dictionnaires pour garantir l’unicité des objets mais aussi pour assurer une bonne performance dans l’accès aux données. La fonction hash() peut vous aider à le calculer à partir des attributs d’un objet si ces derniers peuvent eux-mêmes produire une valeur de hachage. Il peut être intéressant de produire une valeur de hachage pour les instances de notre classe Vecteur.

import math

class Vecteur:

    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    @property
    def norme(self):
        return math.sqrt(self.x**2 + self.y**2)

    def calculer_produit_scalaire(self, v):
        return self.x * v.x + self.y * v.y

    def normaliser(self):
        norme = self.norme
        self.x /= norme
        self.y /= norme

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

    def __hash__(self):
        return hash((self.x, self.y))

De cette manière, une instance de Vecteur peut être ajoutée dans un ensemble.

>>> v1 = Vecteur(1, 2)
>>> v2 = Vecteur(5, 4)
>>> v3 = Vecteur(1, 2)
>>> ensemble_vecteurs = {v1, v2, v3}
>>> ensemble_vecteurs
{Vecteur(1,2), Vecteur(5,4)}

Prudence

La valeur de hachage est généralement dépendante de l’état interne de l’objet. Pour notre classe Vecteur, cette valeur est par exemple calculée à partir des valeurs des attributs x et y. Si ces valeurs changent, la valeur de hachage changera également. Hors, pour que les ensembles et les clés de dictionnaire fonctionnent correctement, la valeur de hachage ne doit plus changer à partir du moment où l’objet est ajouté dans un ensemble ou qu’il est utilisé comme clé dans un dictionnaire. Soit le développeur qui utilise les objets doit s’assurer que l’état de l’objet ne changera pas, soit le développeur de la classe doit s’assurer qu’une fois l’état de l’objet positionné, il n’est plus possible de le modifier. C’est cette dernière stratégie qui est la plus sûr et qui est utilisée dans l’API standard de Python. On parle alors d’immutabilité des objets ou de classe immutable. C’est pour cette raison que les classes int, float, bool, str et tuple sont immutables. Il n’est pas possible d’altérer l’état interne des objets une fois créés. Une opération de modification produit en fait un nouvel objet avec le nouvel état.

Pour notre exemple de la classe Vecteur, il faudrait réfléchir à deux fois à notre implémentation car la méthode normaliser() modifie l’état interne d’un objet de ce type. Soit il faut revoir l’implémentation de cette méthode afin qu’elle produise un nouvel objet, soit il faudra peut-être abandonner l’idée qu’un objet de type Vecteur produise une valeur de hachage.

Méthodes spéciales pour les conversions

Il est possible de réaliser des conversions lorsque l’objet est passé en paramètre de certaines fonctions. La conversion en valeur booléenne est également utilisée lorsqu’un objet doit être évalué comme expression booléenne dans une structure if ou while.

Méthode spéciale

fonction de conversion

__str__(self)

str

__bytes__(self)

bytes

__bool__(self)

bool ou expression booléenne

__int__(self)

int

__float__(self)

float

__complex__(self)

complex

__dict__(self)

dict

Pour notre classe Vector, en nous inspirant du fonctionnement des nombres en Python, nous pourrions considérer qu’un vecteur est évalué à True si ses coordonnées sont différentes de zéro. La conversion en chaîne de caractères est également utile.

class Vecteur:

    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    def __bool__(self):
        return self.x != 0 and self.y != 0

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

    # reste du code omis
v = Vecteur(0, 0)
if not v:
    print("évalué à False")
# Affiche évalué à False

v = Vecteur(5, 4)
if v:
    print("évalué à True")
# Affiche évalué à True

print(v)
# Affiche (5,4)

Méthodes spéciales pour les opérateurs unaires

Si les objets doivent pouvoir être utilisés avec les opérateurs unaires +, - ou s’ils peuvent être passés en paramètre de la fonction abs(), vous devez fournir respectivement une implémentation des méthodes __pos__(self), __neg__(self), __abs__(self).

class Vecteur:

    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

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

    # reste du code omis
>>> v = Vecteur(2, 3)
>>> -v
Vecteur(-2,-3)

Méthodes spéciales pour les opérations arithmétiques

Si les objets doivent pouvoir être utilisés dans des opérations arithmétiques, alors vous pouvez fournir une implémentation pour les méthodes suivantes :

Méthode spéciale

opérateur ou fonction

__add__(self, o)

+

__sub__(self, o)

-

__mul__(self, o)

*

__matmul__(self, o)

@

__truediv__(self, o)

/

__floordiv__(self, o)

//

__mod__(self, o)

%

__divmod__(self, o)

divmod()

__pow__(self, o, modulo)

** ou pow()

Ces méthodes prennent toutes en paramètres self et le deuxième opérande de l’opérateur arithmétique. __pow__(self, o, modulo) accepte un troisième paramètre optionnel correspondant à la valeur du modulo passée en paramètre de la fonction pow(). Ces méthodes doivent retourner le résultat de l’opération sauf __divmod__(self, o) qui doit retourner le résultat de la division et le reste pour être conforme à divmod(). Normalement, ces méthodes ne doivent pas modifier l’état interne de l’objet mais, au plus, produire un nouvel objet (comme par exemple pour le résultat de l’addition).

Pour notre classe Vecteur, nous pouvons, par exemple, autoriser l’addition de deux vecteurs ou d’un vecteur avec un scalaire.

class Vecteur:

    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    def __add__(self, v):
        if isinstance(v, (int, float)):
            return Vecteur(self.x + v, self.y + v)
        if isinstance(v, Vecteur):
            return Vecteur(self.x + v.x, self.y + v.y)
        return NotImplemented

    # reste du code omis
>>> v1 = Vecteur(2, 3)
>>> v1
Vecteur(2,3)
>>> v2 = v1 + 2
>>> v2
Vecteur(4,5)
>>> v3 = v1 + v2
>>> v3
Vecteur(6,8)

Note

Remarquez l’utilisation du mot-clé NotImplemented pour le cas où l’addition ne concerne ni un scalaire, ni un vecteur. Le fait de retourner ce mot-clé produira une erreur de type TypeError lorsque l’addition ne sera pas possible.

>>> v = Vecteur()
>>> v + "ceci n'est pas un nombre ni un Vecteur"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'Vecteur' and 'str'

La classe peut fournir une implémentation pour les méthodes dont le nom commence par un r (pour right). Ces méthodes seront appelées si l’objet est présent à droite d’un opérateur binaire et l’objet à gauche ne supporte pas l’opération.

Méthode

opérateur

__radd__(self, o)

+

__rsub__(self, o)

-

__rmul__(self, o)

*

__rmatmul__(self, o)

@

__rtruediv__(self, o)

/

__rfloordiv__(self, o)

//

__rmod__(self, o)

%

__rpow__(self, o, modulo)

**

Par exemple pour additionner un scalaire à un vecteur, il faut fournir une implémentation pour la méthode __radd__(self, o).

class Vecteur:

    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    def __add__(self, v):
        if isinstance(v, (int, float)):
            return Vecteur(self.x + v, self.y + v)
        elif isinstance(v, Vecteur):
            return Vecteur(self.x + v.x, self.y + v.y)
        else:
            return NotImplemented

    def __radd__(self, v):
        return self.__add__(v)

    # reste du code omis
>>> v = Vecteur(2, 3)
>>> 10 + v
Vecteur(12,13)

Pour notre exemple, l’implémentation de la méthode __radd__(self, o) se contente de retourner le résultat de l’appel à __add__(self, o) puisque l’addition est commutative.

Enfin, la classe peut fournir une implémentation pour les opérateurs in-place. Il s’agit des opérateurs qui réalisent une opération et une affectation dans le même temps.

Méthode

opérateur

__iadd__(self, o)

+=

__isub__(self, o)

-=

__imul__(self, o)

*=

__imatmul__(self, o)

@=

__itruediv__(self, o)

/=

__ifloordiv__(self, o)

//=

__imod__(self, o)

%=

__ipow__(self, o, modulo)

**=

Généralement, l’implémentation de ces méthodes permet simplement une optimisation. Si ces méthodes ne sont pas présentes alors l’interpréteur appelle la méthode arithmétique équivalente et affecte le résultat à la variable. Ainsi, si l’appel à la méthode __iadd__(self, o) produit un résultat NotImplemented, l’instruction

a += b

sera réalisée par l’interpréteur sous la forme :

a = a.__add__(b)

Comme on s’attend généralement à ce que les opérateurs in-place modifient directement l’objet, fournir une implémentation pour ces opérateurs évite de créer un objet intermédiaire.

class Vecteur:

    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    def __iadd__(self, v):
        if isinstance(v, (int, float)):
            self.x += v
            self.y += v
        elif isinstance(v, Vecteur):
            self.x += v.x
            self.y += v.y
        else:
            return NotImplemented
        return self

    # reste du code omis
>>> v1 = Vecteur(2, 3)
>>> v2 = Vecteur(10, 50)
>>> v1 += v2
>>> v1
Vecteur(12,53)

Méthodes spéciales pour la comparaison

Par défaut, l’opérateur d’égalité == permet de comparer l’unicité en mémoire des objets. Ainsi les deux vecteurs ci-dessous ne sont pas égaux :

>>> v1 = Vecteur(1, 1)
>>> v2 = Vecteur(1, 1)
>>> v1 == v2
False

En effet, nous créons deux objets distincts que nous affectons respectivement à la variable v1 et à la variable v2.

Mais il serait plus intéressant de considérer que deux vecteurs sont égaux s’ils ont les mêmes valeurs pour x et pour y. Nous pouvons modifier ce comportement par défaut en fournissant notre propre méthode d’égalité :

class Vecteur:

    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    def __eq__(self, v):
        if isinstance(v, Vecteur):
            return self.x == v.x and self.y == v.y
        return False

    # reste du code omis
>>> v1 = Vecteur(1, 1)
>>> v2 = Vecteur(1, 1)
>>> v1 == v2
True

Important

En Python, si vous désirez vérifier s’il s’agit ou non du même objet en mémoire, il faut utiliser le mot clé is. Ainsi :

>>> v1 = Vecteur(1, 1)
>>> v2 = Vecteur(1, 1)
>>> v1 is v1
True
>>> v1 is v2
False

Ci-dessous, la liste des méthodes spéciales pour les opérateurs de comparaison :

Méthode spéciale

Opérateur de comparaison

__eq__(self, v)

o == v

__ne__(self, v)

o != v

__lt__(self, v)

o < v

__le__(self, v)

o <= v

__gt__(self, v)

o > v

__ge__(self, v)

o >= v

Note

S’il n’existe pas d’implémentation pour la méthode __ne__(self, v), alors l’interpréteur qui tente de résoudre l’instruction :

o != v

appellera à la place :

not o.__eq__(v)

Astuce

Si vous souhaitez que les séquences de vos objets soient triables grâce à la fonction sorted(), vous devez au minimum fournir une implémentation pour les méthodes __eq__(self, v) et __lt__(self, v).

Méthodes spéciales pour les conteneurs

Si vos objets doivent se comporter comme un conteneur (c’est-à-dire comme une liste ou un dictionnaire), vous pouvez fournir l’implémentation de méthodes spéciales telles que :

Méthode spéciale

Cas d’utilisation

__len__(self)

utilisation de la méthode len()

__getitem__(self, key)

o[key]

__setitem__(self, key, value)

o[key] = value

__delitem__(self, key)

del o[key]

__contains__(self, key)

key in o

class Vecteur:

    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    def __len__(self):
        return 2

    def __getitem__(self, k):
        if k == 'x' or k == 0:
            return self.x
        if k == 'y' or k == 1:
            return self.y
        raise KeyError(k)

    def __setitem__(self, k, v):
        if not isinstance(v, (int, float)):
            raise TypeError
        if k == 'x' or k == 0:
            self.x = v
        elif k == 'y' or k == 1:
            self.y = v
        else:
            raise KeyError(k)

    # reste du code omis
>>> v = Vecteur(2, 5)
>>> len(v)
2
>>> v['x']
2
>>> v[0]
2
>>> v['y']
5
>>> v[1]
5
>>> v[0] = -2
>>> v[1] = -5
>>> v
Vecteur(-2,-5)

Tout est objet !

En découvrant les méthodes spéciales en Python, on se rend compte que les opérateurs arithmétiques ne sont que des raccourcis d’écriture pour les méthodes telles que __add__(self, o) ou __sub__(self, o). Il en va de même pour les opérateurs [] ou même les opérations booléennes.

En fait en Python, tout est un objet ! Les nombres sont des objets et il est donc possible d’écrire :

>>> a = 1
>>> a.__add__(2)
3

Les objets représentant les entiers ont même des méthodes propres comme bit_length() pour connaître la taille en bits nécessaire pour stocker la valeur.

>>> a = 1
>>> a.bit_length()
1
>>> a = 1000000
>>> a.bit_length()
20

int, float, complex, bool, str, tuple, list, set, dict ne sont pas des fonctions mais les classes de ces différents types. Il est donc possible de créer des classes en héritant de ces types. Les classes int, float, complex et bool héritent même d’une classe commune : la classe numbers.Number.

>>> from numbers import Number
>>> isinstance(1, Number)
True
>>> isinstance(2.5, Number)
True
>>> isinstance(1j, Number)
True
>>> isinstance(True, Number)
True

Les fonctions comme objet

En Python, une fonction (et une méthode) est aussi un objet de type function. Il existe une seule instance d’une fonction et on peut utiliser son nom pour accéder à ses attributs.

def compter():
    compter._cpt = getattr(compter, "_cpt", 0) + 1
    return compter._cpt

Dans l’exemple ci-dessus, la fonction compter() mémorise une valeur dans son attribut privé _cpt.

>>> print(compter())
1
>>> print(compter())
2
>>> print(compter())
3
>>> print(compter())
4

De même un objet peut se comporter comme une fonction. Pour cela, il suffit d’implémenter la méthode spéciale __call__(self, args).

from numbers import Number


class Step:
    """Permet de réaliser un incrément ou un décrément d'une valeur"""

    @staticmethod
    def inc(step=1):
        return Step(step)

    @staticmethod
    def dec(step=1):
        return Step(-step)

    def __init__(self, step):
        self._step = step

    def __call__(self, valeur):
        if not isinstance(valeur, Number):
            raise TypeError
        valeur += self._step
        return valeur
>>> inc = Step.inc()
>>> inc(1)
2
>>> dec = Step.dec()
>>> dec(3)
2

La fonction callable() permet de découvrir sur une variable ou un paramètre représente un type appelable, c’est-à-dire une fonction ou un objet disposant de la méthode __call__(self, args).

>>> callable(print)
True

Note

Comme un objet peut se comporter comme une fonction, alors il est possible d’utiliser une classe également comme un décorateur.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class Traceur:

    def __init__(self, func, msg_debut="Appel", msg_fin="Fin appel"):
        self.func = func
        self.msg_debut = msg_debut
        self.msg_fin = msg_fin

    def __call__(self, *args, **kwargs):
        print(f"{self.msg_debut} {self.func.__name__} args={args} kwargs={kwargs}")
        try:
            return self.func(*args, **kwargs)
        finally:
            print(f"{self.msg_fin} {self.func.__name__}")


@Traceur
def say_hello(nom):
    print(f"Hello {nom}")
>>> say_hello("David")
Appel say_hello args=('David',) kwargs={}
Hello David
Fin appel say_hello
>>> say_hello(nom="David")
Appel say_hello args=() kwargs={'nom': 'David'}
Hello David
Fin appel say_hello

Les classes comme objet

Les classes sont elles-mêmes des objets qui héritent de type. Par exemple, une classe possède un attribut __name__ qui contient le nom de la classe. Ses attributs sont également composés des méthodes et des attributs de classe. Il est donc possible de traiter une classe comme n’importe quel objet dans un programme. On dit que Python est un langage réflexif car les éléments constitutifs du langage sont manipulables comme n’importe quel objet.

Si on crée la classe Greeting :

class Greeting:

    def say_hello(self, name):
        print(f"Hello {name}")

On peut bien sûr écrire :

>>> g = Greeting()
>>> g.say_hello("David")
Hello David

Mais on peut également traiter la classe comme un objet :

>>> g = Greeting()
>>> cls = type(g)
>>> cls.__name__
'Greeting'
>>> f = getattr(cls, "say_hello")
>>> f(g, "David")
Hello David

Les bibliothèques et les frameworks les plus avancés en Python peuvent ainsi découvrir dynamiquement la structure des objets et des classes au moment de l’exécution du programme et peuvent donc manipuler des types de données qui n’étaient pas connus au moment de l’écriture de ces bibliothèques et de ces frameworks.

Et bien d’autres méthodes spéciales…

Il existe encore d’autres méthodes spéciales pour la réflexivité ou encore pour permettre de réaliser des arrondis ou des opérations binaires. Pour la liste complète, vous pouvez consulter la documentation officielle du Data model. Le chapitre suivant présente les méthodes spéciales pour les itérateurs.

Ci-dessous, vous trouverez l’implémentation complète de la classe Vecteur :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
import math


class Vecteur:
    """Un vecteur à deux dimensions"""

    __slots__ = ('x', 'y')

    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    @property
    def norme(self):
        return math.sqrt(self.x**2 + self.y**2)

    def calculer_produit_scalaire(self, v):
        return self.x * v.x + self.y * v.y

    def normaliser(self):
        norme = self.norme
        self.x /= norme
        self.y /= norme

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

    def __bool__(self):
        return self.x != 0 and self.y != 0

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

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

    def __add__(self, v):
        if isinstance(v, (int, float)):
            return Vecteur(self.x + v, self.y + v)
        if isinstance(v, Vecteur):
            return Vecteur(self.x + v.x, self.y + v.y)
        return NotImplemented

    def __radd__(self, v):
        return self.__add__(v)

    def __iadd__(self, v):
        if isinstance(v, (int, float)):
            self.x += v
            self.y += v
        elif isinstance(v, Vecteur):
            self.x += v.x
            self.y += v.y
        else:
            return NotImplemented
        return self

    def __eq__(self, v):
        if isinstance(v, Vecteur):
            return self.x == v.x and self.y == v.y
        return False

    def __len__(self):
        return 2

    def __getitem__(self, k):
        if k == 'x' or k == 0:
            return self.x
        if k == 'y' or k == 1:
            return self.y
        raise KeyError(k)

    def __setitem__(self, k, v):
        if not isinstance(v, (int, float)):
            raise TypeError
        if k == 'x' or k == 0:
            self.x = v
        elif k == 'y' or k == 1:
            self.y = v
        else:
            raise KeyError(k)

Méthodes spéciales et classes abstraites

Beaucoup de méthodes abstraites n’ont de sens que lorsqu’elles sont implémentées ensemble par la même classe. Par exemple les méthodes __len__(self) __getitem__(self, key) permettent de définir une séquence puisqu’il est possible de connaître la taille et l’élément associé à une clé.

Le module container.abc fournit des classes abstraites qui définissent différents contrats. Il existe par exemple la classe abstraite Sequence qui déclare les deux méthodes de manière abstraite.

>>> from collections.abc import Sequence
>>> s = Sequence()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class Sequence with abstract methods __getitem__, __len__

Toutes les classes du module colletions.abc sont des classes abstraites qui sont là pour guider le développeur qui voudrait créer sa propre classe et qui souhaiterait que les objets de cette classe se comporte suivant un contrat. En hérite d’une des classes du module colletions.abc, cela permet au développeur de vérifier que son implémentation est conforme au contrat.