Outils pour utilisateurs

Outils du site


cours:informatique:fun_mooc:python3_uca_inria:620_methode_speciale

Python: les méthodes spéciales

Nous avons vu précédemment que nous pouvions définir des méthodes sur les classes. C'est quelque chose de tout à fait classique, les classes définissent en général un certain nombre de méthodes que l'on utilise pour manipuler les attributs des instances. Cependant, nous avons également expliqué que, en Python, une caractéristique des classes, c'est qu'on peut créer nos objets qui se manipulent exactement comme des types built-in.

Vous pouvez tout à fait créer une classe `Phrase` que vous allez initialiser comme vous initialiseriez, par exemple, une liste, directement à la construction de l'instance.

  • Vous pourriez également obtenir le nombre de mots directement avec la fonction *built-in* len();
  • Faire un test d'appartenance directement avec l'instruction in;
  • Accéder par exemple au troisième mot avec la notation crochets comme on le ferait avec une liste;
  • Faire un print() directement sur votre instance pour afficher par exemple la liste des mots affichés en colonne;
  • Ou encore, si vous avez deux objets `Phrase`, pouvoir les concaténer simplement avec l'opérateur '+'.

Toutes ces opérations peuvent être implémentées sur votre propre classe 'Phrase'. La manière d'implémenter cela en Python est par l'intermédiaire de ce que l'on appelle des méthodes spéciales.

Les méthodes spéciales commencent toutes par un double underscore et finissent toutes par un double underscore, et sont appelées automatiquement lorsque l'on utilise par exemple une fonction *built-in*, un opérateur comme une addition ou alors une instruction comme le test d'appartenance avec in. Notamment, le test d'appartenance correspond à la méthode qui s'écrit __contains__().

Le premier comportement que l'on implémente pour toutes les classes est l'initialisation de l'instance. I est naturel lorsque l'on crée une instance, que l' instance puisse être initialisée avec un certain nombre d'attributs déjà prédéfinis. Donc la manière de le faire, en Python, c'est de définir une méthode spéciale qui s'appelle __init__().

Cet initialisateur, qu'on appelle parfois par abus de langage un constructeur mais qui n'est pas vraiment un constructeur, qui est juste quelque chose qui initialise mon instance une fois qu'elle a été créée, me permet de créer des attributs automatiquement avec une certaine valeur par défaut lorsque je crée mon instance. Donc ça, c'est le premier comportement que j'ai implémenté avec cette méthode spéciale __init__().

Si on souhaite obtenir le nombre de mots avec la fonction built-in len() exactement comme on le ferait avec un type built-in, on peut implémenter une méthode spéciale qui va s'appeler __len__(). Cette méthode `len()` doit retourner un entier qui va correspondre à la taille de l'objet (pour notre exemple Phrase, le nombre de mots qu'elle contient).

Un autre comportement classique que l'on pourrait vouloir implémenter, c'est par exemple le test d'appartenance. Ça serait extrêmement pratique de pouvoir déterminer, par exemple, si le mot 'mooc' est dans l' instance? Là encore, on s'appuie sur une méthode spéciale dédiée qui s'appelle __contains__(). On définit __contains__() qui prend comme premier argument “self”, toujours mon instance, et comme deuxième argument le mot sur lequel je veux faire le test. Cette méthode doit retourner un booléen qui vaut Vrai si mot est dans l'instance, ou Faux sinon.

Une dernière méthode très courante, qu'on implémente très souvent pour les classes, c'est la méthode qui permet de supporter la fonction *built-in* print(). Par défaut si on appelle print() avec l'instance en argument on voit s'afficher l'adresse de l'objet, ce qui a assez peu d'intérêt. Or, si on veut avoir une manière d'afficher ce que contient l' instance, qui peut être utile en cours d'exécution du programme mais également si on veut débugger le programme, on doit implémenter une méthode qui s'appelle __str__() qui doit retourner une chaîne de caractères.

Nous venons de détailler quelques méthodes spéciales usuelles et nous avons vu que ces méthodes spéciales permettent de créer vos propres classes qui se comportent comme des types *built-in*. C'est donc extrêmement souple, extrêmement puissant. Nous n' avons abordé ici qu'un petit sous-ensemble des méthodes spéciales mais il existe autour d'une centaine de méthodes spéciales. Il faut savoir également que ces méthodes spéciales peuvent constituer ce que l'on appelle un protocole, notamment le protocole d'itération, ou le protocole de context manager que nous aborderons prochainement.

Ensembles contenant des instances

Lors de la séquence consacrée au dictionnaires Nous avions vu que, pour les types built-in, les clés (dans les dictionnaires ou les éléments dans un ensemble) devaient être des objets immuables et même globalement immuables. Nous allons voir ici quelles sont les règles qui s'appliquent aux instances de classe

Instance mutable dans un ensemble

Une instance de classe est par défaut un objet mutable. Malgré cela, le langage vous permet d'insérer une instance dans un ensemble (ou de l'utiliser comme clé dans un dictionnaire). Nous allons voir ce mécanisme en action.

