Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Licence CC BY-NC-ND Thierry Parmentelat & Arnaud Legout Inria - UCA

Complément - niveau avancé

Nous poursuivons dans ce complément la sélection de méthodes spéciales entreprise en première partie.


__contains__, __len__, __getitem__ et apparentés

La méthode __contains__ permet de donner un sens à :

item in objet

Sans grande surprise, elle prend en argument un objet et un item, et doit renvoyer un booléen. Nous l’illustrons ci-dessous avec la classe DualQueue.

La méthode __len__ est utilisée par la fonction built-in len pour retourner la longueur d’un objet.

La classe DualQueue

Nous allons illustrer ceci avec un exemple de classe, un peu artificiel, qui implémente une queue de type FIFO. Les objets sont d’abord admis dans la file d’entrée (add_input), puis déplacés dans la file de sortie (move_input_to_output), et enfin sortis (emit_output).

Clairement, cet exemple est à but uniquement pédagogique ; on veut montrer comment une implémentation qui repose sur deux listes séparées peut donner l’illusion d’une continuité, et se présenter comme un container unique. De plus cette implémentation ne fait aucun contrôle pour ne pas obscurcir le code.

class DualQueue:
    """Une double file d'attente FIFO"""

    def __init__(self):
        "constructeur, sans argument"
        self.inputs = []
        self.outputs = []

    def __repr__ (self):
        "affichage"
        return f"<DualQueue, inputs={self.inputs}, outputs={self.outputs}>"

    # la partie qui nous intéresse ici
    def __contains__(self, item):
        "appartenance d'un objet à la queue"
        return item in self.inputs or item in self.outputs
    
    def __len__(self):
        "longueur de la queue"
        return len(self.inputs) + len(self.outputs)        

    # l'interface publique de la classe
    # le plus simple possible et sans aucun contrôle
    def add_input(self, item):
        "faire entrer un objet dans la queue d'entrée"
        self.inputs.insert(0, item)
        
    def move_input_to_output (self):
        """
        l'objet le plus ancien de la queue d'entrée
        est promu dans la queue de sortie
        """
        self.outputs.insert(0, self.inputs.pop())
        
    def emit_output (self):
        "l'objet le plus ancien de la queue de sortie est émis"
        return self.outputs.pop()
# on construit une instance pour nos essais
queue = DualQueue()
queue.add_input('zero')
queue.add_input('un')
queue.move_input_to_output()
queue.move_input_to_output()
queue.add_input('deux')
queue.add_input('trois')

print(queue)
<DualQueue, inputs=['trois', 'deux'], outputs=['un', 'zero']>

Longueur et appartenance

Avec cette première version de la classe DualQueue on peut utiliser len et le test d’appartenance :

print(f'len() = {len(queue)}')

print(f"deux appartient-il ? {'deux' in queue}")
print(f"1 appartient-il ? {1 in queue}")
The history saving thread hit an unexpected error (OperationalError('attempt to write a readonly database')).History will not be written to the database.len() = 4
deux appartient-il ? True
1 appartient-il ? False

Accès séquentiel (accès par un index entier)

Lorsqu’on a la notion de longueur de l’objet avec __len__, il peut être opportun - quoique cela n’est pas imposé par le langage, comme on vient de le voir - de proposer également un accès indexé par un entier pour pouvoir faire :

queue[1]

Pour ne pas répéter tout le code de la classe, nous allons étendre DualQueue ; pour cela nous définissons une fonction, que nous affectons ensuite à DualQueue.__getitem__, comme nous avons déjà eu l’occasion de le faire :

# une première version de DualQueue.__getitem__
# pour uniquement l'accès par index

# on définit une fonction
def dual_queue_getitem (self, index):
    "redéfinit l'accès [] séquentiel"

    # on vérifie que l'index a un sens
    if not (0 <= index < len(self)):
        raise IndexError(f"Mauvais indice {index} pour DualQueue")
    # on décide que l'index 0 correspond à l'élément le plus ancien
    # ce qui oblige à une petite gymnastique
    li = len(self.inputs)
    lo = len(self.outputs)
    if index < lo:
        return self.outputs[lo - index - 1]
    else:
        return self.inputs[li - (index-lo) - 1]

