Porter un état supplémentaire avec callback en Python

Vous écrivez du code qui repose sur l’utilisation de fonctions de rappel (par exemple, les gestionnaires d’événements, les rappels d’achèvement, etc.), mais vous voulez que la fonction de rappel comporte un état supplémentaire à utiliser dans la fonction de rappel.

Cet article concerne l’utilisation des fonctions de rappel que l’on trouve dans de nombreuses bibliothèques et frameworks, en particulier ceux liés au traitement asynchrone.

Pour illustrer et à des fins de test, définissez la fonction suivante, qui appelle une fonction de rappel:

def apply_async(func, args, *, callback):
    # Calculez le résultat
    result = func(*args)

    # Invoquer le rappel avec le résultat
    callback(result)

En réalité, un tel code pourrait faire toutes sortes de traitements avancés impliquant des threads, des processus et des temporisateurs, mais ce n’est pas l’objectif principal ici.

Au lieu de cela, nous nous concentrons simplement sur l’invocation de la fonction de rappel. Voici un exemple qui montre comment le code précédent est utilisé:

>>> def afficher_resultat(result):
...     print('Résultat:', result)
...
>>> def ajouter(x, y):
...     return x + y
...
>>> apply_async(ajouter, (2, 3), callback=afficher_resultat)
Résultat: 5
>>> apply_async(ajouter, ('hello', 'world'), callback=afficher_resultat)
Résultat: helloworld
>>>

Comme vous le remarquerez, la fonction afficher_resultat() n’accepte qu’un seul argument, qui est le résultat. Aucune autre information n’est transmise.

Ce manque d’information peut parfois poser des problèmes lorsque vous voulez que l’appel interagisse avec d’autres variables ou parties de l’environnement.

Une façon de transporter des informations supplémentaires dans un fonction est d’utiliser une méthode bound au lieu d’une simple fonction. Par exemple, cette classe conserve un numéro de séquence interne qui est incrémenté chaque fois qu’un résultat est reçu:

class ResultHandler:
    def __init__(self):
        self.sequence = 0
    def gestionnaire(self, result):
        self.sequence += 1
        print('[{}] Got: {}'.format(self.sequence, result))

Pour utiliser cette classe, vous devez créer une instance et utiliser le gestionnaire de méthode bound comme fonction de rappel:

>>> r = ResultHandler()
>>> apply_async(ajouter, (2, 3), callback=r.gestionnaire)
[1] Résultat: 5
>>> apply_async(ajouter, ('hello', 'world'), callback=r.gestionnaire)
[2] Résultat: helloworld
>>>

Comme alternative à une classe, vous pouvez également utiliser une fermeture pour capturer l’état. Par exemple:

def make_handler():
    sequence = 0
    def gestionnaire(resultat):
        nonlocal sequence
        sequence += 1
        print('[{}] Résultat: {}'.format(sequence, resultat))
    return gestionnaire

Voici un exemple de cette variante:

>>> gestionnaire = make_handler()
>>> apply_async(ajouter, (2, 3), callback=gestionnaire)
[1] Résultat: 5
>>> apply_async(ajouter, ('hello', 'world'), callback=gestionnaire)
[2] Résultat: helloworld
>>>

Comme une autre variation sur ce thème, vous pouvez parfois utiliser une coroutine pour accomplir la même chose:

def make_handler():
    sequence = 0
    while True:
        resultat = yield
        sequence += 1
        print('[{}] Résultat: {}'.format(sequence, resultat))

Pour une coroutine, vous utiliserez sa méthode envoyer() comme fonction de rappel, comme ceci:

>>> gestionnaire = make_handler()
>>> next(gestionnaire)      
>>> apply_async(ajouter, (2, 3), callback=gestionnaire.send)
[1] Résultat: 5
>>> apply_async(ajouter, ('hello', 'world'), callback=gestionnaire.send)
[2] Résultat: helloworld
>>>

Enfin, et ce n’est pas le moins important, vous pouvez également porter l’état dans une fonction de rappel à l’aide d’un argument supplémentaire et d’une application de fonction partielle. Par exemple:

>>> class SequenceNo:
...     def __init__(self):
...         self.sequence = 0
...
>>> def gestionnaire(resultat, seq):
...     seq.sequence += 1
...     print('[{}] Got: {}'.format(seq.sequence, resultat))
...
>>> seq = SequenceNo()
>>> from functools import partial
>>> apply_async(ajouter, (2, 3), callback=partial(gestionnaire, seq=seq))
[1] Résultat: 5
>>> apply_async(ajouter, ('hello', 'world'), callback=partial(gestionnaire, seq=seq))
[2] Résultat: helloworld
>>>

Les logiciels basés sur les fonctions de rappel risquent souvent de se transformer en un énorme désordre. Une partie du problème est que la fonction de rappel est souvent déconnectée du code qui a fait la requête initiale menant à l’exécution de la fonction.

Ainsi, l’environnement d’exécution entre la formulation de la demande et le traitement du résultat est effectivement perdu.

Si vous voulez que la fonction de rappel continue avec une procédure comportant plusieurs étapes, vous devez trouver comment sauvegarder et restaurer l’état associé.

Il y a vraiment deux approches principales qui sont utiles pour capturer et porter l’état. Vous pouvez le transporter sur une instance (attachée à une méthode bound peut-être) ou vous pouvez le transporter dans une fermeture (une fonction interne).

Des deux techniques, les fermetures sont peut-être un peu plus légères et naturelles dans la mesure où elles sont simplement construites à partir de fonctions. Elles capturent également automatiquement toutes les variables utilisées.

Ainsi, vous n’avez plus à vous soucier de l’état exact à stocker (il est déterminé automatiquement à partir de votre code).

Si vous utilisez des fermetures, vous devez porter une attention particulière aux variables mutables. Dans la solution, la déclaration non locale est utilisée pour indiquer que la variable séquence est modifiée à partir de l’appel de de la fonction. Sans cette déclaration, vous obtiendrez une erreur.

L’utilisation d’une coroutine comme fonction de rappel est intéressante dans la mesure où elle est étroitement liée à l’approche de fermeture. Dans un sens, c’est encore plus propre, puisqu’il n’y a qu’une seule fonction.

De plus, les variables peuvent être librement modifiées sans se soucier des déclarations non locales. L’inconvénient potentiel est que les coroutines n’ont pas tendance à être aussi bien comprises que les autres parties de Python.

Il y a aussi quelques bits délicats tels que le besoin d’appeler next() sur une coroutine avant de l’utiliser. C’est quelque chose qui pourrait être facile à oublier dans la pratique. Néanmoins, les coroutines ont d’autres utilisations potentielles ici, telles que la définition d’une fonction de rappel en ligne.

La dernière technique impliquant partial() est utile si tout ce que vous avez à faire est de passer des valeurs supplémentaires dans une fonction de rappel. Au lieu d’utiliser partial(), vous verrez parfois la même chose avec l’utilisation du mot-clé lambda:

>>> apply_async(ajouter, (2, 3), callback=lambda r: gestionnaire(r, seq))
[1] Résultat: 5
>>>

Pour plus d’exemples, voir l’article sur Functools.partial(), qui montre comment utiliser partial() pour changer les signatures des arguments.

LAISSER UN COMMENTAIRE

Please enter your comment!
Please enter your name here