Hachage par défaut: basé sur id()

Les ensembles et les dictionnaires s'appuie sur le hachage pour stocker leurs éléments. La fonction de hachage sur une instance ne dépend par défaut que de la valeur retournée par la fonction built-in id() appliquée sur cet objet. Deux objets distincts au sens de id() sont considérés comme différents, et donc peuvent coexister dans un ensemble (ou dans un dictionnaire) même si les valeurs de leurs attributs respectifs sont identiques:

Pour l'exemple on définit une classe Point1

class Point1:
    def __init__(self, x, y):
        self.x = x
        self.y = y
 
    def __repr__(self):
        return f"Point1[{self.x}, {self.y}]"

Avec ce code, les instances de Point sont mutables:

>>> p1 = Point1(2, 2)
 
>>> p2 = Point1(2, 3)
 
# L'objet p1 est mutable, la valeur de son attribut est modifiée
>>> p1.y = 3
 
# les deux objets se ressemblent, les valeurs de leurs
# attributs sont identiques
>>> p1, p2
(Point1[2, 3], Point1[2, 3])
 
# mais peuvent coexister dans un ensemble
# qui a alors 2 éléments
>>> s = { p1, p2 }
 
>>> len(s)
2
 
>>> p1 in s
True
 
# mais pas une troisième instance qui pourtant 
# contient des attributs identiques à p1 et p2
>>> p3 = Point1(2, 3)
 
>>> p3 in s
False

Cette possibilité de gérer des ensembles d'objets selon cette stratégie est très commode et peut apporter de gros gains de performance, notamment lorsqu'on a souvent besoin de faire des tests d'appartenance. Pour rappel les ensembles et les dictionnaires ne sont pas ordonnés et s'appuient sur le hachage pour un accès beaucoup plus rapide aux éléments.

En pratique, lorsqu'un modèle de données définit une relation de type “1-n”, il est recommandé d'envisager d'utiliser un ensemble plutôt qu'une liste.

Par exemple envisagez:

class Animal:
    # blabla
    pass
 
class Zoo:
    def __init__(self):
        self.animals = set()

Plutot que:

class Animal:
    # blabla
    pass
 
class Zoo:
    def __init__(self):
        self.animals = []

Le comportement introduit ici pour les instances de Point1 dans les tables de hachage est raisonnable, si on admet que deux points ne sont égaux que s'ils sont le même objet au sens de is.

Mais imaginons que vous voulez au contraire considérer que deux points son égaux lorsqu'ils coincident sur le plan. Avec ce modèle de données, vous voudriez que :

  • Un ensemble dans lequel on insère p1 et p2 ne contienne qu'un élément;
  • Et qu'on trouve p3 quand on le cherche dans cet ensemble.

Protocole hashable: __hash__ et __eq__

Le langage nous permet de faire cela, grâce au protocole hashable; pour cela il nous faut définir deux méthodes :

  • __eq__() qui, sans grande surprise, va servir à évaluer p == q;
  • __hash__() qui va retourner la clé de hachage sur un objet.

La subtilité étant bien entendu que ces deux méthodes doivent être cohérentes, si deux objets sont égaux, il faut que leurs hashs soient égaux ; de bon sens, si l'égalité se base sur nos deux attributs x et y, il faudra bien entendu que la fonction de hachage utilise elle aussi ces deux attributs. Voir la documentation de __hash__.

class Point2(Point1):
 
    # l'égalité va se baser naturellement sur x et y
    def __eq__(self, other):
        return self.x == other.x and self.y == other.y
 
    # du coup la fonction de hachage 
    # dépend aussi de x et de y
    def __hash__(self):
        return (11 * self.x + self.y) // 16

A présent les deux objets sont distincts pour id()/is, mais égaux pour == :

>>> q1 = Point2(2, 3)
>>> q2 = Point2(2, 3)
 
>>> print(f"is → {q1 is q2} \n== → {q1 == q2}")
isFalse 
==True
 
# A présent un ensemble auquel on tente d'ajouter deux points
# ayant les même attributs n'en contiendra qu'un
>>> s = {q1, q2}
 
>>> len(s)
1
 
>>> q3 = Point2(2, 3)
 
>>> q3 in s
True

Comme les ensembles et les dictionnaires reposent sur le même mécanisme de table de hachage, on peut aussi indifféremment utiliser n'importe lequel de nos 3 points pour indexer un dictionnaire:

>>> d = {}
 
>>> d[q1] = 1
 