# et on affecte cette fonction à l'intérieur de la classe
DualQueue.__getitem__ = dual_queue_getitem

À présent, on peut accéder aux objets de la queue séquentiellement :

print(queue[0])
zero

ce qui lève la même exception qu’avec une vraie liste si on utilise un mauvais index :

try:
    print(queue[5])
except IndexError as e:
    print('ERREUR',e)
ERREUR Mauvais indice 5 pour DualQueue

Amélioration : accès par slice

Si on veut aussi supporter l’accès par slice comme ceci :

queue[1:3]

il nous faut modifier la méthode __getitem__.

Le second argument de __getitem__ correspond naturellement au contenu des crochets [], on utilise donc isinstance pour écrire un code qui s’adapte au type d’indexation, comme ceci :

# une deuxième version de DualQueue.__getitem__
# pour l'accès par index et/ou par slice

def dual_queue_getitem (self, key):
    "redéfinit l'accès par [] pour entiers, slices, et autres"

    # l'accès par slice queue[1:3] 
    # nous donne pour key un objet de type slice
    if isinstance(key, slice):
        # key.indices donne les indices qui vont bien
        return [self[index] for index in range(*key.indices(len(self)))]

    # queue[3] nous donne pour key un entier
    elif isinstance(key, int):
        index = key
        # on vérifie que l'index a un sens
        if index < 0 or index >= len(self):
            raise IndexError(f"Mauvais indice {index} pour DualQueue")
        # on décide que l'index 0 correspond à l'élément le plus ancien
        # ce qui oblige à une petite gymnastique
        li = len(self.inputs)
        lo = len(self.outputs)
        if index < lo:
            return self.outputs[lo-index-1]
        else:
            return self.inputs[li-(index-lo)-1]
    # queue ['foo'] n'a pas de sens pour nous
    else:
        raise KeyError(f"[] avec type non reconnu {key}")

# et on affecte cette fonction à l'intérieur de la classe
DualQueue.__getitem__ = dual_queue_getitem

Maintenant on peut accéder par slice :

queue[1:3]
['un', 'deux']

Et on reçoit bien une exception si on essaie d’accéder par clé :

try:
    queue['key']
except KeyError as e:
    print(f"OOPS: {type(e).__name__}: {e}")
OOPS: KeyError: '[] avec type non reconnu key'

L’objet est itérable (même sans avoir __iter__)

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 :

# grâce à __getitem__ on a rendu les 
# objets de type DualQueue itérables
for item in queue:
   print(item)
zero
un
deux
trois

On peut faire un test sur l’objet

De manière similaire, même sans la méthode __bool__, cette classe sait faire des tests de manière correcte grâce uniquement à la méthode __len__ :

# un test fait directement sur la queue
if queue:
    print(f"La queue {queue} est considérée comme True")
La queue <DualQueue, inputs=['trois', 'deux'], outputs=['un', 'zero']> est considérée comme True
# le même test sur une queue vide
empty = DualQueue()

# maintenant le test est négatif (notez bien le *not* ici)
if not empty:
    print(f"La queue {empty} est considérée comme False")
La queue <DualQueue, inputs=[], outputs=[]> est considérée comme False

__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(arg1, arg2) ⟺ objet.__call__(arg1, arg2)

Voyons cela sur un exemple :

class PlusClosure:
    """Une classe callable qui permet de faire un peu comme la 
    fonction built-in sum mais en ajoutant une valeur initiale"""
    def __init__(self, initial):
        self.initial = initial
    def __call__(self, *args):
        return self.initial + sum(args)
    
# on crée une instance avec une valeur initiale 2 pour la somme
plus2 = PlusClosure (2)
# on peut maintenant utiliser cet objet 
# comme une fonction qui fait sum(*arg)+2
plus2()
2
plus2(1)
3
plus2(1, 2)
5

Pour ceux qui connaissent, nous avons choisi à dessein un exemple qui s’apparente à une clôture. Nous reviendrons sur cette notion de callable lorsque nous verrons les décorateurs en semaine 9.