>>> d[q2]
1
 
# les clés q1, q2 et q3 sont les mêmes pour le dictionnaire
>>> d[q3] = 10000
 
>>> d
{Point1[2, 3]: 10000}

Avertissement

Tout ceci semble très bien fonctionner; sauf qu'en fait, il y a une grosse faille, c'est que nos objets Point2 sont mutables. Du coup on peut maintenant imaginer un scénario comme celui-ci:

# définit deux instances équivalentes de type Point2
>>> t1, t2 = Point2(10, 10), Point2(10, 10)
 
# L'ensemble s accepte une seule instance
>>> s = {t1, t2}
 
>>> s
{Point1[10, 10]}
 
>>> t1 in s, t2 in s
(True, True)

Mais si on modifie la valeur

# La modification de la valeur de l'attribut x a un 
# impact sur le calcul du hash
>>> t1.x = 100
 
>>> s
{Point1[100, 10]}
 
>>> t1 in s
False
 
>>> t2 in s
False

Évidemment cela n'est pas correct. Ce qui se passe ici c'est qu'on a:

  • D'abord inséré t1 dans s, avec un indice de hachage calculé à partir de 10, 10
  • Pas inséré t2 dans s parce qu'on a déterminé qu'il existait déjà.

Après avoir modifié t1 qui est le seul élément de s: À ce stade:

  • Lorsqu'on cherche t1 dans s, on le fait avec un indice de hachage calculé à partir de 100, 10 et du coup on ne le trouve pas;
  • Lorsqu'on cherche t2 dans s, on utilise le bon indice de hachage, mais ensuite le seul élément qui pourrait faire l'affaire n'est pas égal à t2.

La documentation de python indique bien:

If a class defines mutable objects and implements an __eq__() method, it should not implement __hash__(), since the implementation of hashable collections requires that a key’s hash value is immutable (if the object’s hash value changes, it will be in the wrong hash bucket).

Notre classe Point2 illustre bien cette limitation. Pour qu'elle soit utilisable en pratique, il faut rendre ses instances imuables. Cela peut se faire de plusieurs façons entre autres:

  • le namedtuple;
  • et la dataclass (nouveau en 3.7).

Surcharge des opérateurs

On illustre ici certaines des possibilités de surcharge d'opérateurs, ou plus généralement les mécanismes disponibles pour étendre le langage et donner un sens à des fragments de code comme :

  • objet1 + objet2
  • item in objet
  • objet[key]
  • objet.key
  • for i in objet:
  • if objet:
  • objet(arg1, arg2) (et non pas classe(arg1, arg2))
  • etc..

que jusqu'ici, sauf pour la boucle for et pour le hachage, on n'a expliqués que pour des objets de type prédéfini.

Le mécanisme général pour cela consiste à définir des méthodes spéciales, avec un nom en __nom__. Il existe un total de près de 80 méthodes dans ce système de surcharges, aussi il n'est pas question ici d'être exhaustif. Vous trouverez dans ce document une liste complète de ces possibilités.

Il nous faut également signaler que les mécanismes mis en jeu ici sont de difficultés assez variables. Dans le cas le plus simple il suffit de définir une méthode sur la classe pour obtenir le résultat (par exemple, définir __call__ pour rendre un objet callable). Mais parfois on parle d'un ensemble de méthodes qui doivent être cohérentes, voyez par exemple les descriptors qui mettent en jeu les méthodes __get__, __set__ et __delete__, et qui peuvent sembler particulièrement cryptiques. On aura d'ailleurs l'occasion d'approfondir les descriptors en section 9 avec les sujets avancés.

Nous vous conseillons de commencer par des choses simples, et surtout de n'utiliser ces techniques que lorsqu'elles apportent vraiment quelque chose. Le constructeur et l'affichage sont pratiquement toujours définis, mais pour tout le reste il convient d'utiliser ces traits avec le plus grand discernement. Dans tous les cas écrivez votre code avec la documentation sous les yeux, c'est plus prudent :)

Affichage via __repr__ et __str__

Nous commençons par signaler la méthode __repr__ qui est assez voisine de __str__, et qui donc doit retourner un objet de type chaîne de caractères, sauf que:

  • __str__ est utilisée par print (affichage orienté utilisateur du programme, priorité au confort visuel);
  • Alors que __repr__ est utilisée par la fonction repr() (affichage orienté programmeur, aussi peu ambigu que possible);
  • Enfin il faut savoir que __repr__ est utilisée aussi par print si __str__ n'est pas définie.

Pour cette dernière raison, on trouve dans la nature __repr__ plutôt plus souvent que __str__.

La fonction repr() est utilisée massivement dans les informations de debugging comme les traces de pile lorsqu'une exception est levée. Elle est aussi utilisée lorsque vous affichez un objet sans passer par print, c'est-à-dire par exemple dans l'interpréteur:

>>> class Foo:
...     pass
 
>>> f = Foo()
 
# lorsque vous affichez un objet comme ceci
>>> f
<__main__.Foo at 0x7f0dfc3644c0>
# en fait vous utilisez repr()
Les f-strings (ou format) utilisent également la méthode spéciale __str__.

__bool__

Vous vous souvenez que la condition d'un test dans un if peut ne pas retourner un booléen (confère wiki Tests et opérateurs booléens). Nous avions noté que pour les types prédéfinis, sont considérés comme faux les objets : None, la liste vide, un tuple vide, etc.

Avec __bool__ on peut redéfinir le comportement des objets d'une classe vis-à-vis des conditions, ou si l'on préfère, quel doit être le résultat de bool(instance).

Attention pour éviter les comportements imprévus, comme on est en train de redéfinir le comportement des conditions, il faut renvoyer un booléen (ou à la rigueur 0 ou 1), on ne peut pas dans ce contexte retourner d'autres types d'objet.

Nous allons illustrer cette méthode dans un petit moment avec une nouvelle implémentation de la classe Matrix2.

Remarquez enfin qu'en l'absence de méthode __bool__, on cherche aussi la méthode __len__ pour déterminer le résultat du test; Une instance de longueur nulle est alors considéré comme False, en cohérence avec ce qui se passe avec les types built-in list, dict, tuple, etc.

Ce genre de protocole, qui cherche d'abord une méthode (__bool__), puis une autre (__len__) en cas d'absence de la première, est relativement fréquent dans la mécanique de surcharge des opérateurs. C'est entre autres pourquoi la documentation est indispensable lorsqu'on surcharge les opérateurs.

Opérateurs arithmétiques

On peut également redéfinir les opérateurs arithmétiques et logiques via les méthode spéciales __add__ et apparentés: __mul__, __sub__, __div__, __and__, etc.

Le fait d'avoir défini l'addition sur la classe via la méthode spéciale __add__ nous permet par exemple de bénéficier de la fonction built-in sum(). En effet le code de sum() fait lui-même des additions.

redéfinir un ordre

Il est possible également de redéfinir les opérateurs __eq__, __ne__, __lt__, __le__, __gt__ __ge__, de redéfinir un ordre sur les instances d'une classe.

Signalons à cet égard qu'il existe un mécanisme “intelligent” qui permet de définir un ordre à partir d'un sous-ensemble seulement de ces méthodes, l'idée étant que si vous savez faire > et =, vous savez sûrement faire tout le reste. Ce mécanisme est documenté ici ; il repose sur un décorateur (@total_ordering), un mécanisme que nous étudierons en semaine 9, mais que vous pouvez utiliser dès à présent.

De manière analogue à sum qui fonctionne sur une liste d' objets ayant redéfini __add__, si on avait défini un ordre sur ces objets, on aurait pu alors utiliser les fonctions built-in min() et max() pour calculer une borne supérieure ou inférieure dans une séquence d'objets de ce type.

Accès séquentiel (via un index entier)

La méthode __contains__ permet de donner une sens au code:

>>> item in object

Sans grande surprise, elle prend en argument un objet et un item, et doit renvoyer un booléen.

Lorsqu'on a implémenter la notion de longueur de l'objet avec __len__, il peut être opportun ( quoique cela n'est pas imposé par le langage ) de proposer également un accès indexé par un entier pour pouvoir faire :

>>> objet[1]

On utilisera alors la méthode spéciale __getitem__. Son implémentation pourra prévoir un accès par slice. Avec seulement __getitem__, on peut faire une boucle sur l'objet queue. On l'a mentionné rapidement dans la séquence sur les itérateurs, mais la méthode __iter__ n'est pas la seule façon de rendre un objet itérable.

__call__ et les callables

Le langage introduit de manière similaire la notion de callable, littéralement: qui peut être appelé. L'idée est très simple, on cherche à donner un sens à un fragment de code du genre de:

# on crée une instance
>>> objet = Classe(arguments)

Et c'est l'objet (Attention : l'objet, pas la classe) qu'on utilise comme une fonction

>>> objet(arg1, arg2)

Le protocole ici est très simple ; cette dernière ligne a un sens en Python dès lors que :

  • objet possède une méthode call;
  • Et que celle-ci peut être envoyée à objet avec les arguments arg1, arg2;
  • Et c'est ce résultat qui sera alors retourné par objet(arg1, arg2).
objet(arg1, arg2) ⟺ objet.__call__(arg1, arg2)
cours/informatique/fun_mooc/python3_uca_inria/620_methode_speciale.txt · Dernière modification : 2021/05/29 18:13 de 77.192.232.26